carlos / platform Public

plan: instance-bound tokens (#155 half 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5471efd995ed5b787298414447f8e19194af5dc1 parent 6f4bb5c
1 file changed, +1063 −0
  • docs/superpowers/plans/2026-10-16-instance-bound-tokens.md +1063 −0

CI cancelled — superseded by a newer push — 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

amadan: job cancelled — a newer push superseded it
diff --git a/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md b/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md
new file mode 100644
index 000000000..68f498800
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-16-instance-bound-tokens.md
@@ -0,0 +1,1063 @@
+# Instance-bound tokens (#155 half 1) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A `cli_tokens` credential bound to one instance incarnation, resolving to a non-mintable role `svcrole.Self` that reaches only its own host on six doors.
+
+**Architecture:** A new token kind `instance` with `host` and `host_born` columns resolves to `Principal{Role: Self, Instance, InstanceBorn}`. `cliRoute` refuses any other `{host}` with `http.NotFound`; a `selfBinding` value travels into every record read on the Self doors and refuses a different incarnation. Admission into doors is through `svcrole.AllowSelf`, never `Allow`, so Admin does not ride along.
+
+**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.
+
+## Global Constraints
+
+- Self is never in `svcrole.All()`, never accepted by `svcrole.Parse`, and `svcrole.Allow(Self)` panics.
+- Every refusal of a host the token is not bound to is `http.NotFound(w, r)`, byte-identical to the stranger's 404 (`canonicalNotFound` in tests).
+- Every write to `cli_tokens` goes through `withAutoAppSQL` (claim gate) with a `claimRaw…` body, as `NewAppToken` does. Tests that write `cli_tokens` also run against the gated store (`OpenGated`, `HostClaim` on); find the existing gated fixture with `grep -rn OpenGated internal/console/*_test.go`.
+- No tenant names in new code, tests or comments: use `shop`, `a.example.com`, `b.example.com` (house rule). Existing fixtures named `titogo` may be reused as they are.
+- User-facing strings are drafts until Paul's copy review (Task 8). Each draft is a named const so the approved text replaces it verbatim. No em dashes in new copy.
+- Go: leave `GOCACHE` unset. Run only the touched packages' tests locally (`go test ./internal/console/ -run 'TestX|TestY'`, `./internal/svcrole/`, the root package `-run`); CI runs the full suite. Unix-socket tests need the sandbox off.
+- Commit per task; message ends with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
+
+## Review Focus
+
+1. A Self token whose host is spelled differently from the record (upper case, trailing dot, percent-encoded): must be the canonical 404, never a match after normalisation. Pinned in Task 3.
+2. A Self token on a door with `{host}` that is NOT one of the six (delete, move mutations, import, channel, runs, db, email instance doors): must be the role 403 even for its own host. Pinned by Task 4's whole-route table.
+3. An app with no instances manifest or no `updates` policy: `instance_control` is empty, so Self can change nothing and install nothing. Pinned in Task 5.
+4. The browser settings page (`browserupdates.go`) shares `readUpdatesTarget`, `applyUpdates` and `requestInstall`: a person there must behave exactly as before. Pinned in Task 5 by running the existing browser updates tests unchanged.
+5. A minting member who later loses membership: the token keeps working (it belongs to the account, like app tokens) but they cannot revoke it from `/settings`; an owner can from the account pane. Pinned in Task 6.
+
+---
+
+### Task 1: `svcrole.Self` and `AllowSelf`
+
+**Files:**
+- Modify: `internal/svcrole/svcrole.go`
+- Test: `internal/svcrole/svcrole_test.go`
+
+**Interfaces:**
+- Produces: `svcrole.Self Role = "self"`; `func AllowSelf(base Set) Set`.
+
+- [ ] **Step 1: Write the failing tests** (append to `svcrole_test.go`)
+
+```go
+func TestSelfIsNotMintable(t *testing.T) {
+ if _, ok := Parse("self"); ok {
+ t.Fatal(`Parse("self") succeeded; Self must never be stored on a service row`)
+ }
+ for _, r := range All() {
+ if r == Self {
+ t.Fatal("Self is in All(); the mint door and form would offer it")
+ }
+ }
+}
+
+func TestAllowRefusesSelf(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("Allow(Self) did not panic; it would admit Admin with it")
+ }
+ }()
+ Allow(Self)
+}
+
+func TestAllowSelfAddsOnlySelf(t *testing.T) {
+ closed := AllowSelf(Set{})
+ if !closed.Permits(Self) {
+ t.Fatal("AllowSelf(Set{}) refuses Self")
+ }
+ for _, r := range All() {
+ if closed.Permits(r) {
+ t.Fatalf("AllowSelf(Set{}) admits %q", r)
+ }
+ }
+ open := AllowSelf(Allow(Instance))
+ for _, r := range []Role{Self, Instance, Admin} {
+ if !open.Permits(r) {
+ t.Fatalf("AllowSelf(Allow(Instance)) refuses %q", r)
+ }
+ }
+ if open.Permits(Publish) {
+ t.Fatal("AllowSelf widened the base set")
+ }
+ base := Allow(Instance)
+ _ = AllowSelf(base)
+ if base.Permits(Self) {
+ t.Fatal("AllowSelf mutated its argument")
+ }
+ if closed.Empty() {
+ t.Fatal("AllowSelf(Set{}) reads as a closed door")
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect FAIL** (`undefined: Self`, `undefined: AllowSelf`)
+
+Run: `go test ./internal/svcrole/ -run 'TestSelf|TestAllowRefusesSelf|TestAllowSelf'`
+
+- [ ] **Step 3: Implement** (in `svcrole.go`, after the `catalogue` var)
+
+```go
+// Self is what a kind='instance' credential resolves to: the authority of
+// one instance over itself (spec 2026-10-16-instance-bound-tokens §3.3).
+//
+// It is deliberately NOT in the catalogue. Parse refuses it, so no service
+// row can be minted with it, and All omits it, so no mint door or form can
+// offer it. Allow refuses it too, because Allow adds Admin to every set and
+// Admin is app-wide: a door opened to Self through Allow would open to every
+// Admin credential, which has no host to be checked against. AllowSelf is
+// the only way a door admits it.
+const Self Role = "self"
+
+// AllowSelf returns base with Self added and nothing else: AllowSelf(Set{})
+// admits Self alone (not Admin), AllowSelf(Allow(Instance)) admits Instance,
+// Admin and Self. base is not modified.
+func AllowSelf(base Set) Set {
+ s := Set{roles: make(map[Role]bool, len(base.roles)+1)}
+ for r := range base.roles {
+ s.roles[r] = true
+ }
+ s.roles[Self] = true
+ return s
+}
+```
+
+`Allow` already panics for any role `Parse` refuses, so `Allow(Self)` panics with no change to `Allow`. Check that `Roles()` sorts Self in with the rest (it does: it reads the map).
+
+- [ ] **Step 4: Run, expect PASS**, plus the package: `go test ./internal/svcrole/`
+- [ ] **Step 5: Commit** `svcrole: Self, outside the catalogue, and AllowSelf (#155)`
+
+---
+
+### Task 2: The token kind in the store
+
+**Files:**
+- Modify: `internal/console/authstore.go` (schema ALTERs, `cliKindInstance`, `CLIToken`, `scanCLITokenRows`, `CLITokens` query, `NeverExpires`, `IsInstance`)
+- Modify: `internal/console/svcprincipal.go` (`Principal`, `CLIPrincipal`, `ServicePrincipals`, `principalKey`, new `NewInstanceToken`, new `RevokeInstanceTokens`)
+- Modify: `internal/console/apicli.go` (`auditActor`)
+- Modify: `internal/console/store.go:1736`, `:1928`, `:1970`, `:2074`, `:2089` and `internal/console/claim_app_identity.go:182` (kind lists)
+- Test: `internal/console/instancetoken_store_test.go` (new)
+
+**Interfaces:**
+- Consumes: `svcrole.Self` (Task 1).
+- Produces:
+ - `const cliKindInstance = "instance"`
+ - `Principal.Instance string`, `Principal.InstanceBorn int64`
+ - `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>"`
+
+- [ ] **Step 1: Write the failing tests** in `instancetoken_store_test.go`:
+
+```go
+package console
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+const testBorn = int64(1_789_000_000_123_456_789)
+
+func mintInstanceToken(t *testing.T, st *Store, accountID int64, app, host string, born int64) string {
+ t.Helper()
+ raw, err := st.NewInstanceTokenContext(context.Background(), accountID, app, host, born, "alice@keymail.dev", "", time.Now())
+ if err != nil {
+ t.Fatalf("NewInstanceTokenContext(%s): %v", host, err)
+ }
+ return raw
+}
+
+func TestCLIPrincipalInstanceRowIsSelf(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ raw := mintInstanceToken(t, fx.st, OpsAccountID, "shop", "a.example.com", testBorn)
+ p, ok := fx.st.CLIPrincipal(raw, time.Now())
+ if !ok {
+ t.Fatal("instance token did not resolve")
+ }
+ want := Principal{Kind: PrincipalService, Address: "alice@keymail.dev", AccountID: OpsAccountID,
+ App: "shop", Role: svcrole.Self, Instance: "a.example.com", InstanceBorn: testBorn}
+ if p.Kind != want.Kind || p.Address != want.Address || p.AccountID != want.AccountID || p.App != want.App ||
+ p.Role != want.Role || p.Instance != want.Instance || p.InstanceBorn != want.InstanceBorn || p.Name != "" || p.Rungs != nil {
+ t.Fatalf("principal = %+v, want %+v", p, want)
+ }
+}
+
+// Every malformed instance row fails to resolve rather than resolving to
+// something wider. Written with raw SQL because no minter can produce them.
+func TestCLIPrincipalRefusesMalformedInstanceRows(t *testing.T) {
+ fx := newConsoleFixture(t)
+ cases := map[string]string{
+ "no app": `UPDATE cli_tokens SET app = '' WHERE token_hash = ?`,
+ "bad host": `UPDATE cli_tokens SET host = '../x' WHERE token_hash = ?`,
+ "no host": `UPDATE cli_tokens SET host = '' WHERE token_hash = ?`,
+ "no born": `UPDATE cli_tokens SET host_born = 0 WHERE token_hash = ?`,
+ "a role": `UPDATE cli_tokens SET role = 'admin' WHERE token_hash = ?`,
+ "a name": `UPDATE cli_tokens SET name = 'hub' WHERE token_hash = ?`,
+ "rungs": `UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`,
+ "no acct": `UPDATE cli_tokens SET account_id = 0 WHERE token_hash = ?`,
+ "revoked": `UPDATE cli_tokens SET revoked_at = 1 WHERE token_hash = ?`,
+ }
+ for name, stmt := range cases {
+ raw := mintInstanceToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
+ if _, err := fx.st.db.Exec(stmt, hashToken(raw)); err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if p, ok := fx.st.CLIPrincipal(raw, time.Now()); ok {
+ t.Errorf("%s: resolved to %+v, want no principal", name, p)
+ }
+ }
+}
+
+func TestInstanceTokenKeysArePerHost(t *testing.T) {
+ a := Principal{Kind: PrincipalService, AccountID: 7, App: "shop", Role: svcrole.Self, Instance: "a.example.com", InstanceBorn: 1}
+ 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" {
+ 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")
+ }
+ acct := &Account{ID: 7, Sqid: "k7"}
+ if got := auditActor(a, acct); got != "instance:k7/shop/a.example.com" {
+ t.Fatalf("auditActor = %q", got)
+ }
+}
+
+func TestInstanceTokensListRevokeAndNeverExpire(t *testing.T) {
+ fx := newConsoleFixture(t)
+ raw := mintInstanceToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
+ rows, err := fx.st.ServicePrincipals(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var row CLIToken
+ for _, r := range rows {
+ if r.ID == hashToken(raw) {
+ row = r
+ }
+ }
+ if !row.IsInstance() || row.Host != "a.example.com" || row.Role != string(svcrole.Self) || !row.NeverExpires() || row.App != "kass" {
+ t.Fatalf("listed row = %+v", row)
+ }
+ if row.Label != "a.example.com instance token" {
+ t.Fatalf("default label = %q", row.Label)
+ }
+ if err := fx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := fx.st.CLIPrincipal(raw, time.Now()); ok {
+ t.Fatal("revoked instance token still resolves")
+ }
+}
+```
+
+Add, in the same file, the lifecycle tests, each copying the setup of the existing app-token twin named beside it:
+
+- `TestPurgeDeletesInstanceTokens`: copy `TestRenameAppMovesServicePrincipalToo`'s fixture style; purge an app holding an instance row; assert `SELECT COUNT(*) FROM cli_tokens WHERE kind='instance'` is 0.
+- `TestTrashKeepsInstanceTokens`: trash then restore; the original token still resolves.
+- `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.
+- Run each of these against both the plain fixture and the gated one.
+
+- [ ] **Step 2: Run, expect FAIL** (undefined `NewInstanceTokenContext`, fields).
+
+Run: `go test ./internal/console/ -run 'TestCLIPrincipalInstance|TestCLIPrincipalRefusesMalformed|TestInstanceToken|TestPurgeDeletesInstance|TestTrashKeepsInstance|TestRenameAndMoveCarryInstance|TestStaleInstanceRows|TestOldConsoleSQL'`
+
+- [ ] **Step 3: Implement.**
+
+`authstore.go`: add to the ALTER list
+
+```go
+ `ALTER TABLE cli_tokens ADD COLUMN host TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE cli_tokens ADD COLUMN host_born INTEGER NOT NULL DEFAULT 0`,
+```
+
+and `cliKindInstance = "instance"` to the kind consts with a comment: "bound to one incarnation of one instance (spec 2026-10-16 §3); an older console resolves no such row". `CLIToken` gains `Host string // instance tokens only`. Add:
+
+```go
+// IsInstance reports whether this row is an instance-bound token, for the
+// same reason IsApp exists: a template cannot see cliKindInstance.
+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`.
+
+`svcprincipal.go`: `Principal` gains
+
+```go
+ // Instance and InstanceBorn bind a kind='instance' credential to one
+ // incarnation of one host: Instance is the host, InstanceBorn the
+ // record's CreatedAt in Unix nanoseconds at mint. Both are zero for
+ // every other kind, and cliRoute and selfBinding read Instance != "" as
+ // "this caller may act only on that host".
+ Instance string
+ InstanceBorn int64
+```
+
+`CLIPrincipal` selects `host, host_born` too (`var host string; var born int64`) and gains, before `default:`,
+
+```go
+ case cliKindInstance:
+ // Fail closed on anything a minter would never write: a row bound
+ // to no app, no host or no incarnation is not read as bound to
+ // more, and any role, name or rungs is a row this console did not
+ // write.
+ 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}
+```
+
+(import `internal/provision` if not already imported; check for an import cycle with `go build ./internal/console/` and, if there is one, use the console's existing host-shape helper instead — `grep -n ValidHostShape internal/console/*.go | head`).
+
+Minter and revoker, following `NewAppToken`'s wrapper pattern exactly:
+
+```go
+// NewInstanceTokenContext mints a credential bound to one incarnation of one
+// instance (spec 2026-10-16 §3, §5). born is the record's CreatedAt in Unix
+// nanoseconds; mintedBy is the member, kept so a human stays answerable.
+// Never expires, like an app token: revocation and the instance's own life
+// (§3.4) are the controls.
+func (s *Store) NewInstanceTokenContext(ctx context.Context, accountID int64, app, host string, born int64, mintedBy, label string, now time.Time) (raw string, err error) {
+ if accountID == 0 || app == "" || host == "" || born == 0 {
+ return "", errors.New("console: an instance token needs an account, an app, a host and an incarnation")
+ }
+ if label == "" {
+ label = fmt.Sprintf(instanceTokenDefaultLabel, host)
+ }
+ err = s.withAutoAppSQL(ctx, accountID, app, "sql.NewInstanceToken", func(view *Store) error {
+ raw, err = view.claimRawNewInstanceToken(accountID, app, host, born, mintedBy, label, now)
+ return err
+ })
+ return raw, err
+}
+
+func (s *Store) claimRawNewInstanceToken(accountID int64, app, host string, born int64, mintedBy, label string, now time.Time) (string, error) {
+ raw, err := randomToken()
+ if err != nil {
+ return "", err
+ }
+ if _, err := s.db.Exec(`INSERT INTO cli_tokens
+(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app, host, host_born)
+VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?, ?)`,
+ hashToken(raw), mintedBy, label, now.Unix(), cliKindInstance, accountID, app, host, born); err != nil {
+ return "", err
+ }
+ return raw, nil
+}
+
+// RevokeInstanceTokensContext soft-revokes every live token bound to host,
+// whatever incarnation it names. Zero rows is not an error: the delete door
+// calls it for every host, minted or not.
+func (s *Store) RevokeInstanceTokensContext(ctx context.Context, accountID int64, app, host string, now time.Time) error {
+ return s.withAutoAppSQL(ctx, accountID, app, "sql.RevokeInstanceTokens", func(view *Store) error {
+ _, err := view.db.Exec(`UPDATE cli_tokens SET revoked_at = ?
+WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND revoked_at = 0`,
+ now.Unix(), cliKindInstance, accountID, app, host)
+ return err
+ })
+}
+```
+
+The default label is a copy-review draft; declare it in a new file `internal/console/instancetoken_words.go` that Task 8 rewrites:
+
+```go
+package console
+
+// Drafts for #155 half 1, awaiting Paul's copy review (plan Task 8). The
+// approved text replaces each value verbatim.
+const (
+ instanceTokenDefaultLabel = "%s instance token"
+)
+```
+
+If the claim layer lints raw SQL outside `claimRaw…` functions (check: `grep -rn "claimRaw" internal/console/*_test.go | grep -i "ast\|lint" | head`), move the UPDATE in `RevokeInstanceTokensContext` into a `claimRawRevokeInstanceTokens` method.
+
+`principalKey` and `auditActor` gain, as their FIRST service branch (before `p.Name == ""`):
+
+```go
+ if p.Instance != "" {
+ return "instance:" + strconv.FormatInt(p.AccountID, 10) + "/" + p.App + "/" + p.Instance
+ }
+```
+
+```go
+ if p.Instance != "" {
+ // Before the Name test: an instance row has no name either, and
+ // must not be filed as the app token.
+ return "instance:" + acct.Sqid + "/" + p.App + "/" + p.Instance
+ }
+```
+
+Kind lists:
+- `store.go:1736` and `claim_app_identity.go:182` (purge, DELETE): `kind IN (?, ?, ?)` adding `cliKindInstance`.
+- `store.go:1970` and `:2089` (the UPDATEs that carry credentials with a move/rename): add `cliKindInstance`.
+- `store.go:1928` and `:2074` (the destination stale COUNTs): leave as `(cliKindApp, cliKindService)`, with a comment: "Instance rows are not counted: one left at the destination by an older console's purge is bound to a record incarnation that no longer exists, so it can only 404 (spec 2026-10-16 §3.4)."
+- Also grep the rename path: `grep -n "kind IN" internal/console/*.go` and apply the same rule to every hit, recording each in the commit message.
+
+- [ ] **Step 4: Run, expect PASS.** Then the neighbours: `go test ./internal/console/ -run 'TestCLIPrincipal|TestAppToken|TestRenameApp|TestMoveApp|TestPurge|TestServicePrincipal|TestTokens'`
+- [ ] **Step 5: Commit** `console: the instance token kind in the store (#155)`
+
+---
+
+### Task 3: The gate
+
+**Files:**
+- Modify: `internal/console/authcli.go` (`cliRoute`)
+- Modify: `internal/console/apiinstances.go` (`instanceGateSaying` limiter key)
+- Create: `internal/console/selfbinding.go`
+- Test: `internal/console/instancetoken_gate_test.go` (new)
+
+**Interfaces:**
+- Consumes: Task 2's `Principal.Instance`, `InstanceBorn`, `principalKey`.
+- Produces:
+
+```go
+type selfBinding struct {
+ Host string
+ Born int64
+}
+func bindingOf(p Principal) selfBinding
+func (b selfBinding) refuses(rec *provision.Record) bool
+```
+
+- [ ] **Step 1: Write the failing tests.** In `instancetoken_gate_test.go`, a test server with three throwaway doors registered through `cliRoute` on a fresh `Server` from the console fixture (`fx.srv` — check the fixture's field name with `grep -n "type consoleFixture" -A15 internal/console/*_test.go`):
+
+```go
+func TestCLIRouteSelfHostCheck(t *testing.T) {
+ fx := newConsoleFixture(t)
+ raw := mintInstanceToken(t, fx.st, OpsAccountID, "kass", "a.example.com", testBorn)
+ hit := 0
+ fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/instances/{host}/probe", svcrole.AllowSelf(svcrole.Set{}),
+ func(w http.ResponseWriter, r *http.Request) { hit++; w.WriteHeader(http.StatusNoContent) })
+ want := canonicalNotFound(t)
+ base := fx.ts.URL + "/api/cli/test/" + fx.sqid + "/kass/instances/"
+ for _, host := range []string{"b.example.com", "A.example.com", "a.example.com.", "a.example.com%2e", "nope"} {
+ st, h, body := rawReq(t, http.MethodGet, base+host+"/probe", raw, "")
+ assertNotFound(t, "self token on "+host, want, st, h, body)
+ }
+ if hit != 0 {
+ t.Fatalf("handler ran %d times for hosts the token is not bound to", hit)
+ }
+ if st, _, body := rawReq(t, http.MethodGet, base+"a.example.com/probe", raw, ""); st != http.StatusNoContent {
+ t.Fatalf("own host = %d %s", st, body)
+ }
+}
+
+func TestCLIRoutePanicsOnSelfDoorWithoutHost(t *testing.T) {
+ fx := newConsoleFixture(t)
+ defer func() {
+ if recover() == nil {
+ t.Fatal("a Self door without {host} registered")
+ }
+ }()
+ fx.srv.cliRoute("GET /api/cli/test/{account}/{app}/nohost", svcrole.AllowSelf(svcrole.Set{}), func(http.ResponseWriter, *http.Request) {})
+}
+
+func TestSelfBindingRefusesAnotherIncarnation(t *testing.T) {
+ b := selfBinding{Host: "a.example.com", Born: testBorn}
+ same := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn)}
+ reborn := &provision.Record{Host: "a.example.com", CreatedAt: time.Unix(0, testBorn+1)}
+ other := &provision.Record{Host: "b.example.com", CreatedAt: time.Unix(0, testBorn)}
+ if b.refuses(same) || !b.refuses(reborn) || !b.refuses(other) || !b.refuses(nil) {
+ t.Fatal("selfBinding.refuses wrong")
+ }
+ if (selfBinding{}).refuses(reborn) {
+ t.Fatal("the zero binding refused a record")
+ }
+}
+
+func TestInstanceLimiterIsPerHost(t *testing.T) {
+ // Two tokens for host A share one budget on a real door; a token for B
+ // is unaffected. Set s.cli.instances to a burst of 2 for this test; find
+ // how other tests shrink it: grep -n "cli.instances" internal/console/*_test.go
+}
+```
+
+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.
+
+- [ ] **Step 2: Run, expect FAIL.**
+- [ ] **Step 3: Implement.**
+
+`cliRoute`, at the top (registration time):
+
+```go
+ if allow.Permits(svcrole.Self) && !strings.Contains(pattern, "{host}") {
+ // A Self door can only ever answer for one host; one without a host
+ // in its path would 404 every request (spec §4.1).
+ panic("console: " + pattern + " admits svcrole.Self but has no {host}")
+ }
+```
+
+and in the handler, straight after the role gate:
+
+```go
+ // The instance-bound credential's host check (spec §4.1). Like the
+ // role gate it reads only the path and the caller's own binding, so
+ // it leaks nothing, and its answer is the stranger's 404 byte for
+ // byte. Exact comparison: another spelling of the same host fails
+ // closed.
+ if p.Instance != "" && r.PathValue("host") != p.Instance {
+ http.NotFound(w, r)
+ return
+ }
+```
+
+`selfbinding.go`:
+
+```go
+package console
+
+import "github.com/carlosframework/platform/internal/provision"
+
+// selfBinding is the instance incarnation a Self principal may act on
+// (spec 2026-10-16 §4.3). The zero value, every other caller's, binds
+// nothing and refuses nothing. It travels as a value into every helper that
+// reads the record, because a check made once in a handler misses a retry
+// that re-reads a replacement record.
+type selfBinding struct {
+ Host string
+ Born int64
+}
+
+func bindingOf(p Principal) selfBinding {
+ return selfBinding{Host: p.Instance, Born: p.InstanceBorn}
+}
+
+// refuses reports whether rec is not the incarnation b names. Callers answer
+// http.NotFound when it does, before anything that depends on rec.
+func (b selfBinding) refuses(rec *provision.Record) bool {
+ if b.Host == "" {
+ return false
+ }
+ return rec == nil || rec.Host != b.Host || rec.CreatedAt.UnixNano() != b.Born
+}
+```
+
+`instanceGateSaying`'s limiter:
+
+```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).
+ key := hashToken(bearer(r))
+ if p, _ := principalFrom(r.Context()); p.Instance != "" {
+ key = principalKey(p)
+ }
+ if !s.cli.instances.allow(key, time.Now()) {
+```
+
+- [ ] **Step 4: Run, expect PASS**; then `go test ./internal/console/ -run 'TestCLIRoute|TestEveryCLIDoor|TestAPIInstance'`.
+- [ ] **Step 5: Commit** `console: cliRoute's host check and the self binding (#155)`
+
+---
+
+### Task 4: GET, PATCH and move status admit Self
+
+**Files:**
+- Modify: `internal/console/authcli.go` (three registrations)
+- Modify: `internal/console/apiinstances.go` (`handleAPIInstanceGet`, `handleAPIInstanceUpdate`)
+- Modify: `internal/console/apiinstancemove.go` (`moveGate`)
+- Modify: `internal/console/instancetoken_words.go` (one draft)
+- Modify: `internal/console/svcroutes_test.go` (pins)
+- Test: `internal/console/instancetoken_doors_test.go` (new)
+
+**Interfaces:**
+- Consumes: `svcrole.AllowSelf`, `bindingOf`, `selfBinding.refuses`.
+- Produces: the registered door set below; `selfDoors` (test var) listing all six patterns, used by Task 5.
+
+- [ ] **Step 1: Write the failing tests.**
+
+In `instancetoken_doors_test.go`:
+
+```go
+// The six doors a Self principal may pass (spec §4.2). Task 5 adds the
+// three update doors' behaviour; their registration is pinned here.
+var selfDoors = []string{
+ "GET /api/cli/apps/{account}/{app}/instances/{host}",
+ "PATCH /api/cli/apps/{account}/{app}/instances/{host}",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/move",
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/updates",
+ "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates",
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install",
+}
+
+func TestSelfRoleDoors(t *testing.T) {
+ fx := newConsoleFixture(t)
+ want := map[string]bool{}
+ for _, d := range selfDoors {
+ want[d] = true
+ }
+ for pattern, set := range fx.srv.cliRoles {
+ if set.Permits(svcrole.Self) != want[pattern] {
+ t.Errorf("%s: admits Self = %v, want %v", pattern, set.Permits(svcrole.Self), want[pattern])
+ }
+ }
+}
+```
+
+The containment table: `TestSelfTokenContainment` builds the `instanceFixture`, enables instances with a manifest for `shop` whose domain covers `a.example.com` and `b.example.com` (copy `titoManifest`, change names), creates both records through the create door with the member token, reads A's record to get `CreatedAt`, mints a Self token for A with that born, then walks EVERY entry of `fx.srv.cliRoles`:
+
+- doors not in `selfDoors`: request with wildcards filled (`{host}` = A's host; reuse the replacer from `TestEveryClosedDoorRefusesAServiceCredential`) → status 403;
+- `selfDoors`, `{host}` = B → canonical 404; `{host}` = `c.example.com` (no record) → canonical 404;
+- after each request, A's and B's records (read with `provision.GetRecordETag`) have unchanged ETags, except for the PATCH on A below.
+- GET on A → 200 and the body's `host` is A.
+- PATCH on A with `{"probe":{"path":"/healthz","status":200}}` → 200.
+- PATCH on A with each of `{"environment":"x"}`, `{"steering":"latency"}`, `{"gate":"pw-123456"}`, and `{"probe":null,"environment":""}` → 403 with body containing the draft `selfPatchFieldsRefused` text; A's ETag unchanged afterwards; the app's environment list and store seed marker unchanged (read them the way `seedenvbind_test.go` does).
+- Move status on A → whatever a member gets (compare status codes with the member token).
+
+`TestSelfTokenDiesWithItsIncarnation`: delete A through the delete door with the member token, but first set a test hook that skips the revoke (Task 6 adds the revoke; for now there is none, so no hook yet: write the test so it holds both before and after Task 6 by deleting the token row's revoked mark directly: `UPDATE cli_tokens SET revoked_at = 0`), re-create A, then every Self door on A answers the canonical 404, and a move-status read when the re-created A has `kind: "static"` answers the canonical 404, not the kind refusal.
+
+In `svcroutes_test.go`: `TestInstanceRoleMatchesTodaysAppTokenReach` must still pass unchanged (the three base sets keep `Instance`). Remove the three update doors from `closedDoors` (Task 5 opens them; do it in this task so the pins move together), change the `seen != 60` expectation and its sentence to 57 and drop "three instance update settings doors," from the sentence. Add the three update doors to a new pin in `TestAdminIsRefusedAtAClosedDoor`'s neighbourhood:
+
+```go
+func TestAppWideCredentialsStillRefusedAtUpdateDoors(t *testing.T) {
+ fx := newConsoleFixture(t)
+ for _, role := range []svcrole.Role{svcrole.Admin, svcrole.Instance} {
+ tok := serviceToken(t, fx.st, OpsAccountID, "c-"+string(role), "kass", role)
+ for _, d := range selfDoors[3:] {
+ method, path, _ := strings.Cut(d, " ")
+ u := strings.NewReplacer("{account}", fx.sqid, "{app}", "kass", "{host}", "web.go.tito.io").Replace(path)
+ if st, _, body := rawReq(t, method, fx.ts.URL+u, tok, `{}`); st != http.StatusForbidden {
+ t.Errorf("%s as %s = %d %s, want 403", d, role, st, body)
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect FAIL.**
+- [ ] **Step 3: Implement.**
+
+Registrations in `authcli.go`:
+
+```go
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceGet)
+ s.cliRoute("PATCH /api/cli/apps/{account}/{app}/instances/{host}", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceUpdate)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.AllowSelf(svcrole.Allow(svcrole.Instance)), s.handleAPIInstanceMoveStatus)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesGet)
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesPut)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.AllowSelf(svcrole.Set{}), s.handleAPIUpdatesInstall)
+```
+
+Rewrite the comments above them: the update doors' "Person-only until #155 step 6" becomes "People and the instance's own credential (#155, spec 2026-10-16 §4.2). Still closed to Instance and Admin: an app-wide credential would let one instance's code set a sibling's pin." Also update the file comment at `apiupdates.go:12-15` and `instanceupdates/control.go:4` to say the instance-bound credential has shipped and writes go through the console.
+
+`handleAPIInstanceGet`, straight after the successful `GetRecord`:
+
+```go
+ p, _ := principalFrom(r.Context())
+ if bindingOf(p).refuses(rec) {
+ http.NotFound(w, r)
+ return
+ }
+```
+
+`handleAPIInstanceUpdate`, straight after `readJSON` succeeds and before anything else reads `req`:
+
+```go
+ if p.Instance != "" && (req.Environment != nil || req.Steering != nil || req.Gate != nil) {
+ // An instance's own credential may repoint its upstreams and probe,
+ // nothing else (spec §4.4.1): environment alone would bind it to a
+ // sibling's bundle and its secrets.
+ jsonError(w, http.StatusForbidden, selfPatchFieldsRefused)
+ return
+ }
+```
+
+and straight after its `GetRecordETag` succeeds:
+
+```go
+ if bindingOf(p).refuses(rec) {
+ http.NotFound(w, r)
+ return
+ }
+```
+
+(`p` is already in scope there from `principalFrom`.) The write stays `provision.UpdateRecord(ctx, …, *rec, etag)` on that ETag.
+
+`moveGate`, straight after its `GetRecord` succeeds and BEFORE the kind check:
+
+```go
+ p, _ := principalFrom(r.Context())
+ if bindingOf(p).refuses(rec) {
+ http.NotFound(w, r)
+ return nil, "", "", nil, false
+ }
+```
+
+Draft in `instancetoken_words.go`:
+
+```go
+ selfPatchFieldsRefused = "an instance's own credential can change addrs and probe only. environment, steering and gate need a member of the app"
+```
+
+- [ ] **Step 4: Run, expect PASS**; then `go test ./internal/console/ -run 'TestSelf|TestAppWide|TestEveryCLIDoor|TestEveryClosedDoor|TestInstanceRoleMatches|TestNoRoleReaches|TestAPIInstance|TestAPIInstanceMove'`.
+- [ ] **Step 5: Commit** `console: GET, PATCH and move status admit an instance's own credential (#155)`
+
+---
+
+### Task 5: Update settings from the instance itself
+
+**Files:**
+- Modify: `internal/console/updatescore.go` (`readUpdatesTarget`, `applyUpdates`, `requestInstall`)
+- Modify: `internal/console/apiupdates.go` (three handlers)
+- Modify: `internal/console/browserupdates.go` (pass `selfBinding{}`)
+- Test: `internal/console/instancetoken_updates_test.go` (new)
+
+**Interfaces:**
+- Consumes: `selfBinding`, `bindingOf`, `selfDoors`.
+- Produces (new signatures):
+
+```go
+func (s *Server) readUpdatesTarget(ctx context.Context, acct *Account, app, host string, b selfBinding) (*provision.Record, string, *provision.UpdatePolicy, int, string)
+func (s *Server) applyUpdates(ctx context.Context, acct *Account, app, principal string, b selfBinding, rec *provision.Record, etag string, policy *provision.UpdatePolicy, req map[string]any) (updatesView, int, string)
+func (s *Server) requestInstall(ctx context.Context, acct *Account, app, principal string, b selfBinding, host string, fast bool) (uint64, int, string)
+```
+
+- [ ] **Step 1: Write the failing tests** in `instancetoken_updates_test.go`. Setup: copy `TestUpdatesDoorsRefuseAppWideCredentials`' fixture (`apiupdates_test.go:458`) for a record and an update policy, set the policy's `instance_control` through the member-only policy door, mint a Self token for the record.
+
+Cases, each a subtest:
+- `instance_control = []`: PUT `{"updates":"manual"}` → 403, body is `fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), "updates")`; the record's ETag is unchanged.
+- `instance_control = ["pin"]`: PUT `{"pin":"","updates":"manual"}` raw JSON with pin FIRST → 403 naming `updates` (fixed order, not request order).
+- `instance_control = ["updates"]`: PUT `{"updates":"manual"}` → 200, record `Updates == "manual"`, an activity row whose actor is `instance:<sqid>/<app>/<host>`.
+- inherited value: app `default_updates = manual`, record `Updates = ""`, `instance_control = []`: PUT the GET response's `explicit` object minus `channel` → 200 and no write (ETag unchanged); PUT `{"updates":"manual"}` (the effective value) → 403 naming `updates`.
+- `{"channel":"stable"}` (unchanged) → 400 `settings.console.channel-elsewhere`, as for a member.
+- no manifest at all: PUT `{"updates":"auto"}` where the record already has `""` → 403 naming `updates`.
+- install with `instance_control = []` → 403 naming `updates`; with `["updates"]` → 200 and `install_request.id` advanced; with `["updates"]` and `{"fast_track":true}` → 403 `settings.console.fast-track-owner`, and a test store spy (or a log assertion) shows `Store.Role` was never called with the instance actor. If there is no seam for the spy, assert instead that an account OWNER's own fast-track still works and that the Self request is refused before any record read (ETag unchanged, no audit row).
+- incarnation during install retry: install from Self, with a store wrapper (find the existing CAS-conflict injection in `apiupdates_test.go` or `updatescore_test.go`: `grep -n "ErrCASConflict" internal/console/*updates*_test.go`) that, on the first `UpdateRecord`, deletes the record and re-creates it with a new `CreatedAt` before returning the conflict → canonical 404, and neither the old nor the new record carries an install request.
+- a sibling host on all three doors → canonical 404 (already covered by Task 4's table; assert once here for PUT with a valid body to be sure the body is never parsed into a write).
+- Run `go test ./internal/console/ -run 'TestBrowserUpdates|TestAPIUpdates|TestUpdates'` unchanged as the regression for Review Focus 4.
+
+- [ ] **Step 2: Run, expect FAIL.**
+- [ ] **Step 3: Implement.**
+
+`readUpdatesTarget`, straight after the record read succeeds:
+
+```go
+ if b.refuses(rec) {
+ // Answered exactly as a host with no record: the caller is bound to
+ // another incarnation (spec §4.3).
+ return nil, "", nil, http.StatusNotFound, ""
+ }
+```
+
+`applyUpdates`, after `next` is fully computed from `req` and before `provision.ValidateUpdates`:
+
+```go
+ if b.Host != "" {
+ if key, refused := selfRefusedKey(*rec, next, policy); refused {
+ return updatesView{}, http.StatusForbidden, fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), key)
+ }
+ }
+```
+
+with, in `updatescore.go`:
+
+```go
+// selfRefusedKey is §4.4.2: the first of updates, major and pin, in that
+// fixed order, whose EXPLICIT value this request changes and which the app's
+// instance_control does not name. Effective values are not compared: echoing
+// an inherited value back would write an override.
+func selfRefusedKey(before, after provision.Record, policy *provision.UpdatePolicy) (string, bool) {
+ may := map[string]bool{}
+ if policy != nil {
+ for _, k := range policy.InstanceControl {
+ may[k] = true
+ }
+ }
+ switch {
+ case before.Updates != after.Updates && !may["updates"]:
+ return "updates", true
+ case !sameMajor(before.Major, after.Major) && !may["major"]:
+ return "major", true
+ case before.Pin != after.Pin && !may["pin"]:
+ return "pin", true
+ }
+ return "", false
+}
+```
+
+`requestInstall`: pass `b` to `readUpdatesTarget` on every attempt, keep the policy it returns (`rec, etag, policy, st, msg := …`), and after `shapeRefusal`:
+
+```go
+ if b.Host != "" && !policyNames(policy, "updates") {
+ return 0, http.StatusForbidden, fmt.Sprintf(updatewords.Text("settings.console.app-keeps"), "updates")
+ }
+```
+
+with a small `policyNames(policy *provision.UpdatePolicy, key string) bool` helper beside `selfRefusedKey`.
+
+Handlers in `apiupdates.go`: each reads `p, _ := principalFrom(r.Context())` and passes `bindingOf(p)` into `updatesTarget` (give `updatesTarget` a `b selfBinding` parameter and forward it), `applyUpdates` and `requestInstall`. In `handleAPIUpdatesInstall`, before the existing fast-track owner check:
+
+```go
+ if req.FastTrack && p.Instance != "" {
+ jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner"))
+ return
+ }
+```
+
+`browserupdates.go:78`, `:244`, `:281`, `:305`: pass `selfBinding{}`.
+
+- [ ] **Step 4: Run, expect PASS**, plus the regression list above.
+- [ ] **Step 5: Commit** `console: an instance sets its own update settings within instance_control (#155)`
+
+---
+
+### Task 6: The mint door, revoke on delete, and the credential surfaces
+
+**Files:**
+- Create: `internal/console/apiinstancetoken.go` (the mint handler)
+- Modify: `internal/console/authcli.go` (register the door; `serviceAccountSqids`; `serviceRowOf` and `handleCLITokenRevoke` branch)
+- Modify: `internal/console/apiinstances.go` (`handleAPIInstanceDelete`)
+- Modify: `internal/console/apiservices.go` (`serviceWhat`, `serviceView` gains `Host string \`json:"host,omitempty"\``)
+- Modify: `internal/console/templates/settings.html` and the account credentials pane template (find it: `grep -ln "IsApp" internal/console/templates/*.html`)
+- Modify: `internal/console/instancetoken_words.go` (drafts)
+- Modify: `internal/console/svcroutes_test.go` (add the mint door to `closedDoors`, count 58)
+- Test: `internal/console/instancetoken_mint_test.go` (new)
+
+**Interfaces:**
+- Consumes: `NewInstanceTokenContext`, `RevokeInstanceTokensContext`.
+- Produces: `POST /api/cli/apps/{account}/{app}/instances/{host}/token` → 201 `{"token","account","app","host"}`.
+
+- [ ] **Step 1: Write the failing tests:**
+- mint as a member → 201; the token resolves; its `host_born` equals the record's `CreatedAt.UnixNano()`; an activity row `instance-token` names the host.
+- mint for a host with no record → canonical 404; for a record whose `CreatedAt` is zero (write it with `provision.WriteRecord` directly) → 409 with `instanceTokenNoBirth`.
+- every service role, Admin included, and a Self token → 403 closed-door; a stranger person → the family 404 (copy `TestAPIInstanceTokenMintEndToEnd`'s shape).
+- delete A through the delete door → A's tokens are revoked (`CLIPrincipal` fails) before the record is gone; with a store hook making `RevokeInstanceTokensContext` fail → 500 and the record still exists.
+- personal revoke: Alice mints, is removed from the account, signs in, posts `/auth/cli/tokens/revoke` for the row → the "no longer a member" flash and the row is live; a current member who minted it can revoke it there.
+- `GET` services API lists the row with `role: "self"` and `host`.
+- the settings page renders the row with `instanceTokenRowLabel` (render and grep the HTML).
+
+- [ ] **Step 2: Run, expect FAIL.**
+- [ ] **Step 3: Implement.**
+
+`apiinstancetoken.go`:
+
+```go
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// handleAPIInstanceTokenMint mints a credential bound to one incarnation of
+// one instance (spec 2026-10-16 §5). People only, for the app-token mint's
+// reason: a credential never mints a credential. The zero Set at cliRoute is
+// the outer wall, apiPerson the inner one.
+func (s *Server) handleAPIInstanceTokenMint(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable, "this console has no deployment bucket configured")
+ return
+ }
+ host := r.PathValue("host")
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var req struct {
+ Label string `json:"label"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the instance")
+ return
+ }
+ if rec.CreatedAt.IsZero() {
+ jsonError(w, http.StatusConflict, fmt.Sprintf(instanceTokenNoBirth, host))
+ return
+ }
+ raw, err := s.store.NewInstanceTokenContext(r.Context(), acct.ID, app, host, rec.CreatedAt.UnixNano(), addr, cleanLabel(req.Label), time.Now())
+ if err != nil {
+ log.Printf("console: api instance token %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusInternalServerError, "could not mint the token — try again")
+ return
+ }
+ s.audit(acct, app, addr, "instance-token", "minted an instance token for "+host+" (cli)")
+ writeJSON(w, http.StatusCreated, map[string]any{"token": raw, "account": acct.Sqid, "app": app, "host": host})
+}
+```
+
+(The 429, 503 and 500 sentences are the app-token mint's existing ones, copied, so they need no review. Check the exact 503 text the instance family uses, `mainGateCopy().noBucket`, and use that.)
+
+Register beside the app-token mint in `authcli.go`, with the zero Set and a comment pointing at the mint door's reasoning:
+
+```go
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/token", svcrole.Set{}, s.handleAPIInstanceTokenMint)
+```
+
+`handleAPIInstanceDelete`: after the host shape check and the instance gate, BEFORE the record delete:
+
+```go
+ // Revoke first (spec §3.4): a failed revoke deletes nothing, and a
+ // failed delete after it leaves a live instance with no token, which a
+ // member mints again. The incarnation check is the guarantee; this is
+ // tidiness.
+ if err := s.store.RevokeInstanceTokensContext(ctx, acct.ID, app, host, time.Now()); err != nil {
+ log.Printf("console: api instances delete %s/%s/%s: revoke instance tokens: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusInternalServerError, "could not delete the instance — try again")
+ return
+ }
+```
+
+Read the handler first: if its delete is idempotent for an absent record, the revoke still runs (harmless, zero rows).
+
+`serviceRowOf`/`handleCLITokenRevoke`: change the classify so an instance row takes the `svc.IsApp()` branch: `case svc.IsApp() || svc.IsInstance():`. Check `serviceRowOf`'s `isService` return: it must stay false for instance rows (they are not "service credentials" with a name, so the first branch's flash, which prints `svc.Name`, never sees one). `serviceAccountSqids`: `if !(t.IsService() || t.IsInstance()) || t.AccountID == 0 { continue }`.
+
+`serviceWhat`: before the app-credential fallback,
+
+```go
+ if row.IsInstance() {
+ return row.App, fmt.Sprintf(instanceTokenRevokeWhat, row.Host, row.App)
+ }
+```
+
+`serviceView` gains `Host` filled from `t.Host`. Templates: where a row's label reads `{{if .IsApp}}…{{end}}`, add `{{else if .IsInstance}}` rendering `instanceTokenRowLabel` with the host (pass it through a template func or a `CLIToken` method `InstanceLabel() string` that formats the draft const; templates cannot read Go consts).
+
+Drafts in `instancetoken_words.go`:
+
+```go
+ instanceTokenNoBirth = "%s has no creation time on its record, so a token cannot be bound to it. Delete and create it again"
+ instanceTokenRevokeWhat = "instance credential for %s in %s"
+ instanceTokenRowLabel = "bound to %s"
+```
+
+- [ ] **Step 4: Run, expect PASS**; then `go test ./internal/console/ -run 'TestAPIInstanceToken|TestCLITokenRevoke|TestSettings|TestService|TestEveryClosedDoor|TestEveryCLIDoor|TestAPIInstanceDelete'`.
+- [ ] **Step 5: Commit** `console: mint an instance token; delete revokes it; credential surfaces name the host (#155)`
+
+---
+
+### Task 7: Pin the creation call sites
+
+**Files:**
+- Test: `internal/console/instancetoken_creation_test.go` (new)
+
+- [ ] **Step 1: Write the test.** Parse every non-test `.go` file under `internal/` and the repo root with `go/parser`; for each call expression whose function is `provision.CreateRecord`, `CreateRecord` inside package `provision`, `hostops.PrepareInstance` or `PrepareInstance` inside package `hostops`, record `file:enclosing func`. Compare against:
+
+```go
+// Every path that creates an instance record (spec 2026-10-16 §3.4). Each
+// stamps CreatedAt from the clock (or, for PrepareInstance, refuses a
+// supplied one and stamps its own), which is what makes an instance token's
+// incarnation binding sound. A new caller fails this test: review it against
+// that rule, then add it here.
+var reviewedCreationSites = map[string]bool{
+ "internal/console/apiinstances.go:handleAPIInstanceCreate": true,
+ "internal/console/apiinstanceseed.go:<func>": true,
+ "internal/console/apiinstanceimport.go:<func>": true,
+ "internal/console/domainattach.go:<func>": true,
+ "internal/provision/replica.go:<func>": true,
+}
+```
+
+Fill the `<func>` names from the first run's output (read each site before adding it and check it stamps the clock; `apiinstanceseed.go` and `apiinstanceimport.go` create from `c.rec`, stamped by the create door). Include the `hostops.PrepareInstance` caller(s) the same way. The test is in `internal/console` only because that is where the others live; it reads files by path relative to the module root (find the root with `go list -m -f '{{.Dir}}'` at test time via `os/exec`, or walk up from `os.Getwd()` to `go.mod`).
+
+- [ ] **Step 2: Run; adjust the map to the reviewed set; run again, PASS.**
+- [ ] **Step 3: Commit** `console: pin the instance record creation sites (#155)`
+
+---
+
+### Task 8: CLI verb, docs and copy review
+
+**Files:**
+- Modify: `cli.go` (`runInstances`: `case "token": runInstancesToken(args[1:])`)
+- Modify: `instances_cli.go` (`runInstancesToken`, usage lines)
+- Modify: `apimode.go` (`func (s *apiSession) instanceToken(ctx context.Context, a apiAcct, app, host, label string) (string, error)`)
+- Modify: `docs/site/cli.md` (the `carlos instances` section)
+- Modify: `internal/console/instancetoken_words.go` (approved text)
+- Test: `instances_cli_test.go` (the root package's existing httptest pattern for `instanceDelete`)
+
+- [ ] **Step 1: Failing test**: against an httptest console stub (copy the `instanceDelete` client test), `instanceToken` POSTs `{"label":…}` to `…/instances/<escaped host>/token` and returns the token from a 201; a non-201 returns `apiErrorFromBody`.
+- [ ] **Step 2: Implement** the client method (shape of `instanceDelete`, with `s.postJSON`; check its name with `grep -n "func (s \*apiSession) postJSON" apimode.go`), and the verb:
+
+```go
+func runInstancesToken(args []string) {
+ fs := flag.NewFlagSet("instances token", flag.ExitOnError)
+ app := appFlag(fs, "app the instance belongs to")
+ host := fs.String("host", "", "instance host the token is bound to")
+ label := fs.String("label", "", "label shown on the account's credentials pane")
+ consoleFlag := consoleOption(fs, "console to mint through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
+ fs.Usage = flagUsage(fs, instancesRecordsUsage)
+ fs.Parse(args)
+ if *host == "" {
+ log.Fatal(instancesRecordsUsage)
+ }
+ sess := requireAPISession("token", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
+ tok, err := sess.instanceToken(context.Background(), acct, app2, strings.TrimSpace(*host), *label)
+ if err != nil {
+ log.Fatalf("instances token: %v", err)
+ }
+ fmt.Fprintln(stdout, tok)
+ fmt.Fprintf(os.Stderr, instancesTokenNotice+"\n", *host)
+}
+```
+
+The token goes to stdout alone (so `$(carlos instances token …)` captures it); the notice goes to stderr. Add the usage line ` carlos instances token --app <a> --host <h> [--label <l>]` to `instancesRecordsUsage` and a draft:
+
+```go
+// instancesTokenNotice is a copy-review draft (#155, plan Task 8).
+const instancesTokenNotice = "this token acts only on %s, and only while this instance exists. It is shown once"
+```
+
+The docs section in `docs/site/cli.md`: a short paragraph under `carlos instances` (draft, reviewed with the rest).
+
+- [ ] **Step 3: Copy review.** Invoke the `copy-review` skill with every draft: `instanceTokenDefaultLabel`, `selfPatchFieldsRefused`, `instanceTokenNoBirth`, `instanceTokenRevokeWhat`, `instanceTokenRowLabel`, `instancesTokenNotice`, the usage line and the docs paragraph. Before applying, count each string's occurrences at the base commit (house rule: shared strings need their own review). Apply the approved text verbatim. Read `result.json` before restarting the review server, never after.
+- [ ] **Step 4: Run** `go test . -run 'TestInstances'` and `go test ./internal/console/ -run 'TestSelf|TestAPIInstanceToken|TestService'`.
+- [ ] **Step 5: Commit** `carlos instances token, docs, and the reviewed copy (#155)`
+
+---
+
+## After the tasks
+
+- Whole-branch review by Astra (Opus is the session model): a brief naming the spec, the plan, the branch range, and the Review Focus list.
+- Merge per AGENTS.md: re-merge main, wait for CI green on the tip, `amadan branch merge carlos/platform instance-bound-tokens-155 -squash -expect <tip> -message "…"`. Never run `amadan branch merge` bare.
+- Not in this branch: release, roll (dev first), and half 2.