oficina / meet Public

plan: slice 5, the drive surface

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev e657ba959ffba7fee0f0d735143efbfa982f07dd parent 1c66309
1 file changed, +352 −0
  • docs/superpowers/plans/2026-09-11-meet-slice-5-drive.md +352 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-11-meet-slice-5-drive.md b/docs/superpowers/plans/2026-09-11-meet-slice-5-drive.md
new file mode 100644
index 0000000..1114075
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-11-meet-slice-5-drive.md
@@ -0,0 +1,352 @@
+# Meet slice 5: the drive surface — 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 member mints an agent token from Settings; an agent with that token can list, create, link and end meetings over a JSON API, exactly as far as its operator's current standing reaches and never any further; every write it makes is marked as the agent's on the meeting list.
+
+**Architecture:** One new table, `agents`, holds a name, the operator, the SHA-256 of a token shown once, and a revocation time. A bearer middleware on `/api/v1/*` finds the agent by token hash, refuses a revoked one, and re-derives the operator's member standing on every request (§8.1: an operator who leaves takes the agent's reach with them, no revocation step). The five operations of §8.2 reuse the store's existing meeting functions with actor `agent:<id>`. The list shows a robot badge and the principal `<name>~<operator email>` on meetings an agent created. Cookies are never honoured on the API and bearers never on HTML routes.
+
+**Tech Stack:** Go 1.25 (rastrillo v0.26.0, chi, gorm), `rastrillo migration generate`, the vendored ui partials.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` revision 9: §2.5 (the `/api/v1/*` row), §7.2 (actors), §8 (the drive surface: §8.1 principals and reach, §8.2 operations, §8.3 MCP deferred, §8.4 native clients), §12 item 5, §13 (the slice-5 gate: the API reach check after an operator's removal), §15. Suite discussion 4 (agents as their own principal `claude~paul` style, bound to the operator's reach).
+
+## Global Constraints
+
+- `CGO_ENABLED=0`; `make ci` on every task; `make race` where named; the browser gate is untouched by this slice and must stay 31/31 on the final head (run once with the sandbox off).
+- Schema change: the `agents` table only, added with `rastrillo migration generate` after editing `models.go`; read the SQL it writes; never edit a shipped migration.
+- Every query touching user-owned rows goes through `scope.Owned` where the framework applies it; a row that isn't yours 404s, never 403s. Never bind a request body onto a GORM model: explicit `map[string]any` + `.Select` allowlist, or a typed request struct copied field by field.
+- The token is shown exactly once (the Settings page after minting) and stored only as a SHA-256 hex; it never reaches a log line, a response other than the mint page, or an error message. Actor strings are `person:<id>` and `agent:<id>` (§7.2).
+- On the API: `Authorization: Bearer <token>` only; a cookie is ignored; a missing or malformed bearer is 401 `{"error":"unauthorized"}`; an unknown, revoked, or reach-less token is 404 `{"error":"not-found"}` byte-identical to an unknown meeting's 404, so nothing about token existence or operator standing is observable. On HTML routes a bearer is ignored.
+- Reach is re-derived on every API request: the agent unrevoked **and** the operator currently a member (`isMember`); a change in the allowlist takes effect on the next request (§13's gate).
+- JSON bodies are capped at 16 KB (`http.MaxBytesReader`, 413 `{"error":"too-large"}`); malformed or unknown fields are 400 `{"error":"bad-request"}` (`DisallowUnknownFields`). Titles 1..200 characters, `starts_at` RFC 3339 or absent.
+- The only two responses that ever carry a link token are creation and replacement (§8.2). `GET /api/v1/meetings/{sqid}` returns the count of admitted peers while the meeting runs, never names or chat.
+- A per-agent token bucket on the API: 60 requests per minute, burst 60, 429 with `Retry-After` (reuse `rateLimiter` with key `agent:<id>`).
+- Copy review is waived this round per suite discussion 5; every new string is catalogued in `docs/superpowers/copy/2026-09-10-slice-2-strings.md` under "## Slice 5".
+- Screens work without JavaScript; the Settings page and its confirm pages are plain forms.
+- Commits: `git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit`, files by name, never `.claude/`, `node_modules` or `package-lock.json`; trailers `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2`.
+
+---
+
+## File structure
+
+- `internal/meet/models.go` — `Agent`; `internal/meet/migrations/0002_agents.sql` (generated).
+- `internal/meet/agents.go` — the token, store methods: `createAgent`, `agentsFor`, `revokeAgent`, `agentByToken`, `agentByID`; `principal`.
+- `internal/meet/handlers_settings.go`, `templates/settings.html`, `templates/agent_once.html` — the Settings page, the mint result, the revoke confirm page.
+- `internal/meet/api.go` — the bearer middleware, `apiJSON`, the five operations; `internal/meet/hub/count.go` — `AdmittedCount(sqid)`.
+- `internal/meet/store.go` (`meetingView` gains the creator's principal), `templates/index.html` (the badge).
+- `internal/meettest/agents_test.go`, `api_test.go`.
+- `docs/superpowers/reviews/2026-09-11-slice-5-record.md`, spec §15.17.
+
+---
+
+### Task 1: The `agents` table and store
+
+**Files:**
+- Modify: `internal/meet/models.go` (add `Agent`, register in `Models`), `internal/meet/migrations/` (generate `0002_agents.sql`)
+- Create: `internal/meet/agents.go`, `internal/meet/agents_test.go`
+
+**Interfaces:**
+- Produces:
+ ```go
+ type Agent struct {
+ ID int64
+ Name string `gorm:"not null"`
+ OperatorPersonID int64 `gorm:"index;not null"`
+ TokenHash string `gorm:"uniqueIndex;not null"`
+ CreatedAt time.Time
+ RevokedAt *time.Time
+ }
+ func newAgentToken() (token, hash string, err error) // token "mt_" + base64url(32 random bytes); hash = hex(SHA-256(token))
+ func hashAgentToken(token string) string
+ func (s *store) createAgent(ctx, name string, operator Person) (Agent, string /* token, once */, error)
+ func (s *store) agentsFor(ctx, operator Person) ([]Agent, error) // newest first, revoked included
+ func (s *store) revokeAgent(ctx, id int64, operator Person) (bool, error) // false when not the operator's or already revoked
+ func (s *store) agentByToken(ctx, token string) (Agent, Person /* operator */, bool, error) // false for unknown or revoked
+ func (s *store) agentByID(ctx, id int64) (Agent, Person, bool, error)
+ func principal(ag Agent, operator Person) string // "<name>~<operator email>"
+ func actorForAgent(ag Agent) string // "agent:<id>"
+ ```
+- Name rules: 1..64 characters after trimming, letters, digits, `-` and `_` only (`^[A-Za-z0-9_-]{1,64}$`), because it appears in the principal before `~`.
+
+- [ ] **Step 1: Write the failing tests** (`internal/meet/agents_test.go`; use the package's existing test store helper, see `store_test.go`)
+
+```go
+package meet
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) {
+ s := testStore(t)
+ ctx := context.Background()
+ op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
+ ag, token, err := s.createAgent(ctx, "claude", op)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(token, "mt_") || len(token) < 40 {
+ t.Fatalf("token shape %q", token)
+ }
+ if ag.TokenHash != hashAgentToken(token) || ag.TokenHash == token || len(ag.TokenHash) != 64 {
+ t.Fatalf("stored %q for %q", ag.TokenHash, token)
+ }
+ got, opGot, ok, err := s.agentByToken(ctx, token)
+ if err != nil || !ok || got.ID != ag.ID || opGot.ID != op.ID {
+ t.Fatalf("lookup %+v %+v %v %v", got, opGot, ok, err)
+ }
+ if _, _, ok, _ := s.agentByToken(ctx, "mt_nope"); ok {
+ t.Fatal("an unknown token must not resolve")
+ }
+ if principal(ag, op) != "claude~paul@example.com" {
+ t.Fatalf("principal %q", principal(ag, op))
+ }
+ if actorForAgent(ag) != "agent:"+itoa(ag.ID) {
+ t.Fatalf("actor %q", actorForAgent(ag))
+ }
+}
+
+func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) {
+ s := testStore(t)
+ ctx := context.Background()
+ op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
+ other := Person{ID: 2, Email: "bea@example.com", Name: "bea"}
+ ag, token, _ := s.createAgent(ctx, "claude", op)
+ if ok, _ := s.revokeAgent(ctx, ag.ID, other); ok {
+ t.Fatal("another person revoked the agent")
+ }
+ if ok, _ := s.revokeAgent(ctx, ag.ID, op); !ok {
+ t.Fatal("the operator could not revoke")
+ }
+ if ok, _ := s.revokeAgent(ctx, ag.ID, op); ok {
+ t.Fatal("revoking twice must report false")
+ }
+ if _, _, ok, _ := s.agentByToken(ctx, token); ok {
+ t.Fatal("a revoked token must not resolve")
+ }
+ list, _ := s.agentsFor(ctx, op)
+ if len(list) != 1 || list[0].RevokedAt == nil {
+ t.Fatalf("list %+v", list)
+ }
+ if list2, _ := s.agentsFor(ctx, other); len(list2) != 0 {
+ t.Fatal("another person's list must be empty")
+ }
+}
+
+func TestAgentNameRules(t *testing.T) {
+ s := testStore(t)
+ ctx := context.Background()
+ op := Person{ID: 1, Email: "paul@example.com"}
+ for _, bad := range []string{"", " ", "with space", "tilde~x", "colon:x", strings.Repeat("a", 65), "é"} {
+ if _, _, err := s.createAgent(ctx, bad, op); err == nil {
+ t.Fatalf("accepted name %q", bad)
+ }
+ }
+ if _, _, err := s.createAgent(ctx, " claude-2_ok ", op); err != nil {
+ t.Fatalf("trimmed valid name refused: %v", err)
+ }
+}
+```
+
+(If the package has no `testStore` helper or `itoa`, write minimal ones in the test file, opening an in-memory database through the same `db.Open` path the harness uses and applying `Schema`.)
+
+- [ ] **Step 2: Run to see them fail**: compile error.
+
+- [ ] **Step 3: Implement.** Add `Agent` to `models.go` and to the `Models` list, then run `rastrillo migration generate` (through `go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generate` from the module root, as the Makefile's migration-check does) and read the SQL it writes into `migrations/0002_agents.sql`: it must be a `CREATE TABLE agents` with the unique index on `token_hash` and the index on `operator_person_id`, nothing else. Then `agents.go`:
+
+```go
+package meet
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+
+var errAgentName = errors.New("meet: an agent name is 1 to 64 letters, digits, - or _")
+
+// newAgentToken mints "mt_" + 32 random bytes, base64url without
+// padding, and its SHA-256 hex. The token exists in plaintext only on
+// its way to the one page that shows it (§8.1).
+func newAgentToken() (token, hash string, err error) {
+ var b [32]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", "", err
+ }
+ token = "mt_" + base64.RawURLEncoding.EncodeToString(b[:])
+ return token, hashAgentToken(token), nil
+}
+
+func hashAgentToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email }
+
+func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) }
+
+func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) {
+ name = strings.TrimSpace(name)
+ if !agentNameRe.MatchString(name) {
+ return Agent{}, "", errAgentName
+ }
+ token, hash, err := newAgentToken()
+ if err != nil {
+ return Agent{}, "", err
+ }
+ ag := Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()}
+ if err := s.g.WithContext(ctx).Create(&ag).Error; err != nil {
+ return Agent{}, "", err
+ }
+ return ag, token, nil
+}
+
+func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) {
+ var out []Agent
+ err := s.g.WithContext(ctx).Where("operator_person_id = ?", operator.ID).Order("created_at DESC, id DESC").Find(&out).Error
+ return out, err
+}
+
+// revokeAgent revokes one of the operator's own agents. False when the
+// row is not theirs or is already revoked: a 404 shape, never a 403.
+func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (bool, error) {
+ res := s.g.WithContext(ctx).Model(&Agent{}).
+ Where("id = ? AND operator_person_id = ? AND revoked_at IS NULL", id, operator.ID).
+ Update("revoked_at", s.now().UTC())
+ return res.RowsAffected == 1, res.Error
+}
+
+// agentByToken resolves a bearer to its agent and operator. Unknown and
+// revoked both answer false; the caller must not tell them apart.
+func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) {
+ var ag Agent
+ err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Agent{}, Person{}, false, nil
+ }
+ if err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ var op Person
+ if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Agent{}, Person{}, false, nil
+ }
+ return Agent{}, Person{}, false, err
+ }
+ return ag, op, true, nil
+}
+
+func (s *store) agentByID(ctx context.Context, id int64) (Agent, Person, bool, error) {
+ var ag Agent
+ err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Agent{}, Person{}, false, nil
+ }
+ if err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ var op Person
+ if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ return ag, op, true, nil
+}
+
+var _ = time.Now
+```
+
+- [ ] **Step 4: Gate**: `make ci` (its migration check must agree), `make race`.
+- [ ] **Step 5: Commit** `agents: the table, the once-shown token, the store` with the trailers, files by name.
+
+---
+
+### Task 2: Settings: mint, show once, revoke
+
+**Files:**
+- Create: `internal/meet/handlers_settings.go`, `internal/meet/templates/settings.html`, `internal/meet/templates/agent_once.html`
+- Modify: `internal/meet/app.go` (routes inside the `requireMember` group: `GET /settings`, `POST /settings/agents`, `GET /settings/agents/{id}/revoke`, `POST /settings/agents/{id}/revoke`), `internal/meet/templates/layout.html` or the topbar (a "Settings" link for members), `internal/meet/templates/index.html` (nothing yet; Task 4)
+- Test: `internal/meettest/agents_test.go`
+
+**Interfaces:**
+- `GET /settings` lists the member's agents (name, created, revoked or the principal) with a create form (`name`) and a Revoke link per live agent; `POST /settings/agents` mints and renders `agent_once.html` with the token in a readonly input (the `link_once.html` pattern) and the principal; `GET /settings/agents/{id}/revoke` is the confirm page (`ui/confirm-form`); `POST` revokes and 303s to `/settings`; a foreign or unknown id 404s.
+- Strings: "Settings", "Agents", "An agent acts on your behalf with exactly your reach, as <name>~<your address>.", "Name", "Create an agent", "This is the only time this token is shown. Copy it now.", "Revoke", "Revoke <name>? Anything using its token stops working at once.", "Revoked", "No agents yet."
+
+- [ ] **Step 1: Write the failing tests** (meettest, the harness's `memberWithMeeting`/`browser`/`signIn`/`get`/`post` helpers): a member sees an empty Settings page; creating an agent renders the token once (the page contains `mt_` and the principal; a second GET of Settings does not contain the token); the Revoke confirm page and POST work for the operator and 404 for another member (`bea@rslw.com`) and for a guest; the token never appears in the captured log (`newHarnessWithLogger`).
+- [ ] **Step 2: Implement** the handlers in the file's existing style (`page(r, data)`, `ErrorPage`, `actorFor(p)`), the templates with the vendored partials (`field` for the name, `confirm-form` for revoke, `back-nav`, `page-header`, `empty-state`), and the Settings link in the shell for members.
+- [ ] **Step 3: Gate, catalogue the strings, commit** `settings: mint an agent token once, revoke it`.
+
+---
+
+### Task 3: The API: bearer, reach, five operations
+
+**Files:**
+- Create: `internal/meet/api.go`, `internal/meet/hub/count.go` (+ test), `internal/meettest/api_test.go`
+- Modify: `internal/meet/app.go` (a route group `/api/v1` outside the session group, with `csrf.Protect` NOT applied? Keep the router's global `csrf.Protect`: it is origin-based and exempts requests without an Origin; confirm an agent's POST with no Origin header passes; if it does not, mount the API before `csrf.Protect` and say so), `internal/meet/ratelimit.go` only if a constructor is needed.
+
+**Interfaces:**
+- `func (h *Hub) AdmittedCount(sqid string) int` — under `h.mu`, the number of admitted participations in the room, 0 for no room.
+- Middleware `a.requireAgent`: reads `Authorization`, requires `Bearer mt_…` (else 401 `{"error":"unauthorized"}`), `agentByToken` (false → 404 not-found body), `a.isMember(operator)` (false → the same 404), then `a.apiLimiter.allow("agent:"+id, now)` (false → 429 with `Retry-After`), puts `agent` and `operator` on the context. Cookies are never read here. Logs one line per refusal with `agent_id` (only when resolved) and `outcome`; never the token or its hash; nothing on success.
+- JSON shapes:
+ - `GET /api/v1/meetings` → `200 {"meetings":[{"sqid","title","starts_at":RFC3339|null,"ended_at":RFC3339|null,"link_present":bool,"created_by":"<principal or person name>"}]}`
+ - `POST /api/v1/meetings` body `{"title":"…","starts_at":"RFC3339"?}` → `201 {"meeting":{…},"link":"https://…/j/<token>"}` (the link URL through `a.linkURL(token)`), actor `agent:<id>`
+ - `POST /api/v1/meetings/{sqid}/links` → `200 {"meeting":{…},"link":"…"}`; ended meeting → 409 `{"error":"ended"}`
+ - `POST /api/v1/meetings/{sqid}/end` → `200 {"meeting":{…}}`; already ended → 409 `{"error":"ended"}`
+ - `GET /api/v1/meetings/{sqid}` → `200 {"meeting":{…},"admitted":N}` (`admitted` present only while `ended_at` is null; it is `hub.AdmittedCount`)
+ - unknown sqid → 404 `{"error":"not-found"}`; malformed body/unknown field → 400 `{"error":"bad-request"}`; > 16 KB → 413 `{"error":"too-large"}`; title outside 1..200 → 400.
+ - Every response `Content-Type: application/json`, `Cache-Control: no-store`.
+
+- [ ] **Step 1: Write the failing tests** (`api_test.go`, with a helper `mintAgent(t, x, c) (token string)` that drives Settings as the member):
+
+```go
+func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) // no header → 401; a signed-in cookie alone → 401; "Bearer nope" → 404 with the not-found body; Basic → 401
+func TestAPIReachIsTheOperatorsStandingOnEveryRequest(t *testing.T) // list → 200; x.h.SetAllowlist("bea@rslw.com") (operator removed) → next list 404 with the not-found body; SetAllowlist back → 200 again; revoke via Settings → 404
+func TestAPIListsCreatesLinksAndEnds(t *testing.T) // create with title and starts_at → 201, link contains "/j/mt"?? no: the LINK token (not the agent token); GET the meeting → admitted 0; a member joins through the browser harness and is admitted → admitted 1; replace link → a different link; the old link 404s at /j/; end → 200, ended_at set, admitted absent; end again → 409; links on ended → 409; list shows created_by "claude~paul@example.com"
+func TestAPIBodyRules(t *testing.T) // unknown field 400; title 201 chars 400; empty title 400; bad starts_at 400; 17 KB body 413; a title with "<script>" is stored and listed escaped-as-JSON (a plain string)
+func TestAPILogsCarryNoToken(t *testing.T) // newHarnessWithLogger: a success, a 404 refusal, a 401; the buffer never contains the token, its hash, the link token, or the title
+func TestAPIRateLimitsPerAgent(t *testing.T) // 61 GETs → the 61st is 429 with Retry-After; a second agent is unaffected
+func TestAPIWritesAreMarkedAsTheAgents(t *testing.T) // after a create, the member's HTML list shows the robot badge and the principal for that meeting and not for a member-created one (Task 4's assertion, write it here and let it fail until Task 4)
+```
+
+Also `hub/count_test.go`: `AdmittedCount` is 0 for no room, counts admitted only, and never a waiting guest.
+
+- [ ] **Step 2: Implement** `api.go` with a small `apiJSON(w, status, body)` writer, the middleware, and five handlers that call the store (`listMeetings`, `createMeeting`, `meetingBySqid`, `replaceLink`, `endMeeting`) with `actorForAgent(ag)`; `hub/count.go`. Refusal bodies through one `apiNotFound()` constant map. No handler reads a cookie. The per-agent limiter: `newRateLimiter(1, 60, 4096)` (or the existing constructor's shape) stored on `app` as `apiLimiter`.
+- [ ] **Step 3: Gate**: `make ci`, `make race`. **Commit** `api: /api/v1 meetings for agents, reach re-derived per request`.
+
+---
+
+### Task 4: The badge on the list, the record, the spec note
+
+**Files:**
+- Modify: `internal/meet/store.go` (`meetingView` gains `CreatedByPrincipal string` and `ByAgent bool`, filled by resolving `created_by` prefixes `agent:<id>` through `agentByID`; `person:<id>` stays as today), `internal/meet/templates/index.html` (a robot badge through `ui/badge` with the lucide `bot` icon from `internal/meet/icons`, and the principal after the title: "by claude~paul@example.com"), `internal/meet/icons/` (add `bot` if absent, following the file's pattern), `docs/superpowers/copy/2026-09-10-slice-2-strings.md` ("## Slice 5"), `docs/superpowers/specs/2026-09-07-meet-design.md` (§15.17: the API wire, the 401/404 rule, the per-agent limit, the token shape, the actor and principal), `docs/superpowers/reviews/2026-09-11-slice-5-record.md`
+- Test: `internal/meettest/api_test.go`'s badge test now passes; `internal/meettest/room_test.go` untouched.
+
+- [ ] **Step 1: Implement the view and the badge; make Task 3's badge test pass.**
+- [ ] **Step 2: Write §15.17 and the record (rulings from the ledger, per-task dispositions, carried items, the gate note that the browser gate is unchanged and must still pass on the final head, the deploy note: one migration, applied at boot, `agents` table).**
+- [ ] **Step 3: Gate**: `make ci`, `make race`, and `make browser` once (sandbox off) on the head. **Commit** `list: the robot badge and the principal on an agent's meetings; docs`.
+
+---
+
+## Self-review
+
+**Spec coverage (§12 item 5):** `agents` (1); the five operations (3); the per-request reach check (3, and §13's gate in `TestAPIReachIsTheOperatorsStandingOnEveryRequest`); the badge (4). §8.1's Settings mint and revoke (2). §8.3 deferred by the spec. §8.4: the API is plain JSON with a bearer, usable by a native client.
+
+**Placeholders:** none; Task 2's handlers follow the named files' existing style with the partials named.
+
+**Type consistency:** `Agent`, `createAgent`, `agentsFor`, `revokeAgent`, `agentByToken`, `agentByID`, `principal`, `actorForAgent` (1) used in 2, 3, 4; `hub.AdmittedCount` (3); `meetingView.CreatedByPrincipal/ByAgent` (4) read by 3's badge test.
+
+**Rulings for the controller:** token shape `mt_` + 32 random bytes base64url (Slides has no implementation to copy; §8.1 says instance-minted); unknown, revoked and reach-less tokens all answer the not-found body, a missing bearer 401; `end` and `links` on an ended meeting are 409 `ended`; the per-agent limit is 60/min; `created_by` on the API is the principal for agents and the person's display name for members.