# 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/calls/models.go` — `Agent`; `internal/calls/migrations/0002_agents.sql` (generated).
- `internal/calls/agents.go` — the token, store methods: `createAgent`, `agentsFor`, `revokeAgent`, `agentByToken`, `agentByID`; `principal`.
- `internal/calls/handlers_settings.go`, `templates/settings.html`, `templates/agent_once.html` — the Settings page, the mint result, the revoke confirm page.
- `internal/calls/api.go` — the bearer middleware, `apiJSON`, the five operations; `internal/calls/hub/count.go` — `AdmittedCount(sqid)`.
- `internal/calls/store.go` (`meetingView` gains the creator's principal), `templates/index.html` (the badge).
- `internal/callstest/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/calls/models.go` (add `Agent`, register in `Models`), `internal/calls/migrations/` (generate `0002_agents.sql`)
- Create: `internal/calls/agents.go`, `internal/calls/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/calls/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("calls: 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/calls/handlers_settings.go`, `internal/calls/templates/settings.html`, `internal/calls/templates/agent_once.html`
- 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)
- Test: `internal/callstest/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** (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`).
- [ ] **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/calls/api.go`, `internal/calls/hub/count.go` (+ test), `internal/callstest/api_test.go`
- 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.

**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/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`
- Test: `internal/callstest/api_test.go`'s badge test now passes; `internal/callstest/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.
