| 1 | # Meet slice 5: the drive surface — Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **Tech Stack:** Go 1.25 (rastrillo v0.26.0, chi, gorm), `rastrillo migration generate`, the vendored ui partials. |
| 10 | |
| 11 | **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). |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - `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). |
| 16 | - 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. |
| 17 | - 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. |
| 18 | - 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). |
| 19 | - 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. |
| 20 | - 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). |
| 21 | - 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. |
| 22 | - 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. |
| 23 | - A per-agent token bucket on the API: 60 requests per minute, burst 60, 429 with `Retry-After` (reuse `rateLimiter` with key `agent:<id>`). |
| 24 | - 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". |
| 25 | - Screens work without JavaScript; the Settings page and its confirm pages are plain forms. |
| 26 | - 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`. |
| 27 | |
| 28 | --- |
| 29 | |
| 30 | ## File structure |
| 31 | |
| 32 | - `internal/calls/models.go` — `Agent`; `internal/calls/migrations/0002_agents.sql` (generated). |
| 33 | - `internal/calls/agents.go` — the token, store methods: `createAgent`, `agentsFor`, `revokeAgent`, `agentByToken`, `agentByID`; `principal`. |
| 34 | - `internal/calls/handlers_settings.go`, `templates/settings.html`, `templates/agent_once.html` — the Settings page, the mint result, the revoke confirm page. |
| 35 | - `internal/calls/api.go` — the bearer middleware, `apiJSON`, the five operations; `internal/calls/hub/count.go` — `AdmittedCount(sqid)`. |
| 36 | - `internal/calls/store.go` (`meetingView` gains the creator's principal), `templates/index.html` (the badge). |
| 37 | - `internal/callstest/agents_test.go`, `api_test.go`. |
| 38 | - `docs/superpowers/reviews/2026-09-11-slice-5-record.md`, spec §15.17. |
| 39 | |
| 40 | --- |
| 41 | |
| 42 | ### Task 1: The `agents` table and store |
| 43 | |
| 44 | **Files:** |
| 45 | - Modify: `internal/calls/models.go` (add `Agent`, register in `Models`), `internal/calls/migrations/` (generate `0002_agents.sql`) |
| 46 | - Create: `internal/calls/agents.go`, `internal/calls/agents_test.go` |
| 47 | |
| 48 | **Interfaces:** |
| 49 | - Produces: |
| 50 | ```go |
| 51 | type Agent struct { |
| 52 | ID int64 |
| 53 | Name string `gorm:"not null"` |
| 54 | OperatorPersonID int64 `gorm:"index;not null"` |
| 55 | TokenHash string `gorm:"uniqueIndex;not null"` |
| 56 | CreatedAt time.Time |
| 57 | RevokedAt *time.Time |
| 58 | } |
| 59 | func newAgentToken() (token, hash string, err error) // token "mt_" + base64url(32 random bytes); hash = hex(SHA-256(token)) |
| 60 | func hashAgentToken(token string) string |
| 61 | func (s *store) createAgent(ctx, name string, operator Person) (Agent, string /* token, once */, error) |
| 62 | func (s *store) agentsFor(ctx, operator Person) ([]Agent, error) // newest first, revoked included |
| 63 | func (s *store) revokeAgent(ctx, id int64, operator Person) (bool, error) // false when not the operator's or already revoked |
| 64 | func (s *store) agentByToken(ctx, token string) (Agent, Person /* operator */, bool, error) // false for unknown or revoked |
| 65 | func (s *store) agentByID(ctx, id int64) (Agent, Person, bool, error) |
| 66 | func principal(ag Agent, operator Person) string // "<name>~<operator email>" |
| 67 | func actorForAgent(ag Agent) string // "agent:<id>" |
| 68 | ``` |
| 69 | - Name rules: 1..64 characters after trimming, letters, digits, `-` and `_` only (`^[A-Za-z0-9_-]{1,64}$`), because it appears in the principal before `~`. |
| 70 | |
| 71 | - [ ] **Step 1: Write the failing tests** (`internal/calls/agents_test.go`; use the package's existing test store helper, see `store_test.go`) |
| 72 | |
| 73 | ```go |
| 74 | package meet |
| 75 | |
| 76 | import ( |
| 77 | "context" |
| 78 | "strings" |
| 79 | "testing" |
| 80 | ) |
| 81 | |
| 82 | func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) { |
| 83 | s := testStore(t) |
| 84 | ctx := context.Background() |
| 85 | op := Person{ID: 1, Email: "paul@example.com", Name: "paul"} |
| 86 | ag, token, err := s.createAgent(ctx, "claude", op) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if !strings.HasPrefix(token, "mt_") || len(token) < 40 { |
| 91 | t.Fatalf("token shape %q", token) |
| 92 | } |
| 93 | if ag.TokenHash != hashAgentToken(token) || ag.TokenHash == token || len(ag.TokenHash) != 64 { |
| 94 | t.Fatalf("stored %q for %q", ag.TokenHash, token) |
| 95 | } |
| 96 | got, opGot, ok, err := s.agentByToken(ctx, token) |
| 97 | if err != nil || !ok || got.ID != ag.ID || opGot.ID != op.ID { |
| 98 | t.Fatalf("lookup %+v %+v %v %v", got, opGot, ok, err) |
| 99 | } |
| 100 | if _, _, ok, _ := s.agentByToken(ctx, "mt_nope"); ok { |
| 101 | t.Fatal("an unknown token must not resolve") |
| 102 | } |
| 103 | if principal(ag, op) != "claude~paul@example.com" { |
| 104 | t.Fatalf("principal %q", principal(ag, op)) |
| 105 | } |
| 106 | if actorForAgent(ag) != "agent:"+itoa(ag.ID) { |
| 107 | t.Fatalf("actor %q", actorForAgent(ag)) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) { |
| 112 | s := testStore(t) |
| 113 | ctx := context.Background() |
| 114 | op := Person{ID: 1, Email: "paul@example.com", Name: "paul"} |
| 115 | other := Person{ID: 2, Email: "bea@example.com", Name: "bea"} |
| 116 | ag, token, _ := s.createAgent(ctx, "claude", op) |
| 117 | if ok, _ := s.revokeAgent(ctx, ag.ID, other); ok { |
| 118 | t.Fatal("another person revoked the agent") |
| 119 | } |
| 120 | if ok, _ := s.revokeAgent(ctx, ag.ID, op); !ok { |
| 121 | t.Fatal("the operator could not revoke") |
| 122 | } |
| 123 | if ok, _ := s.revokeAgent(ctx, ag.ID, op); ok { |
| 124 | t.Fatal("revoking twice must report false") |
| 125 | } |
| 126 | if _, _, ok, _ := s.agentByToken(ctx, token); ok { |
| 127 | t.Fatal("a revoked token must not resolve") |
| 128 | } |
| 129 | list, _ := s.agentsFor(ctx, op) |
| 130 | if len(list) != 1 || list[0].RevokedAt == nil { |
| 131 | t.Fatalf("list %+v", list) |
| 132 | } |
| 133 | if list2, _ := s.agentsFor(ctx, other); len(list2) != 0 { |
| 134 | t.Fatal("another person's list must be empty") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestAgentNameRules(t *testing.T) { |
| 139 | s := testStore(t) |
| 140 | ctx := context.Background() |
| 141 | op := Person{ID: 1, Email: "paul@example.com"} |
| 142 | for _, bad := range []string{"", " ", "with space", "tilde~x", "colon:x", strings.Repeat("a", 65), "é"} { |
| 143 | if _, _, err := s.createAgent(ctx, bad, op); err == nil { |
| 144 | t.Fatalf("accepted name %q", bad) |
| 145 | } |
| 146 | } |
| 147 | if _, _, err := s.createAgent(ctx, " claude-2_ok ", op); err != nil { |
| 148 | t.Fatalf("trimmed valid name refused: %v", err) |
| 149 | } |
| 150 | } |
| 151 | ``` |
| 152 | |
| 153 | (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`.) |
| 154 | |
| 155 | - [ ] **Step 2: Run to see them fail**: compile error. |
| 156 | |
| 157 | - [ ] **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`: |
| 158 | |
| 159 | ```go |
| 160 | package meet |
| 161 | |
| 162 | import ( |
| 163 | "context" |
| 164 | "crypto/rand" |
| 165 | "crypto/sha256" |
| 166 | "encoding/base64" |
| 167 | "encoding/hex" |
| 168 | "errors" |
| 169 | "regexp" |
| 170 | "strconv" |
| 171 | "strings" |
| 172 | "time" |
| 173 | |
| 174 | "gorm.io/gorm" |
| 175 | ) |
| 176 | |
| 177 | var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) |
| 178 | |
| 179 | var errAgentName = errors.New("calls: an agent name is 1 to 64 letters, digits, - or _") |
| 180 | |
| 181 | // newAgentToken mints "mt_" + 32 random bytes, base64url without |
| 182 | // padding, and its SHA-256 hex. The token exists in plaintext only on |
| 183 | // its way to the one page that shows it (§8.1). |
| 184 | func newAgentToken() (token, hash string, err error) { |
| 185 | var b [32]byte |
| 186 | if _, err := rand.Read(b[:]); err != nil { |
| 187 | return "", "", err |
| 188 | } |
| 189 | token = "mt_" + base64.RawURLEncoding.EncodeToString(b[:]) |
| 190 | return token, hashAgentToken(token), nil |
| 191 | } |
| 192 | |
| 193 | func hashAgentToken(token string) string { |
| 194 | sum := sha256.Sum256([]byte(token)) |
| 195 | return hex.EncodeToString(sum[:]) |
| 196 | } |
| 197 | |
| 198 | func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email } |
| 199 | |
| 200 | func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) } |
| 201 | |
| 202 | func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) { |
| 203 | name = strings.TrimSpace(name) |
| 204 | if !agentNameRe.MatchString(name) { |
| 205 | return Agent{}, "", errAgentName |
| 206 | } |
| 207 | token, hash, err := newAgentToken() |
| 208 | if err != nil { |
| 209 | return Agent{}, "", err |
| 210 | } |
| 211 | ag := Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()} |
| 212 | if err := s.g.WithContext(ctx).Create(&ag).Error; err != nil { |
| 213 | return Agent{}, "", err |
| 214 | } |
| 215 | return ag, token, nil |
| 216 | } |
| 217 | |
| 218 | func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) { |
| 219 | var out []Agent |
| 220 | err := s.g.WithContext(ctx).Where("operator_person_id = ?", operator.ID).Order("created_at DESC, id DESC").Find(&out).Error |
| 221 | return out, err |
| 222 | } |
| 223 | |
| 224 | // revokeAgent revokes one of the operator's own agents. False when the |
| 225 | // row is not theirs or is already revoked: a 404 shape, never a 403. |
| 226 | func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (bool, error) { |
| 227 | res := s.g.WithContext(ctx).Model(&Agent{}). |
| 228 | Where("id = ? AND operator_person_id = ? AND revoked_at IS NULL", id, operator.ID). |
| 229 | Update("revoked_at", s.now().UTC()) |
| 230 | return res.RowsAffected == 1, res.Error |
| 231 | } |
| 232 | |
| 233 | // agentByToken resolves a bearer to its agent and operator. Unknown and |
| 234 | // revoked both answer false; the caller must not tell them apart. |
| 235 | func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) { |
| 236 | var ag Agent |
| 237 | err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error |
| 238 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 239 | return Agent{}, Person{}, false, nil |
| 240 | } |
| 241 | if err != nil { |
| 242 | return Agent{}, Person{}, false, err |
| 243 | } |
| 244 | var op Person |
| 245 | if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil { |
| 246 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 247 | return Agent{}, Person{}, false, nil |
| 248 | } |
| 249 | return Agent{}, Person{}, false, err |
| 250 | } |
| 251 | return ag, op, true, nil |
| 252 | } |
| 253 | |
| 254 | func (s *store) agentByID(ctx context.Context, id int64) (Agent, Person, bool, error) { |
| 255 | var ag Agent |
| 256 | err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error |
| 257 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 258 | return Agent{}, Person{}, false, nil |
| 259 | } |
| 260 | if err != nil { |
| 261 | return Agent{}, Person{}, false, err |
| 262 | } |
| 263 | var op Person |
| 264 | if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil { |
| 265 | return Agent{}, Person{}, false, err |
| 266 | } |
| 267 | return ag, op, true, nil |
| 268 | } |
| 269 | |
| 270 | var _ = time.Now |
| 271 | ``` |
| 272 | |
| 273 | - [ ] **Step 4: Gate**: `make ci` (its migration check must agree), `make race`. |
| 274 | - [ ] **Step 5: Commit** `agents: the table, the once-shown token, the store` with the trailers, files by name. |
| 275 | |
| 276 | --- |
| 277 | |
| 278 | ### Task 2: Settings: mint, show once, revoke |
| 279 | |
| 280 | **Files:** |
| 281 | - Create: `internal/calls/handlers_settings.go`, `internal/calls/templates/settings.html`, `internal/calls/templates/agent_once.html` |
| 282 | - Modify: `internal/calls/app.go` (routes inside the `requireMember` group: `GET /settings`, `POST /settings/agents`, `GET /settings/agents/{id}/revoke`, `POST /settings/agents/{id}/revoke`), `internal/calls/templates/layout.html` or the topbar (a "Settings" link for members), `internal/calls/templates/index.html` (nothing yet; Task 4) |
| 283 | - Test: `internal/callstest/agents_test.go` |
| 284 | |
| 285 | **Interfaces:** |
| 286 | - `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. |
| 287 | - 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." |
| 288 | |
| 289 | - [ ] **Step 1: Write the failing tests** (callstest, 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`). |
| 290 | - [ ] **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. |
| 291 | - [ ] **Step 3: Gate, catalogue the strings, commit** `settings: mint an agent token once, revoke it`. |
| 292 | |
| 293 | --- |
| 294 | |
| 295 | ### Task 3: The API: bearer, reach, five operations |
| 296 | |
| 297 | **Files:** |
| 298 | - Create: `internal/calls/api.go`, `internal/calls/hub/count.go` (+ test), `internal/callstest/api_test.go` |
| 299 | - Modify: `internal/calls/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/calls/ratelimit.go` only if a constructor is needed. |
| 300 | |
| 301 | **Interfaces:** |
| 302 | - `func (h *Hub) AdmittedCount(sqid string) int` — under `h.mu`, the number of admitted participations in the room, 0 for no room. |
| 303 | - 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. |
| 304 | - JSON shapes: |
| 305 | - `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>"}]}` |
| 306 | - `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>` |
| 307 | - `POST /api/v1/meetings/{sqid}/links` → `200 {"meeting":{…},"link":"…"}`; ended meeting → 409 `{"error":"ended"}` |
| 308 | - `POST /api/v1/meetings/{sqid}/end` → `200 {"meeting":{…}}`; already ended → 409 `{"error":"ended"}` |
| 309 | - `GET /api/v1/meetings/{sqid}` → `200 {"meeting":{…},"admitted":N}` (`admitted` present only while `ended_at` is null; it is `hub.AdmittedCount`) |
| 310 | - 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. |
| 311 | - Every response `Content-Type: application/json`, `Cache-Control: no-store`. |
| 312 | |
| 313 | - [ ] **Step 1: Write the failing tests** (`api_test.go`, with a helper `mintAgent(t, x, c) (token string)` that drives Settings as the member): |
| 314 | |
| 315 | ```go |
| 316 | func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) // no header → 401; a signed-in cookie alone → 401; "Bearer nope" → 404 with the not-found body; Basic → 401 |
| 317 | 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 |
| 318 | 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" |
| 319 | 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) |
| 320 | 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 |
| 321 | func TestAPIRateLimitsPerAgent(t *testing.T) // 61 GETs → the 61st is 429 with Retry-After; a second agent is unaffected |
| 322 | 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) |
| 323 | ``` |
| 324 | |
| 325 | Also `hub/count_test.go`: `AdmittedCount` is 0 for no room, counts admitted only, and never a waiting guest. |
| 326 | |
| 327 | - [ ] **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`. |
| 328 | - [ ] **Step 3: Gate**: `make ci`, `make race`. **Commit** `api: /api/v1 meetings for agents, reach re-derived per request`. |
| 329 | |
| 330 | --- |
| 331 | |
| 332 | ### Task 4: The badge on the list, the record, the spec note |
| 333 | |
| 334 | **Files:** |
| 335 | - Modify: `internal/calls/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/calls/templates/index.html` (a robot badge through `ui/badge` with the lucide `bot` icon from `internal/calls/icons`, and the principal after the title: "by claude~paul@example.com"), `internal/calls/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` |
| 336 | - Test: `internal/callstest/api_test.go`'s badge test now passes; `internal/callstest/room_test.go` untouched. |
| 337 | |
| 338 | - [ ] **Step 1: Implement the view and the badge; make Task 3's badge test pass.** |
| 339 | - [ ] **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).** |
| 340 | - [ ] **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`. |
| 341 | |
| 342 | --- |
| 343 | |
| 344 | ## Self-review |
| 345 | |
| 346 | **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. |
| 347 | |
| 348 | **Placeholders:** none; Task 2's handlers follow the named files' existing style with the partials named. |
| 349 | |
| 350 | **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. |
| 351 | |
| 352 | **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. |
| 353 | |