agent-connection-grants.md36.6 KBView on GitHub
# Per-agent connections — the scope an agent owns, stored where it can be enforced

Extends [agent-workspace.md](agent-workspace.md) Phase 7 (per-agent grants) and Phase 15
(the connection picker). This is the phase those two left open.

**Nothing was removed in a staging merge.** Per-agent gating was built, is live, and passes
its tests today — for *some* capabilities on *one* path. The rest was never built: §2.1
has the commit-level record. What this design adds is the missing half, and it moves the
scope out of the markdown body into `documents.metadata` — the column agent configuration
already lives in — rather than into a table of its own.

---

## 1) What is being asked for

1. An agent can turn a default family off — the switch on Config §2 is inert today.
2. Per-verb scopes (read / draft / send) on Gmail, Slack, LinkedIn, and the rest.
3. Meetings stops wearing Drive's icon; Drive becomes its own thing.
4. **Connections are per-agent and independent of the user/global connection list** — each
   agent holds its own scope over the connections and tools it may use, stored and enforced
   as such, backend and frontend.

(4) is the load-bearing one and it reframes (1)–(3): they are the surface of it.

---

## 2) Present state

### 2.1 The record — built, never built, and never removed

Established by pickaxe across **all** branches (`git log --all -S`), not by reading the
doc's checkboxes.

**Built and live today:**

| Capability | Where | Evidence |
| --- | --- | --- |
| MCP servers, per agent | `narrowMcpServersToGrant` at [run-chat-agent-sdk.ts:168](../../server/src/mastra/workflows/chat/run-chat-agent-sdk.ts) | 14 tests green in `agent-bound-chat.test.ts` |
| Boards, per agent | [boardTool.ts:277](../../server/src/mastra/tools/document/boardTool.ts) | `board-grants.test.ts` |
| Files, per agent | `agent-workspace/grants.ts` | `file-grants-enforcement.test.ts`, 14 tests |

`narrowMcpServersToGrant` appears in exactly two commits in the history of every branch —
`c40c4ee81` (2026-08-25) and `2156fb4ce` (2026-08-29) — **both additions**. It is called
today. It was not removed, reverted, or lost to a merge.

**Never built** (zero commits on any branch contain them):

- `mastra/tools/__tests__/capability-grant.test.ts` — Phase 7 ticks this file. It has never
  existed.
- The frontmatter grant merged into
  [`subagent-tool-allowlists.ts`](../../server/src/mastra/tools/subagent-tool-allowlists.ts).
  Phase 7 ticks it; that file has never imported `grants.ts`.
- Any grant over Cedar's **own** tools. No `tools:` frontmatter key, no `assertToolGrant`,
  no family gate — in any commit, ever.

**So the actual gap:**

| | Chat harness | Automation path |
| --- | --- | --- |
| MCP servers | gated | **not gated** |
| Cedar's own tools (mail, slack, linkedin, meetings, draft-comms) | **not gated** | **not gated** |

The second row is the one with teeth. `linkedin-write` performs `send-message` (30/hr,
100/day), `send-invitation` (80/week) and `send-inmail` (20/month) **on the user's real
LinkedIn account**, and every agent in the workspace can call it on both paths.

The reason the family switch is inert is recorded in
[AgentConnectionsPanel.tsx:21-27](../modules/agents/components/AgentConnectionsPanel.tsx) —
that these families are scoped by the playbook's `permissions:` (a skill list), and writing
an inverse skill list from a connection switch would freeze the agent at today's skill
catalog. The argument is sound; its premise is dead, because `permissions:` is not enforced
either:

- [`loadSkillTool.ts:92`](../../server/src/mastra/tools/skills/loadSkillTool.ts) —
  *"nothing sets this key today… it was always constructed with `[]`."*
- [`review-effects.ts:263`](../../server/src/services/agenda/review-effects.ts) —
  *"`allowedSkills` is not applied on the automation path (`runAutomationInProcess` passes
  `getAllSkillTools()` wholesale), so a subagent document with no `permissions` reaches
  `save-draft` whatever its job is meant to be."* That is the 2026-08-09 double-draft
  incident.

Three capability mechanisms, two working on one path each, one inert. That is the state to
fix, and it is why (4) is the right frame: not another narrowing bolted onto a shared
connection, but one per-agent scope object that every path reads.

### 2.2 The grant is in the markdown body, keyed by name, and defaults to inherit

Three problems with where a grant lives today — and, importantly, **none of them is "it
isn't a row"**:

- **It lives in the document CONTENT.** `mcp_servers:` / `files:` / `boards:` are YAML
  frontmatter inside the agent's markdown — the same text the prompt is built from,
  hand-editable, and rewritten wholesale by any full-file upsert. A permission that lives
  in the prompt body is one a stray edit breaks and one a model writing its own doc could
  in principle rewrite.
- **It is keyed by display NAME.** Grants match a connection by `name ?? id`
  ([connections.ts](../../server/src/services/agent-workspace/connections.ts)). Rename a
  connection and every grant naming it stops matching — no error, the agent quietly loses
  the capability. Same class as the provider-id bug fixed in `1d30b314c`.
- **`inherit` means reach grows on its own.** An absent key means "everything the user has
  connected", so connecting a new integration in Settings hands it to every existing agent,
  with no action on the agent and nothing shown on its config page. That is the opposite of
  "each agent has its own scope", and it is the default.

**What is emphatically NOT a problem is the absence of a table.** `documents.metadata` is
already the canonical home for agent configuration, and the codebase says so:

> *"Persisting them here keeps agent configuration in metadata — the canonical source now
> that the legacy `aop_agents` columns are no longer read — rather than only in the .md
> body."* — [playbook-doc-hooks.ts:320](../../server/src/services/playbook/playbook-doc-hooks.ts)

- It already carries `agent_id`, `aopId`, `ownerUserId`, `aopAgentId` on ~790 agent docs.
- It is **already selected on the agent read path**
  ([agent-read.ts:144](../../server/src/services/agent-workspace/agent-read.ts)), so a scope
  stored there costs nothing to read — every path already fetches this row.
- There is **already a GIN index** — `documents_metadata_gin_idx (metadata jsonb_path_ops)`
  — so "which agents can send on LinkedIn" is a containment query today, no new index.
- There is already an allowlist pattern for adding a config key=[redacted]
- `aop_agents` is **dormant** and slated to be dropped ("no application code reads or writes
  it"), so its `allowed_skills` column is not an alternative either.

A separate `agent_connection_scopes` table would add a query to every run, duplicate a bag
that exists, and contradict a decision already made and documented. **Scrapped.**

### 2.3 The granularity already exists — it is just not exposed

`LEGACY_TOOL_NAME_BY_FAMILY_ACTION`
([family-tool-names.ts](../../server/src/mastra/tools/family-tool-names.ts)) already splits
every family by verb:

| Family | Tool id | Actions |
| --- | --- | --- |
| Gmail | `mail` | `list-sent`, `find`, `fetch-attachments`, `search-live`, `read-thread` — all read |
| | `draft-comms` | `draft-email` → `save-draft` |
| Slack | `slack-read` | `search-live`, `read-thread` |
| | `slack-write` | `sync-thread` — attaches a thread to a deal; sends nothing |
| | `draft-comms` | `draft-slack-message` |
| LinkedIn | `linkedin-read` | `list-seats`, `view-profile`, `list-connections`, `profile-viewers`, `list-chats`, `read-chat` |
| | `linkedin-write` | `sync-chat` (ingest) **and** `send-message`, `send-invitation`, `send-inmail` (real outbound) |
| Meetings | `meetings` | `find`, `sync`, `capabilities` |

Two consequences that should shape the design rather than be papered over:

1. **`send` is not a verb Cedar has for email or Slack.** No agent tool sends mail —
   `save-draft` writes a draft a human sends, which is the product. `send-slack` is a
   notification *to the user*. A Send switch on the Gmail row would be a second control
   that governs nothing, which is the defect in §2.1 restated in a lighter colour.
2. **`linkedin-write` mixes two risk classes under one id.** `sync-chat` ingests; the three
   `send-*` actions act on the user's account. A whole-tool scope cannot separate them, so
   the scope must reach **actions**, not just tools.

### 2.4 Meetings wears Drive's icon; Drive is not on the list

[`connection-logo.tsx:107`](../modules/agents/utils/connection-logo.tsx):

```ts
// The meetings family is Google Calendar/Meet-backed; Drive's mark is the closest
// real Google-workspace glyph the repo ships.
meetings: GoogleDriveIcon,
```

Same wrong premise as the `['recall','google']` provider list fixed in `1d30b314c`:
Meetings is `MEETING_PROVIDER_IDS` — Granola, Fireflies, Gong, Circleback, Clari, Krisp,
Caretta, Fellow, Attio, Recall, Attention, AskElephant — and the family's dialog renders a
`MeetingIntegrationCard` per notetaker. Nothing on that row is Google.

Drive, meanwhile, is a genuinely separate capability with **no row at all**:

- Its own OAuth scope tier, with a legacy grant that must be re-consented —
  `drive.file` vs `drive.readonly`, surfaced by
  [`DriveReauthCard`](../modules/integrations/drive-reauth-card.tsx).
- Its own deny-by-default enforcement boundary, read and write verbs already separated —
  [`drive-proxy.ts`](../../server/src/mastra/tools/integrations/drive-proxy.ts),
  `mode: 'read'` vs `mode: 'collateral-write'`, with `files.delete` and every
  `permissions.*` mutation denied in **every** mode.

Drive has the most developed granularity in the codebase and is the only capability missing
from the screen that exists to list exactly this.

---

## 3) Designed state

### 3.1 One object: `metadata.connection_scopes` on the agent document row

An agent's reach becomes a key on the metadata bag the agent row already carries — beside
`agent_id`, `aopId` and `output_field_ids`, which is where agent configuration already
lives. No new table, no extra query, no second source of truth.

```jsonc
// documents.metadata on the agent's subagent .md row
{
  "agent_id": "…", "aopId": "…", "output_field_ids": [],
  "connection_scopes": {
    "version": 1,
    "entries": [
      { "kind": "family", "ref": "gmail",          "actions": ["*"] },
      { "kind": "family", "ref": "linkedin",       "actions": ["list-chats", "read-chat", "sync-chat"] },
      { "kind": "mcp",    "ref": "<connection.id>", "actions": ["create_payment_link"] }
    ],
    "updatedAt": "2026-08-31T…", "updatedBy": "user_…"
  }
}
```

Four properties, each achieved by a specific choice rather than by the storage medium:

- **Free to read.** The row and its metadata are already fetched on every agent path
  ([agent-read.ts:144](../../server/src/services/agent-workspace/agent-read.ts)). A table
  would have cost a query per run to buy nothing.
- **Queryable both ways.** `documents_metadata_gin_idx (metadata jsonb_path_ops)` already
  exists, so "which agents may send on LinkedIn" is `metadata @> '{"connection_scopes":…}'`
  — an admin question, answered on an index that ships today.
- **Referentially sound** — because `ref` is a connection **id**, never a display name.
  This was always about the key, not about table-vs-JSON; storing the name in a table would
  have been just as broken.
- **Explicit, so reach never grows on its own.** Connecting a new integration in Settings
  writes no entries. No existing agent gains anything until someone grants it on that
  agent's page.

**Out of the prompt, and out of the model's reach.** The scope goes in `metadata`, *not* in
the markdown frontmatter, and that distinction is the whole security argument for this
placement:

- Frontmatter is document CONTENT — the text the prompt is built from and any full-file
  upsert rewrites. `metadata` is a column, out of band.
- `writeDocumentTool` accepts a caller-supplied `metadata` object, and
  `pickSubagentConfigMetadata` decides which keys a write may set — today
  `['output_field_ids', 'avatar']`. **`connection_scopes` must NOT join that list.** If it
  did, an agent calling `write-document` on its own doc could widen its own permissions.
  It is written only by the tRPC route, and stripped from any model-supplied metadata on
  the way in. Same class of hole as `spawn-subagent`, closed the same way: name it and shut
  it, rather than trusting that nobody will try.

**One hazard to design against, because this codebase has already been bitten by it.**
`writeDocument` persists `metadata ?? existing` — **REPLACE**, not merge. Any caller passing
a metadata object drops every key it did not resend; that is exactly how `agent_id` was lost
and agents were unlinked from the refs that resolved them, which is why it now has a
dedicated precedence rule
([convention-paths.ts:480](../../server/src/services/documents/convention-paths.ts)).
`connection_scopes` needs the same treatment: preserved from the stored row unless the write
is the scope route itself, and written with a targeted `jsonb_set` rather than a
whole-object rewrite. The failure direction is at least safe — absent means deny, so a strip
breaks the agent loudly instead of silently widening it — but a permission that vanishes on
an unrelated save is still a bug.

**Seeding, and why not `inherit`.** A new agent is seeded with explicit entries from its
template (a `deal-drafter` gets Gmail read + draft; a research agent gets read only) —
written down, visible, editable. Existing agents are backfilled once from their current
effective grant, so nothing loses a capability on deploy. After the backfill `inherit` does
not exist: no entry means **no access**, which is the only reading under which an agent's
scope is genuinely its own.

**Frontmatter's remaining job.** `mcp_servers:` / `files:` / `boards:` stay readable and
hand-authorable for one release as a *projection* — written through on save, read only by
the backfill — then the connection keys leave the document. Two live sources of truth for
one permission is how the third inert mechanism gets written.

**The honest limit — credentials belong to people, not agents.** Cedar cannot give an agent
its own Google identity; a Gmail connection is a person's OAuth grant and a LinkedIn seat is
a person's account. So "separate from the user's connections" resolves in two layers, and
the design should not blur them:

- **The scope is the agent's** — always, for every connection kind. This is what makes two
  agents over one mailbox genuinely independent.
- **The credential is selectable, not duplicated.** Where a user holds several (multiple
  mailboxes, several LinkedIn seats, an MCP server connected once per agent), the scope row
  names *which* one this agent uses via `connection_ref`. Where the user holds one, agents
  share the credential and differ only in scope. `metadata.requiresAgentGrant` — the flag
  stamped on a server added from inside an agent so no other agent inherits it — is the
  existing half-model of this and folds into `connection_kind = 'mcp'` with a single scope
  row.

### 3.2 The grant vocabulary is the dispatch table's own

`actions` holds the action ids that already exist in
`LEGACY_TOOL_NAME_BY_FAMILY_ACTION` — so a scope is checkable against the real dispatch
table rather than a hand-kept parallel list, which is the drift that caused the LinkedIn
provider-id bug. `['*']` means every action the tool exposes, including ones added later.

Where the authored frontmatter form survives (the migration window, and hand-authored
agents), it reuses `grants.ts`'s existing grammar verbatim — `parseGrantEntry`,
`grantsTool`, `describeDenial`:

```yaml
tools:
  - mail                          # every read action
  - draft-comms: draft-email      # may draft email, not Slack
  - linkedin-read
  - linkedin-write: sync-chat     # may attach chats to deals; may NOT message anyone
```

One trap: **do not spell the verbs `read` / `write`.** `parseGrantEntry`'s `NEAR_MISS` set
deliberately reads a bare `: read` as a mistyped access bit, so `slack: read` parses as a
read-only *file* grant and silently grants nothing. Real action names sidestep a collision
that would otherwise need a parser change.

### 3.3 What the UI shows — verbs, not tool ids

One row per connection, each with a small fixed verb set. **A verb appears only where a tool
implements it:**

| Connection | Read | Draft | Attach to a deal | Send / Write |
| --- | --- | --- | --- | --- |
| Gmail | `mail` (all) | `draft-comms: draft-email` | — | *not offered — Cedar does not send mail* |
| Slack | `slack-read` | `draft-comms: draft-slack-message` | `slack-write: sync-thread` | *not offered* |
| LinkedIn | `linkedin-read` | — | `linkedin-write: sync-chat` | `linkedin-write: send-message, send-invitation, send-inmail` |
| Meetings | `meetings: find, capabilities` | — | `meetings: sync` | — |
| Drive | proxy `mode: 'read'` | — | — | `mode: 'collateral-write'` |
| MCP server | per-tool, as today | | | |

The row keeps its on/off switch — off deletes the scope row, on writes the full action set —
and expands to verb checkboxes, which is exactly how an MCP row already expands to its tool
list. Same component, same interaction for both halves of the screen.

Where a cell is empty, **render nothing** — not a disabled switch. A greyed-out Send on the
Gmail row would restate §2.1's mistake in a lighter colour.

### 3.4 Execution — enforced at the call site, on every path

`grants.ts` already states the rule: *"Enforcement belongs at the CALL SITE, not in the
prompt. Filtering what the model sees is an optimization; filtering what the dispatcher
executes is the control."* Today that rule is honoured for MCP on one path. It has to hold
on all of them:

- **One resolver, cached per run.** `resolveAgentScope(agentId)` → a map of
  `connection_ref → Set<action>`, loaded once and carried on the request context, so the
  gate costs one query per run rather than a document parse per call.
- **One guard**, `assertToolGrant(toolId, action, ctx)`, refusing through `describeDenial()`
  so the model is told where to widen it and **not to retry** — the message shape that
  already stops an agent burning a turn on path variants.
- **Applied by wrapping at assembly**, so no tool body changes and nothing is missed by
  omission: `getAllSkillTools()` (Mastra granular), `buildMasterFamilyToolset()` (Master
  family surface), and the SDK harness's tool list. The wrapper filters the list (the
  optimization) *and* guards `execute` (the control).
- **The automation path is included.** `runAutomationInProcess` passing
  `getAllSkillTools()` wholesale is the hole behind the 2026-08-09 incident; wrapping at
  assembly closes it in the same change rather than as a follow-up.
- **`spawn-subagent` is the escape hatch and must close with it.** A spawned child is not
  bound by its parent's scope — that is exactly how the 2026-07-21 duplicate-draft incident
  happened, documented in
  [subagent-tool-allowlists.ts](../../server/src/mastra/tools/subagent-tool-allowlists.ts).
  Rule: a child with its own scope entries uses them; a child without inherits the **parent's
  resolved scope**, never the full catalog. Without this every scope on this screen is
  advisory.
- **Every refusal is a structured log**, per `drive-proxy.ts`: a denial is a signal, not a
  shrug, and it is how a too-narrow scope gets noticed before a user reports a broken agent.

### 3.5 Meetings and Drive, separated

- **Drive becomes its own connection row**, status from the stored Google scope
  (`drive.access` → `'none' | 'file' | 'readonly'`) rather than a provider id — the
  connection is Google and the capability is the scope. Its dialog body is `DriveReauthCard`
  when the scope is `'none'`/`'file'`. `AGENT_CONNECTION_FAMILIES` gains `'drive'`;
  `isAgentConnectionFamily` was already written to survive a fifth family. Its two verbs map
  to the proxy's existing `mode`.
- **Meetings gets its own mark.** Cheap: the shipped `Calendar` glyph (icons.tsx:991).
  Preferred: the **connected recorder's own logo**, so the row shows Granola or Gong — what
  it actually stands for — falling back to `Calendar` when nothing is connected.
  `MeetingIntegrationCard` already resolves a provider mark; move the resolver next to
  `connectionLogo` rather than copying it.
- `drive` / `google drive` keep their `GoogleDriveIcon` entries. Only the `meetings` key
  changes.

---

## 4) Critical files

| File | Change |
| --- | --- |
| `services/agent-workspace/agent-scope.ts` *(new)* | the `metadata.connection_scopes` shape + zod parse, `resolveAgentScope`, `setAgentScope` (targeted `jsonb_set`), seeding, the backfill read. **No migration and no new table** — the column, the index and the read path all exist |
| [`services/playbook/playbook-doc-hooks.ts`](../../server/src/services/playbook/playbook-doc-hooks.ts) | preserve `connection_scopes` across a REPLACE write, the way `agent_id` is preserved; keep it **out** of `SUBAGENT_CONFIG_METADATA_KEYS` and strip it from model-supplied metadata |
| [`services/agent-workspace/grants.ts`](../../server/src/services/agent-workspace/grants.ts) | `grantsTool` / `describeDenial` reused unchanged; frontmatter reads become the migration's input |
| [`services/agent-workspace/connections.ts`](../../server/src/services/agent-workspace/connections.ts) | `granted` per connection from `metadata.connection_scopes`; Drive as a row |
| [`mastra/tools/family-tool-names.ts`](../../server/src/mastra/tools/family-tool-names.ts) | source of truth for tool → actions; add the reverse lookup the guard needs |
| `mastra/tools/tool-grant.ts` *(new)* | `assertToolGrant` + the assembly-time wrapper |
| `mastra/skills/index.ts`, [`master-surface.ts`](../../server/src/mastra/tools/master-surface.ts), `run-chat-agent-sdk.ts`, the automation dispatch | apply the wrapper |
| [`harness/bound-agent.ts`](../../server/src/mastra/workflows/chat/harness/bound-agent.ts) | `narrowMcpServersToGrant` reads `metadata.connection_scopes` instead of frontmatter |
| [`event-execution/runSubagentTool.ts`](../../server/src/mastra/tools/event-execution/runSubagentTool.ts) | child inherits the parent's resolved scope |
| `trpc/routes/agent.ts` | `getConnectionScopes` / `setConnectionScope` beside `getMcpGrants` / `setMcpGrants` |
| [`AgentConnectionsPanel.tsx`](../modules/agents/components/AgentConnectionsPanel.tsx) | pass `onToggle` / `onToggleTool` for every row; drop the "not writable" note |
| [`AgentConnectionsSection.tsx`](../modules/agents/components/AgentConnectionsSection.tsx) | verb rows on a family, reusing the MCP tool-row component |
| [`connection-logo.tsx`](../modules/agents/utils/connection-logo.tsx) | meetings mark; Drive keeps its own |
| [`AgentFamilyConnectDialog.tsx`](../modules/agents/components/AgentFamilyConnectDialog.tsx) | `'drive'` row + `DriveReauthCard` body |

---

## 5) Implementation phases

### Phase 1 — Storage: the scope becomes a metadata key

No schema change. The column, the GIN index and the read path all exist.

- [x] `agent-scope.ts` — the `connection_scopes` shape, zod-parsed at the boundary (it is
      jsonb, a genuinely untyped edge, so it is validated and never cast; a malformed blob
      degrades to `inherit` rather than throwing mid-turn). Zero-dependency leaf so
      `convention-paths.ts` can import the strip without closing a cycle.
- [x] `agent-scope-store.ts` — `readAgentConnectionScopes` / `writeAgentConnectionScopes`
      (targeted `jsonb_set`) / `clearAgentConnectionScopes` / `findAgentsWithScope`.
- [x] Preserve `connection_scopes` across `writeDocument`'s semantics, beside the existing
      `agent_id` precedence rule.
- [x] Strip it from model-supplied metadata (and keep it out of
      `SUBAGENT_CONFIG_METADATA_KEYS`).
- [x] tRPC: `getConnectionScopes`, `setConnectionScope`, `setConnectionScopes`,
      `clearConnectionScopes`, `agentsWithScope`.
- [x] CLI: `cedar-cli agent scope show|set|only|clear|who` — thin HTTP client over those
      same procedures, so what it prints is what the screen renders.
- [ ] Backfill every existing agent from its current **effective** grant. **Deliberately
      not run**: it writes to every agent document in every org, and it is only load-bearing
      once Phase 2 lands (until then `inherit` already means "everything", so the backfill
      changes no behaviour and only adds risk). Built as a `--dry-run`-first CLI verb; firing
      it for real is an operator decision.
- [ ] Seed explicit entries at agent creation from the template.

**Tests:** backfill reproduces today's effective reach exactly for a sample of real agents;
a renamed connection keeps its scope (the id, not the name, is the key); an agent calling
`write-document` on its own doc **cannot** set `connection_scopes`; an unrelated write to
the agent doc leaves the scopes intact; the GIN containment query answers "which agents may
`linkedin-write: send-message`".

### Phase 2 — Execution: one guard, every path

- [x] `checkToolGrant` (the CONTROL, inside each gated tool's execute) + `filterToolsetByScope`
      (the OPTIMIZATION, applied in `runAutomationInProcess`).
- [x] `gateToolExecute` at `getAllSkillTools()` closes the GRANULAR ALIASES — `save-draft`,
      `search-live-emails`, `search-live-slack` … are separately registered ids reaching the
      same implementations, so gating the seven family ids alone left the fence bypassable.
- [x] Refusals through `describeToolDenial()`; structured log per refusal.
- [ ] `narrowMcpServersToGrant` reads `metadata.connection_scopes` — still on frontmatter;
      MCP scope entries (`kind: 'mcp'`) are defined but unused.
- [ ] A spawned child with no scope entries inherits the parent's resolved scope. **Still
      open, and it is the remaining hole**: `execute-orchestrator.ts` builds a fresh context
      and copies no grants.

**Tests:** an agent scoped to `linkedin-read` calling `linkedin-write` is refused **at
execute**, not merely absent from its list; the same agent cannot reach it by spawning a
child; the refusal is identical on the chat and automation paths. This is where
`capability-grant.test.ts` — ticked in Phase 7 and never written — actually gets written.

### Phase 3 — Frontend: the switch is real, and per verb

- [x] `getConnections` reports `granted` from `metadata.connection_scopes`;
      `AgentConnectionsPanel` writes family rows through `setConnectionScope`.
- [x] Family rows expand to verb rows through the SAME `ConnectionDetailRow` the MCP tool
      rows use (a test pins the two classNames identical so they cannot drift).
- [x] Verbs derived from the returned catalog, never declared — no Send on Gmail or Slack,
      because no `send-*` action exists on their tools.
- [x] `linkedin-write` splits into **Attach** (`sync-chat`) and **Send** (`send-*`).
- [x] `inherit` renders distinctly from an explicit grant, and Drive's legacy `drive.file`
      scope offers re-consent rather than Connect.

**Tests:** granting Attach without Send permits `sync-chat` and refuses `send-message`;
toggling one connection does not revoke the others.

### Phase 4 — Drive out, Meetings' own mark

- [x] Drive as its own row, status from the Google scope (`drive.file` → connected-but-limited,
      not connected), `DriveReauthCard` inline on the row AND in its connect dialog.
- [x] Drive's verbs mapped to the proxy's `mode` — `read` → `'read'`, `write` →
      `'collateral-write'`, declared in the catalog with `kind: 'drive'` so Drive no longer
      reaches its behaviour by falling out of `FAMILY_TOOLS`. Enforced where the mode is
      chosen (`runCodeExecutorTool`): a refusal means the proxy never starts, so the sandbox
      is handed no Drive credential at all, and the reason travels into the executor's tool
      result rather than surfacing as a mystery failure inside a script.
- [x] Meetings is one row that OPENS into its recorders, and each recorder opens into its
      own actions — three switch levels, all writing `kind: 'meeting'` entries through the
      one `nextScopeEntries` arithmetic. Superseded an earlier pass twice: first the
      recorders were identity-only text (a toggle would have controlled nothing, since
      `find-meetings` reads Cedar's own store), then they were flat top-level rows. Flat was
      wrong for a reason worth keeping: every recorder row was `status: 'connected'`
      unconditionally, and Connect only renders on `!connected`, so connecting a notetaker
      from this page was impossible in EVERY case — not merely the zero-recorder one.
      Nesting restores that, and the per-recorder toggle became real once the scope filters
      `lower(meeting_provider)` in `findMeetingsTool`'s WHERE.
- [x] A recorder's ACTIONS differ by which truth holds: a webhook-only recorder offers
      `find` alone (readable, not pullable); a connected one offers `find, sync`. That is
      per-USER data, so it rides on the recorder rather than in the per-org scope catalog.

**Tests:** a user on `drive.file` shows Drive as connected-but-limited, not connected; the
meetings row renders no Drive glyph.

### Phase 5 — Retire the projections

- [ ] Drop `mcp_servers:` / `tools:` from the document body once `metadata.connection_scopes`
      has been the source of truth for a release; `files:` / `boards:` follow or stay, decided on their own merits.
- [x] Resolve `permissions:` — **deleted, not wired**. `SubagentFrontmatter.permissions` and
      every read of it are gone (`runSubagentTool`, `playbook-execution-triggers`,
      `run-single-subagent`, `tasks-to-agenda`); those dispatches now pass
      `allowedSkills: []` (unrestricted), which is what they effectively did anyway.
      Wiring it on was the worse option: 19 of the 21 production documents declaring the key
      declared it EMPTY (a no-op) and the 2 with real values named four skills that do not
      exist in `mastra/skills/`, so enforcement would have broken exactly those two agents and
      changed nothing for the rest. `loadSkillTool`'s request-context allow-list check STAYS —
      it is generic, correct, and already has a writer (`orchestrator-dispatch-tools.ts`, which
      sets `[]`); only the dead frontmatter feed was removed. `AgentDescriptor.allowedSkills`
      also stays: it mirrors the `aop_agents.allowed_skills` column and is the transport field
      on `AutomationPayload`. The 21 documents are NOT rewritten — an orphan YAML key parses
      as inert prose (regression-tested in `reference-resolver.test.ts` /
      `frontmatter-grants.test.ts`), and a migration over customer documents is a separate,
      riskier decision.

---

## 5.1) Amendments found during implementation

Four things the code taught that the plan had wrong. Recorded here rather than quietly
absorbed, because each one changed a decision.

1. **Entries are keyed by TOOL, not by family.** §3.1 sketched `{ kind: 'family', ref: 'gmail' }`.
   That cannot work: `draft-comms` serves BOTH Gmail (`draft-email`) and Slack
   (`draft-slack-message`), and `search-live` / `read-thread` are action names on `mail` AND
   on `slack-read`. A flat action list per family cannot say which tool an action belongs to,
   and two families would fight over one entry. Keying on the tool id makes every entry exact
   and matches the enforcement point, which sees `(toolId, action)` and nothing else. Families
   stay as the UI's grouping (`FAMILY_TOOLS`), and `nextScopeEntries` MERGES into a tool's
   entry so switching Slack off cannot revoke Gmail's half of `draft-comms`.

2. **`reconcileSubagentWrite` already merges**, so the "REPLACE drops siblings" hazard in
   §3.1 was already fixed for subagent docs. The REAL hazard is narrower and worse: the merge
   is `{ ...existingMetadata, ...incoming }` — **incoming wins** — and
   `pickSubagentConfigMetadata` is **not an allowlist** (it re-applies a subset ON TOP of the
   caller's full bag rather than filtering the rest out). So a model calling `write-document`
   on its own subagent doc could have set `connection_scopes` outright. Closed by stripping
   the key from `incoming` unconditionally, and by routing the one legitimate writer around
   that path entirely via `jsonb_set` — a guard with an exception is a guard someone reaches
   through.

3. **Assembly filtering is not a control on the harness.**
   [run-chat-agent-sdk.ts:188-191](../../server/src/mastra/workflows/chat/run-chat-agent-sdk.ts)
   sets `permissionMode: 'bypassPermissions'`, under which `allowedTools` is advisory — the
   model can call a tool that is not in the list. Together with `spawn-subagent` handing a
   child the full catalog, that means a filtered toolbox is a hint, not a boundary. Phase 2
   therefore does **both**, with the roles named: filter at assembly for cost and wrong-tool
   avoidance (`subagent-tool-allowlists.ts` measured ~52 tools ≈ 28k tokens of schema per
   cached call), and guard at the call site as the actual control — the split
   `board-grants.ts` and `file-grants.ts` already state at the top of their files.

4. **The reverse query needed no new index.** `documents_metadata_gin_idx (metadata
   jsonb_path_ops)` ships today, and `findAgentsWithScope` runs a `@>` containment test on
   it. Verified headlessly: `scope who --ref linkedin-write --action send-message` → 0 agents,
   `--action sync-chat` → 1, against the same stored row.

## 5.2) Open criticisms of this design, raised during implementation

Recorded rather than resolved — each is a real limit of what shipped.

1. ~~**§3.3's table promises Drive verbs that do not exist.**~~ **RESOLVED** — the catalog
   now declares `drive: [{ tool: 'drive', kind: 'drive', actions: ['read', 'write'] }]`, and
   the UI derives a Read and a Write row from it like any other family.

2. ~~**`kind: 'drive'` is named after its first user.**~~ **RESOLVED in the mechanism, not
   the name** — `kind` now TRAVELS with each catalog entry (`catalogEntryKind`), so nothing
   reaches drive-kind by falling out of the table. A family missing from the catalog now
   grants nothing, which is the safe direction; previously it silently became a drive-kind
   whole-family grant. The NAME is still wrong for what it means ("family-level grant, no
   tool actions") and is worth renaming when a second such family appears.

3. **There is no way back to `inherit` from the screen.** `clearConnectionScopes` exists on
   the router and nothing in the UI calls it, so the first toggle on an inheriting agent
   converts it permanently to `scoped`. §3.1 says `inherit` ceases to exist after the
   backfill, which may make this intended — but the backfill is not run, so today it is a
   one-way door with no notice. Workaround until it has a control:
   `pnpm cedar-cli agent scope clear <agentId> --yes`.

4. **The row switch cannot show "partially granted".** `familyGranted` is ANY-of (the agent
   may reach *some* tool in the family) while a verb row is EVERY-of, so LinkedIn shows a
   lit row switch with Send off. Honest, but it means the row is not tri-state; a count is
   doing that job instead — `3/4`, and on Meetings a recorder count, with the real state one
   disclosure down.

5. **You cannot add a SECOND recorder from this page.** With one present, the Meetings row
   shows a switch rather than Connect, so a Circleback user cannot connect Gong here. Every
   connected family shares this (one mailbox, no "add another"), but Meetings is where
   "I have one and want another" is most common. The fix is a Connect affordance inside the
   disclosure, which competes with the recorder switches for the right edge.

## 6) Verification

```bash
timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__
timeout 300 pnpm --filter @zero/server exec vitest run src/mastra/tools/__tests__
timeout 300 pnpm --filter @zero/server exec vitest run src/mastra/workflows/chat/__tests__/agent-bound-chat.test.ts
timeout 300 pnpm --filter @zero/server run types
timeout 300 pnpm --filter @zero/mail exec jest modules/agents
```

Headless, on a real agent — the whole point of the design is that the control is real:

1. Scope an agent to `linkedin-read` only; run it against a deal with a LinkedIn thread.
   It reads. It is refused on `send-message`, with a message naming where to widen it.
2. Flip Send on in the UI; re-run; the send succeeds under the seat's rate caps.
3. Run the same two checks through the **automation** path, not just chat — that path is
   the one with no gate today.
4. Connect a new integration in Settings; confirm **no** existing agent's reach changes.
5. Turn Meetings off; confirm `find-meetings` is absent from the toolset *and* refused at
   execute.

---

## 7) Open question

"Meetings should just be meetings in Mintlify" did not survive transcription. Everything
else in §2.4 / §3.5 is answered without it. If Mintlify was meant as a *docs* connection
alongside Drive, it is already a known MCP provider and becomes an ordinary MCP row rather
than a built-in one — say so and it joins §3.3's table.