diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md new file mode 100644 index 000000000..c575efb7d --- /dev/null +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -0,0 +1,710 @@ +# Design: Contract-wide id→handle cutover for compass request fields (RIG-2751) + +Status: Draft + +Tracking: RIG-2751 (Matt ruled Option A, contract-wide, 2026-08-25; the storage +shape, owner-qualified wire, and rename/reclaim policy were negotiated to a +FINAL contract and Matt-ratified 2026-08-26 with "LGTM, can start"; all +remaining wire-cutover open questions — OQ-1…7 — were Matt-ruled 2026-08-27 +(RIG-2751 comment f59001de), so the Open Questions section below is now closed +as Rulings; the whole contract is frozen; this record designs the how and +freezes on merge) + +## Problem / Intent + +Matt ruled (RIG-2751, Option A expanded contract-wide): **every request-input +account field on the compass proto surface takes a `@handle`, and the server +resolves handle→`account_id`** — no agent and no client UI ever resolves an id. +An unresolvable handle is an in-band NOT_FOUND. Today the request contract is +id-typed (`member_account_ids`, `agent_account_id`, …), which forces the caller +to hold a directory it does not have: the agent's only account surface is the +scope-limited roster (`CommsCallRequest` carries no ListAccounts arm, +`proto/compass/v1/agent_gateway.proto:106-114`), so an agent literally cannot +resolve an arbitrary handle to an id client-side. This record designs the HOW +of the cutover — the whether is frozen. + +## Approach + +### The storage contract: `account_handles` + owner-qualified resolution + +Handle→id resolution is not a flat global lookup. Matt ratified a two-namespace +contract (RIG-2751, 2026-08-26): **user and system handles are globally unique; +agent handles are unique only within their owner's namespace**, and an agent is +always addressed owner-qualified (`matt/compass-ux`). Two different humans may +each own an agent named `compass-ux`; the owner qualifier is what disambiguates +them, so resolution is `(owner, handle)` for agents and bare `handle` for +users/system. + +Today the handle lives directly on `accounts` as a single global-unique column +(`handle TEXT NOT NULL UNIQUE`, `go/internal/store/migrations/0001_init.sql:38`), +which cannot express the per-owner agent namespace. The contract moves handle +ownership into a dedicated table: + +- **`account_handles(account_id TEXT PK/FK→accounts(id), handle TEXT NOT NULL, + owner_user_id TEXT NULL FK→user_accounts(account_id))`** — one row per account. + `owner_user_id` is NULL for user and system accounts, the owning user's id for + agent accounts (it mirrors `agent_accounts.owner_user_id`, + `0001_init.sql:76`). +- **Two partial-unique indexes**, which double as the resolution index: + - `UNIQUE(handle) WHERE owner_user_id IS NULL` — user/system handles are + globally unique (preserving today's `accounts.handle` global-unique + invariant for the human/system tier). + - `UNIQUE(owner_user_id, handle) WHERE owner_user_id IS NOT NULL` — agent + handles are unique per owner. +- An agent handle MAY overlap a global user handle with no collision at resolve + time, because an agent is only ever looked up owner-qualified and a user only + ever looked up bare — the two indexes never contend on the same lookup. + +**Resolution at the edge** (the site §"Where resolution lives" names) parses the +owner qualifier first, then resolves in the matching namespace: + +- `matt/compass-ux` → resolve `matt` bare in the user/system index → its + `account_id` is the `owner_user_id` → resolve `compass-ux` in the agent index + under that owner. +- A bare handle from an agent caller defaults to the **caller's own owner** + namespace (`compass-ux` from one of matt's agents means `matt/compass-ux`), + resolved from the session→account→`owner_user_id` the edge already holds. +- A bare user/system handle (`matt`, `compass`) resolves in the global index. + +**Rename is in-place for both tiers**: `UPDATE account_handles SET handle = $new +WHERE account_id = $id`, no history, no tombstone, no `retired_at`, no +reservation table. The partial-unique index enforces the new handle is free at +rename time. **Reclaim is allowed for both tiers** — a freed handle (user or +agent) may be re-registered — so nothing needs remembering. A user rename has +**zero cascade to agents**: agents key on `owner_user_id` = the owner's stable +`account_id` (`0001_init.sql:76`), never the owner's handle, so a user rename +touches exactly one `account_handles` row and every owned agent re-renders +through the new owner handle automatically. + +**Cross-human reclaim safety is NOT solved here.** Reclaim lets a freed user +handle be re-registered by a *different* human, so a stale `matt/compass-ux` +addresser could resolve into a stranger's namespace. That blast radius is closed +one layer up, at the authorization edge, not by reserving handles: cross-user +agent-to-agent reachability requires a bilateral owner-peering approval (filed +**RIG-2796**), so the new owner of a reclaimed handle receives nothing from +another fleet until the two owners have mutually approved. The handle is just an +address; authorization is the peering edge, not the name. RIG-2796 sits ABOVE +this contract and does not reopen the storage shape. + +### The pattern being mirrored: `from_handle` + +The contract already has one field where the server owns the handle↔id +boundary: the denormalized `from_handle` on Steer/Deliver controls +(`proto/compass/v1/agent.proto:194-199` and `:223-229` — "the Server resolves +it once when wrapping the AgentControl"). The resolution site is +`go/internal/delivery/consumer.go:398-413` (`authorHandle`), which calls the +store's `GetAccount` (`consumer.go:109-113`) and denormalizes the handle onto +the wire op (`deliverOp`/`steerOp`, `consumer.go:370-393`). That is the shape +this cutover generalizes, in the opposite direction: the **request** carries +the handle, and the server resolves handle→id at the service edge before any +store call. + +### Where resolution lives: the service edge, not the store + +Store signatures stay id-typed (`store.AccountID` everywhere: +`CreateChannel` `go/internal/store/channels.go:81`, `UpdateChannelMembers` +`channels.go:407`, `SetChannelPolicy` `channels.go:596`, `OpenAgentWorkspace` +`channels.go:818`, `ReparentAgent` `go/internal/store/accounts.go:448`). The +comms handler edge already owns wire→store mapping +(`accountIDsFromWire`/`memberUpdatesFromWire`, +`go/internal/comms/mapping.go:264,280`); resolution slots in exactly there — +the wire converters become handle resolvers that consult the store once per +request, then hand ids to the unchanged store layer. The lifecycle +(`DespawnPeer`) equivalent lives in `go/server/lifecycle.go` where +`req.GetAgentAccountId()` is read today (`lifecycle.go:221`). + +The **admin/ops lane** (CompassService, adminOnly door) is the third resolution +site — Matt ruled OQ-4 EXTEND (2026-08-27), so no id-typed request field +survives anywhere. `SpawnAgent` (`go/server/spawn.go:87`) and +`ProvisionAgentWorkspace` (`go/server/service.go:139`) name an owned agent → +`AgentByHandle`, owner-qualified (an admin/UI caller supplies the `owner/` +qualifier explicitly; there is no agent-session `callerOwner` to default from on +this door); `IssueToken` (`service.go:416`) names a user OR agent → the general +`AccountsByHandles` singular path. Resolution slots in before the existing +`GetAccount` id lookups (`service.go:152,425`; `spawn.go:103,153,158`). +IssueToken-by-handle also lets the IaC path name a bot account by `@handle` +rather than a looked-up id. + +Two store lookups back the resolvers, both re-pointed at `account_handles` (the +handle column moves off `accounts` per §"The storage contract"): + +- **`AgentByHandle` resolves owner-qualified**: it takes the owner id (from the + parsed `owner/` qualifier, or the caller's own `owner_user_id` for a bare + handle) and the agent handle, and looks up the agent index + (`UNIQUE(owner_user_id, handle) WHERE owner_user_id IS NOT NULL`). It stays + non-elevating and agent-asserting; an unknown, non-agent, or wrong-owner + handle → `ErrNotFound` with a message indistinguishable from unknown. Today's + `AgentByHandle` (`go/internal/store/accounts.go:631-668`) resolves against the + global `accounts.handle`; the cutover re-keys it to `(owner_user_id, handle)` + over `account_handles`. +- A new general **`AccountsByHandles`** batch lookup for member/owner fields, + which legitimately name users as well as agents. Each input is an + owner-qualified or bare handle; the resolver parses the qualifier per input + and resolves in the matching namespace (bare → user/system global index or + the caller's own agent namespace; `owner/agent` → that owner's agent + namespace). No general public by-handle lookup exists today — + `adminByHandle`/`systemByHandle` are private and subtype-asserting + (`accounts.go:107-111,178-183`). + +### Error contract: in-band NOT_FOUND, oracle-safe + +An unresolvable handle maps to `store.ErrNotFound` → `connect.CodeNotFound` +through the existing edge mapping (`edgeError`, +`go/internal/comms/context.go:47-68`), which the agent gateway renders in-band +as `CommsCallError`/`LifecycleCallError` rather than a transport teardown. + +The not-found/forbidden merge is NOT a uniform existing invariant — only the +despawn path has it today (unknown, non-agent, and foreign-owner targets all +collapse into one indistinguishable `errPeerNotFound`, +`go/server/lifecycle.go:121-126,208-256`). The other flipped handlers must be +BROUGHT to that posture, because edge resolution turns their current splits +into a handle-enumeration oracle: an unknown handle gets the resolver's +NOT_FOUND while a real-but-foreign handle gets the handler's current distinct +error, and handles — unlike ids — are guessable. The invariant this record +mandates (DL-269): for every handle-addressed target, ANY post-resolution +authority/visibility failure returns the SAME +NOT_FOUND-naming-the-submitted-handle the resolver emits for an unknown +handle. Per-handler remap table: + +| Handler | Today | Remap | +| --- | --- | --- | +| DespawnPeer | already merged as NotFound (`go/server/lifecycle.go:121-126`) | unchanged | +| CreateAgent parent | split: unknown → NotFound, foreign → `CodePermissionDenied` "parent agent %q has a different owner" (`go/internal/comms/comms.go:124-133`) | foreign → NotFound naming the submitted handle | +| ReparentAgent | merged, but as `ErrPermissionDenied` "caller may not re-parent agent %q" (`go/internal/store/accounts.go:507-509`) | → NotFound naming the submitted handle | +| OpenAgentWorkspace | invisible target → store NotFound naming the resolved ACCOUNT ID (`%w: agent %q`, `go/internal/store/channels.go:839-840`), and `edgeError` maps store errors VERBATIM (`go/internal/comms/context.go:47-68`) — the resolved id of an invisible account would leak in the message | re-key the message to name the submitted HANDLE, never the resolved id | + +This deliberately changes two public error codes (ReparentAgent and the +CreateAgent parent check: PermissionDenied → NotFound). That is a contract +change made on purpose — mandated by DL-269's oracle invariant, not an +accident of the refactor. + +**Message parity**: `AgentByHandle` already keeps the unknown-handle and +wrong-subtype messages identical (`accounts.go:659-665`); the new batch +resolver holds the same line for member fields. Note there is NO existing +member-field NOT_FOUND to inherit: today an unknown member account surfaces as +`ErrInvalidArgument` "unknown member account %q" via FK violation +(`go/internal/store/channels.go:157-158`, `upsertMemberErr` +`channels.go:569-573`) — the NOT_FOUND posture for member handles is DEFINED +by this record, not carried over. Member resolution is additionally +visibility-scoped (invisible ≡ unknown): Matt RULED OQ-6 SCOPED (2026-08-27) — +the T2 resolver intersects the shared D9 predicate `accountVisibleFromWhere` +(`go/internal/store/accounts.go:683-698`) after the namespace split, aligning +with the roster clip which reads the same predicate +(`go/internal/comms/roster.go:43-64`), so resolution and roster stay aligned +by construction. + +### GetRoster's dual vantage + +`GetRosterRequest.agent_account_id` is dual-path today: an agent caller leaves +it empty and is session-resolved to itself; a human/UI caller may name a +vantage (`proto/compass/v1/comms.proto:698-704`, vantage defaulting in +`go/internal/comms/roster.go:24-36`; the agent tool deliberately never sets it, +`packages/compass-agent/src/comms.ts:729-733`; the UI stream also sends it +empty, `apps/ui/src/live/stream.ts:99-101`). The cutover keeps the exact +semantics with a handle-typed field: empty ⇒ caller vantage (agent +session-resolved, unchanged); non-empty ⇒ a handle the server resolves via +`AgentByHandle`. No ambiguity arises because the two paths were already +discriminated by emptiness, not by type. The field's final name is +`vantage_handle` (Matt ruled OQ-3, 2026-08-27), not `agent_handle`. + +Roster's ERROR posture is DEFINED here, not inherited — today a bogus or +invisible id vantage produces NO resolution error at all: the tree is built, +then clipped to the caller-visible set (`roster.go:38-53`), degrading to an +empty/clipped roster. After the flip, an unknown handle → NOT_FOUND while a +real-but-caller-invisible handle would resolve (`AgentByHandle` is +owner-namespaced per §"The storage contract" but NOT viewer-scoped — an +invisible-but-real handle in the vantage's owner namespace still resolves, +`accounts.go:638-668` re-keyed) and return a clipped, likely empty, +roster SUCCESS — a NOT_FOUND-vs-empty-success vantage-probe oracle. Therefore: +post-resolve, the vantage handle MUST be in the caller-visible set (the same +`ListAccounts` projection the clip already fetches at `roster.go:47`), and an +invisible vantage maps to the identical NOT_FOUND an unknown handle gets. T3 +carries the test leg. + +### Explicit non-goal: responses, stored state, and events keep ids + +**Response/stored/event account fields do NOT flip.** Handing back handles on +responses would push id→handle resolution onto the client — the opposite of +the ruling. For the UI lane this holds: the UI acts on ids the server already +gave it (roster entries, channel snapshots, message authors all carry ids +today and the UI joins them locally, e.g. `apps/ui/src/live/adapt.ts:173-208`); +the concern the ruling fixes is INPUT resolution only. The AGENT lane is the +known exception, and Matt RULED it (OQ-5, 2026-08-27): the agent's ListMessages +tool renders raw author ids into the model-visible fence +(`author="${attr(m.authorAccountId, fence)}"`, +`packages/compass-agent/src/comms.ts:694`) and the agent has no id→handle +resolver (`CommsCallRequest` carries no ListAccounts arm, +`proto/compass/v1/agent_gateway.proto:106-114`). Disposition: ship an additive +`author_handle` sibling on `Message` now (DL-270's response-sibling path); the +agent fence renders `author_handle` ONLY and drops the id — agents never see an +id — while `author_account_id` STAYS on the wire `Message` for the UI's local +join (`adapt.ts:173-208`) and its rename-proof correlation key. Unchanged, +deliberately: +(`comms.proto:233,238,243`), `AgentWorkspace.agent_account_id` +(`comms.proto:293`), `Topic.created_by_account_id` (`comms.proto:312`), +`ChannelChanged.removed_account_ids` (`comms.proto:526`), +`AgentPresenceChanged.agent_account_id` (`comms.proto:555`), +`RosterEntry.agent_account_id` (`comms.proto:723` — it already carries +`handle` at `:724` beside the id, the right dual shape), +`SpawnPeerResponse.agent_account_id` (`agent_gateway.proto:175`). If a UI +surface genuinely needs a handle a response lacks, that is a future question to +raise, not a reflex flip. + +### Sequencing against the in-flight org-management stack + +PRs #628 (proto arms, draft) and #630 (handlers, draft, stacked on #628) are +held in draft specifically so their three new `CommsCallRequest` arms +(`create_channel` = 7, `update_members` = 8, `create_channel_group` = 9) ship +handle-first. Because #628 reuses the `comms.proto` payload messages verbatim, +flipping the payload messages here flips the arms automatically. The order: + +1. **This cutover's proto change lands first** (T1): `comms.proto`, + `agent_gateway.proto`, and `compass.proto` (admin lane) request fields flip, + all four gen lanes regenerate. +2. **#628 rebases onto it** (regen-only delta — its arms reference the + now-handle-typed payloads verbatim). +3. **#630's handlers rebase** to do the server-side resolution for the new + arms (its adapters call the T3 resolvers). +4. **compass-agent #632 rewires** its tool params to handles + (`member_handles` etc. instead of `member_account_ids`). +5. **compass-runner / UI consumers** last (the UI sends none of the flipped + fields non-empty today — `stream.ts:99-101` — so its rewire is + forward-looking, not a breakage fix). + +**Skew window (bounded, not denied)**: live agent containers outlive a server +redeploy, so an agent spawned pre-cutover holds the old generated bundle and +old tool schemas (`despawnParameters` still takes `agent_account_id`, +`packages/compass-agent/src/lifecycle.ts:91-95`) and sends an id string into +the now-handle-semantic field (same field number, same string wire type). The +server resolves that id AS a handle → miss → in-band NOT_FOUND tool error +until the container is respawned on the new bundle. Acceptable because the +failure is in-band (no decode error, no transport teardown — a point FOR +rename-in-place), the affected surface for existing agents is despawn + +roster-vantage only, and the fleet respawns on deploy cadence. If fleet +respawn is NOT guaranteed on the deploy that ships the cutover, T5 adds the +explicit respawn step and names who forces it. + +## Field inventory (verified against current main) + +Request-input account fields that flip id→handle. Line numbers are from the +current tree at authoring time. + +| # | Message.field | Location | Handler read site | +| --- | --- | --- | --- | +| 1 | `CreateChannelRequest.member_account_ids` (field 4) | `proto/compass/v1/comms.proto:647` | `go/internal/comms/comms.go:228` (`accountIDsFromWire`) | +| 2 | `UpdateChannelMembersRequest.add_member_account_ids` (2) | `comms.proto:658` | `comms.go:250` via `memberUpdatesFromWire` (`mapping.go:280`) | +| 3 | `UpdateChannelMembersRequest.remove_member_account_ids` (3) | `comms.proto:660` | same | +| 4 | `UpdateChannelMembersRequest.subscribe_account_ids` (4) | `comms.proto:662` | same | +| 5 | `UpdateChannelMembersRequest.unsubscribe_account_ids` (5) | `comms.proto:664` | same | +| 6 | `ReparentAgentRequest.agent_account_id` (1) | `comms.proto:673` | `comms.go:277` | +| 7 | `ReparentAgentRequest.new_parent_agent_id` (2) | `comms.proto:675` | `comms.go:278` | +| 8 | `SetChannelPolicyRequest.owner_account_id` (3) | `comms.proto:688` | `comms.go:476` | +| 9 | `GetRosterRequest.agent_account_id` (2) — dual-path, see Approach | `comms.proto:703` | `go/internal/comms/roster.go:27-36` | +| 10 | `OpenAgentWorkspaceRequest.agent_account_id` (1) | `comms.proto:758` | `comms.go:300` | +| 11 | `CreateAgentRequest.parent_agent_id` (3) | `comms.proto:590` | `comms.go:124-141` | +| 12 | `DespawnPeerRequest.agent_account_id` (1) | `proto/compass/v1/agent_gateway.proto:184` | `go/server/lifecycle.go:221` | +| 13 | The 3 new `CommsCallRequest` arms on PR #628 (`create_channel` = 7, `update_members` = 8, `create_channel_group` = 9) | #628 head, `agent_gateway.proto` | reuse rows 1–5 verbatim (`create_channel_group` carries no account field — `comms.proto:607-613` — it rides the flip only via its sibling arms' payloads) | +| 14 | `SpawnAgentRequest.agent_account_id` (1) — admin/ops lane (CompassService, adminOnly) | `proto/compass/v1/compass.proto:650` | `go/server/spawn.go:103,153,158` | +| 15 | `ProvisionAgentWorkspaceRequest.agent_account_id` (1) — admin/ops lane | `compass.proto:567` | `go/server/service.go:152,166` | +| 16 | `IssueTokenRequest.account_id` (1) — admin/ops lane; names a user OR agent | `compass.proto:702` | `go/server/service.go:420` | + +Rows 7 and 11 were not in the original RIG-2751 enumeration but are the same +class (an agent-account input a UI would otherwise have to resolve): flipping +`ReparentAgentRequest.agent_account_id` while leaving `new_parent_agent_id` +id-typed would leave the caller resolving an id for the same request. + +**The precedent to mirror**: `SpawnPeerRequest.handle` +(`agent_gateway.proto:168`) — the spawn tool already takes a handle and the +server owns the id (`SpawnPeerResponse.agent_account_id`, +`agent_gateway.proto:175`). + +**Out of scope**: `channel_id` / `group_id` / `parent_group_id` (not account +handles); every response/stored/event field (see the non-goal above); the +`asker`/author fields on stored messages (server-derived, never +client-supplied). The compass.proto admin/ops lane IS in scope (Matt ruled +OQ-4 EXTEND, 2026-08-27) — inventory rows 14–16. + +## Global Constraints + +- **Proto conventions**: buf lint runs as `compass-proto:lint`; the envelope + naming exceptions are documented in `agent_gateway.proto:22-28`. Comment + every flipped field with its resolution semantics (mirror the + `from_handle` comments, `agent.proto:194-199`). +- **Pre-GA breaking allowance**: the buf breaking gate is removed pre-dogfood + (`proto/moon.yml:169` — "RE-ADD AT GA", SEA-1922/SEA-1951; RIG-2675). A + breaking rename is allowed and this record uses it; DL-186 + (`docs/designs/DECISIONS.md:203`, Active) rules rename-in-place keeping + field numbers — renumber+reserve would re-add `reserved` markers DL-186 + stripped (OQ-1b is a confirm of this, not an open fork). +- **Codegen**: after any schema edit run `moon run compass-proto:gen` from the + repo root (regenerates all four lanes: `packages/compass-client/src/gen`, + `go/gen`, `packages/compass-agent/src/gen`, `go/internal/gen` — + `proto/moon.yml:33-68`); CI gates on `compass-proto:drift` and + `compass-proto:gen-fence` (`proto/moon.yml:69-171`). +- **Resolution pattern**: handle→id resolution lives at the SERVICE EDGE + (comms handler / lifecycle handler); `store.*` signatures stay id-typed. + Mirrors `from_handle`'s server-side-resolution posture + (`go/internal/delivery/consumer.go:398-413`). +- **Error posture**: unresolvable handle → `store.ErrNotFound` → + `connect.CodeNotFound` via `edgeError` (`go/internal/comms/context.go:47-68`), + in-band on the gateway. The oracle invariant is inviolable: a foreign, + non-visible, or wrong-subtype handle is byte-identical to an unknown one — + code AND message, and no error message may name a resolved account id. Today + only the despawn path has the merge (`go/server/lifecycle.go:121-126`); the + other handlers are brought to it via the §Error contract remap table. + Message parity per `accounts.go:659-665`. +- **Red-green**: every task lands tests first (rule://red-green-testing); + pgtests for store/handler tasks, bun:test for TS tasks. + +## Plan + +### T0 — schema: `account_handles` in `0001_init.sql` (no backfill) + +The `account_handles` table is authored directly into `0001_init.sql` — the +single existing migration — not as an incremental `0002` migration. Pre-dogfood +there is no deployed database and no data to migrate, so the schema the first +deployment creates already carries the final shape: `account_handles(account_id +TEXT PK/FK→accounts(id) ON DELETE RESTRICT, handle TEXT NOT NULL, owner_user_id +TEXT NULL FK→user_accounts(account_id) ON DELETE RESTRICT)` with the two +partial-unique indexes from §"The storage contract" (`UNIQUE(handle) WHERE +owner_user_id IS NULL`; `UNIQUE(owner_user_id, handle) WHERE owner_user_id IS +NOT NULL`). In the same file `accounts.handle` is authored WITHOUT its former +global-unique constraint (`0001_init.sql:38` today) — the column stays (display +reads still use it and `store.CreateUser`/`CreateAgent` still populate it at +runtime, `accounts.go:29,254`) but is no longer the resolution key; the +resolution source of truth becomes `account_handles`. There +is no backfill step — no deployed rows exist to migrate. + +This task is INDEPENDENT of the proto flip (T1) — schema vs wire — and lands in +the same server PR. The store resolvers (T2) and every edge resolver (T3/T4) +read `account_handles`, so T0 lands before or with T2 in the stack. + +- **Interfaces**: the migration file + the store's handle read/write paths + re-pointed at `account_handles` (`CreateUser`/`CreateAgent` insert a handle + row in the same tx that inserts the account; a rename UPDATEs the handle row). +- **Test cycle**: pgtests — the two uniqueness invariants (a second global + `matt` rejected; a second `matt/compass-ux` rejected; `matt/compass-ux` and + `alice/compass-ux` coexist; an agent handle overlapping a user handle + coexists), rename in-place both tiers, reclaim of a freed handle both tiers. + +### T1 — proto flip + regen (lands first; #628/#630 rebase onto it) + +Rename and re-comment every row of the inventory in `comms.proto`, +`agent_gateway.proto`, and `compass.proto` (admin rows 14–16) to its handle +form (final names, Matt ruled OQ-1 2026-08-27): `member_handles`, +`add_member_handles`, `remove_member_handles`, `subscribe_handles`, +`unsubscribe_handles`, `agent_handle`, `new_parent_handle`, `owner_handle`, +`parent_handle`, `account_handle` (IssueToken admin lane, OQ-4), and the roster vantage field `vantage_handle` (OQ-3). Semantics +comment on each: "a `@handle`; the server resolves it to an account id; unknown +→ NOT_FOUND". Field numbers are kept in place (Matt confirmed OQ-1b +rename-in-place per Active DL-186). Regenerate all four lanes. + +- **Interfaces**: proto fields per the inventory table (same string wire type; + field numbers kept in place per OQ-1b). `GetRosterRequest.vantage_handle`: + empty ⇒ caller vantage (unchanged session-resolved semantics), non-empty ⇒ + server-resolved agent handle. +- **Test cycle**: `moon run compass-proto:lint compass-proto:drift + compass-proto:gen-fence`; Go/TS builds red until T3/T6 land in the same + stack — T1 therefore lands as the first commit of the server PR, not as a + green standalone merge to main. + +### T2 — store batch handle resolver + +Add a batch resolver in `go/internal/store/accounts.go` over `account_handles` +(T0's table), plus a small edge helper that parses the `owner/handle` qualifier +before it. Each input is owner-qualified or bare; the resolver splits on the +first `/`, resolves the owner segment in the user/system index +(`UNIQUE(handle) WHERE owner_user_id IS NULL`), then the agent segment in that +owner's agent index (`UNIQUE(owner_user_id, handle)`); a bare handle resolves +either as a user/system handle in the global index or as an agent handle in the +**caller's own** owner namespace (the edge supplies `callerOwner` from +session→account). Its signature is visibility-scoped: Matt RULED OQ-6 SCOPED +(2026-08-27), so the resolver form is +`Store.AccountsByHandles(ctx context.Context, viewer AccountID, callerOwner AccountID, handles []QualifiedHandle) (map[string]AccountID, error)`, +one query per namespace against `account_handles` intersected with +`accountVisibleFromWhere` (`accounts.go:683-698`) so an invisible handle misses +exactly like an unknown one. +No subtype assertion beyond the index split (member/owner fields +legitimately name users and agents; never the system account — exclude +`system_accounts` rows, matching the roster/delivery exclusion in +`go/internal/store/system_account_exclusion_pgtest_test.go:11-14`). Any missing +handle → `ErrNotFound`; the resolver returns the full hit map, so the +set-difference is free and the error names ALL unresolved handles (in their +submitted `owner/handle` spelling), not just the first, with the same message +template as `AgentByHandle` (`accounts.go:655,665`). Agent-typed singular fields +reuse `AgentByHandle`, re-keyed to `(owner_user_id, handle)` over +`account_handles` per §"Where resolution lives" (`accounts.go:638`). + +- **Interfaces**: `AccountsByHandles(ctx context.Context, viewer AccountID, + callerOwner AccountID, handles []QualifiedHandle) (map[string]AccountID, error)` + over `account_handles` — the `viewer` param carries the OQ-6-ruled visibility + scope; + `callerOwner` supplies the bare-agent-handle default namespace; a + `QualifiedHandle` carries the parsed `{owner, handle}` (owner empty = bare). + Atomic — any missing handle fails the whole call, the error naming every + unresolved handle in its submitted spelling (per the OQ-2 ruling — atomic, naming all unresolved handles). +- **Test cycle**: pgtests — round-trip, owner-qualified agent resolution + (`matt/compass-ux` vs `alice/compass-ux` disambiguated by owner), bare agent + handle defaulting to the caller's owner, bare user/system handle in the global + index, missing-handle NOT_FOUND naming ALL missing handles, system-handle + NOT_FOUND, invisible-handle ≡ unknown (OQ-6a leg), user+agent mixed + resolution, empty-input no-op. + +### T3 — comms edge resolution + +Replace the id pass-throughs in `go/internal/comms/mapping.go:264,280` and the +handler read sites (`comms.go:228,250,277-278,300`, `comms.go:476`, +`comms.go:124-141`, `roster.go:27-36`) with resolvers: repeated member fields +resolve through +`AccountsByHandles`; singular agent fields (`agent_handle`, +`new_parent_handle`, roster vantage, workspace target, `parent_handle` on +CreateAgent) through `AgentByHandle`; `owner_handle` through +`AccountsByHandles` (an owner may be a user). Resolver misses flow through +`edgeError`; post-resolution authority/visibility failures on handle-addressed +targets are remapped per the §Error contract table (CreateAgent parent +foreign → NotFound; ReparentAgent's `ErrPermissionDenied` merge → NotFound; +OpenAgentWorkspace's store NotFound re-keyed to name the submitted handle, +never the resolved id). `memberUpdatesFromWire` keeps its merge-by-account +semantics, now keyed post-resolution so two spellings of one handle cannot +yield two conflicting MemberUpdates. + +- **Interfaces**: comms handler signatures unchanged; new unexported + `resolveHandles(ctx, st, []string) ([]store.AccountID, error)` + + `resolveAgentHandle(ctx, st, string) (store.AccountID, error)` helpers in + `go/internal/comms`; store calls unchanged + (`channels.go:81,407,596,818`, `accounts.go:448`). +- **Test cycle**: pgtests per RPC — happy path, unknown-handle NOT_FOUND, + D9-invisible-account parity (invisible ≡ unknown), the remap legs (foreign + parent on CreateAgent, foreign target on ReparentAgent, invisible target on + OpenAgentWorkspace — each byte-identical to unknown, code AND message), + roster empty-vantage session-resolution regression AND roster + invisible-vantage → NOT_FOUND (`roster_pgtest_test.go` extends). + +### T4 — lifecycle despawn resolution + +`go/server/lifecycle.go` Despawn: the ordering MUST preserve the documented +constant-query-shape bar (`lifecycle.go:225-241`: caller-first ordering makes +the unknown-target and foreign-target outcomes both run exactly two queries so +latency cannot distinguish them; a naive resolve-target-first would make +unknown = 1 query and foreign = 3 — the exact existence probe the merge exists +to prevent, on the MORE enumerable input). Order: resolve `callerOwner` via +`AgentOwner` FIRST (unchanged), THEN `AgentByHandle(handle)` — a miss folds +into the existing `errPeerNotFound` merge (`lifecycle.go:121-126,247-256`); a +hit compares `acc.OwnerUserID` against `callerOwner` directly (`AgentByHandle` +already selects `ag.owner_user_id` and returns the full `Account`, +`accounts.go:641-651`, so the separate `AgentOwner(target)` query is DELETED). +Both outcomes run exactly two queries; unknown handle ≡ foreign peer ≡ +non-agent, byte-identical. Self-despawn guard (`lifecycle.go:221-224`) +compares post-resolution ids. + +The despawn `agent_handle` is owner-qualified like every other agent handle; a +bare handle defaults to the caller's own owner namespace, which is exactly the +peer set despawn already scopes to (the `callerOwner` comparison), so the +common same-owner despawn stays a bare handle and the resolution re-keys to +`(callerOwner, handle)` over `account_handles` with no change to the +constant-query-shape ordering above. + +- **Interfaces**: `DespawnAsAccount(ctx, caller store.AccountID, req + *compassv1internal.DespawnPeerRequest)` unchanged externally; internal target + derivation becomes handle-resolved. +- **Test cycle**: extend `lifecycle_pgtest_test.go` / + `lifecycle_e2e_pgtest_test.go` — unknown-handle vs foreign-handle + indistinguishability (code AND message, mirroring + `lifecycle_e2e_pgtest_test.go:348-433`), self-despawn by own handle, + idempotent re-despawn by handle. + +### T5 — rebase the org-management stack (#628, #630) + +PR #628 rebases onto T1 (regen-only: its arms reuse the now-handle-typed +payload messages verbatim). #630's adapters +(`CreateChannelAsAccount`/`UpdateChannelMembersAsAccount`, per its PR body) +inherit T3's resolution for free since they delegate to the shared handler +path; its pgtests gain unknown-handle legs. + +T5 also owns the §Sequencing skew-window close-out: confirm live agents are +respawned on the deploy that ships the cutover; if fleet respawn is not +automatic on server deploy, this task adds the explicit respawn step and names +its operator. + +- **Interfaces**: no new ones — a coordination task with its own verify + (stack CI green post-rebase). +- **Test cycle**: #630's existing suites + new unknown-handle legs. + +### T6 — compass-agent tool rewire + +`packages/compass-agent/src/lifecycle.ts:91-95,181-217`: `agents_despawn_peer` +takes `agent_handle` (non-blank), builds `DespawnPeerRequest{agentHandle}`; +description drops "by its agent account id". `comms.ts` roster tool unchanged +(it never set the vantage, `comms.ts:729-733`). #632's three new tools +re-author their params handle-first (`member_handles` etc.) on its rebase. + +- **Interfaces**: tool param schemas (`despawnParameters`, + `lifecycle.ts:91-95`; #632's `create_channel`/`update_members` params); + wire messages from the T1 regen. +- **Test cycle**: `lifecycle.test.ts` / `comms.test.ts` wire-shape asserts + updated red→green; `bun test` in `packages/compass-agent`. + +### T7 — UI/client sweep + smoke + +The UI sends none of the flipped fields non-empty today +(`apps/ui/src/live/stream.ts:99-101` leaves the roster vantage empty; no UI +call site constructs Create/Update/Reparent/SetPolicy/OpenWorkspace requests +with account inputs — the id usages in `apps/ui/src` are all RESPONSE-side +joins, e.g. `adapt.ts:173-208`). Sweep confirms zero live request-side callers, +updates `comms-stub.ts` commentary if field names leak into docs, and runs the +UI suite against the regenerated client. + +- **Interfaces**: none new; regenerated `packages/compass-client/src/gen`. +- **Test cycle**: `bun test` in `apps/ui`; grep-verify no `_account_ids` + request construction remains outside response adapters. + +### T8 — admin/ops lane (CompassService) handle resolution + +The three admin/ops request fields (inventory rows 14–16) resolve at the +CompassService handler edge, mirroring T3's comms edge. `SpawnAgent` +(`go/server/spawn.go:87`) and `ProvisionAgentWorkspace` +(`go/server/service.go:139`) resolve `agent_handle` via `AgentByHandle`; +`IssueToken` (`service.go:416`) resolves `account_handle` via the singular +`AccountsByHandles` path (it may name a user or an agent). Because this is the +adminOnly door with no agent session, the caller supplies the `owner/` qualifier +explicitly — there is no `callerOwner` default. Resolution slots in before the +existing `GetAccount` id lookups (`spawn.go:103,153,158`; `service.go:152` for +Provision — the `hub.Provision` relay at `:166` then consumes the resolved id — +`:420,425` for IssueToken); an unresolvable handle → the same +in-band NOT_FOUND the existing `GetAccount` miss already returns +(`service.go:154-156,427-429`), naming the submitted handle not a resolved id. + +- **Interfaces**: CompassService handler signatures unchanged; the three request + fields become handle-typed (T1 regen); internal id derivation becomes + handle-resolved. +- **Test cycle**: handler pgtests — spawn/provision/issue-token by owner-qualified + handle, unknown-handle NOT_FOUND, system-handle refusal on IssueToken + (unchanged from `service.go:436-438`). + +### T9 — additive `author_handle` on `Message` + agent handle-only fence + +Add an additive `author_handle` field to `Message` (`proto/compass/v1/comms.proto:332`, +next free field number 6 — `author_account_id = 3` STAYS). The server populates +it wherever it builds a `Message` for the wire, resolving `author_account_id`→handle +the same way `from_handle` denormalizes (`go/internal/delivery/consumer.go:398-413`). +The agent's ListMessages fence renders `author_handle` ONLY and drops the id +(`packages/compass-agent/src/comms.ts:694` — `author="${attr(m.authorHandle, fence)}"`), +so an agent never sees an account id. The UI keeps keying its local join on +`author_account_id` (`apps/ui/src/live/adapt.ts:173-208`), unchanged. This is +DL-270's additive-sibling path — a response field gaining a handle beside its +id, never a retype. + +- **Interfaces**: `Message.author_handle` (new field 6); server Message-build + sites populate it; the agent fence render switches to it. +- **Test cycle**: pgtest/handler — `author_handle` populated on posted messages; + `comms.test.ts` fence-shape assert renders handle, no id; `bun test` in + `packages/compass-agent`. + +## Tasks + +- [ ] T0 — `account_handles` in `0001_init.sql` (table + two partial-unique + indexes, no backfill) + author `accounts.handle` without global-unique; store + handle read/write re-pointed at `account_handles` + pgtests +- [ ] T1 — proto flip (inventory rows 1–16, incl. admin lane) + 4-lane regen; + lint/drift/fence green +- [ ] T2 — `Store.AccountsByHandles` owner-qualified batch resolver + + `AgentByHandle` re-keyed to `(owner, handle)` + pgtests +- [ ] T3 — comms edge resolution (mapping.go + handler sites) + pgtests +- [ ] T4 — lifecycle despawn handle resolution, merged NOT_FOUND + pgtests +- [ ] T5 — rebase #628/#630 handle-first; stack CI green +- [ ] T6 — compass-agent despawn tool + #632 tools rewired to handles + bun + tests +- [ ] T7 — UI/client sweep, regenerated client, UI suite green +- [ ] T8 — admin/ops lane (CompassService) handle resolution + (spawn/provision/issue-token) + handler pgtests +- [ ] T9 — additive `Message.author_handle` + server populate + agent fence + renders handle-only + bun tests + +## Ledger impact + +No existing DL row mandates id-typed request addressing +(`docs/designs/DECISIONS.md` grepped for handle/addressing: DL-094 covers +display attribution — "the bare `@handle`... owner resolved server-side" — +DL-188/DL-191 cover reserved system handles, DL-202 covers forge provider +addressing; none constrains request account fields). One DL row DOES bear on +the mechanics: **DL-186** (`DECISIONS.md:203`, Active) strips pre-dogfood +proto wire-compat — all `reserved` markers removed across compass/v1, live +fields densely renumbered, the buf breaking gate removed (re-armed at GA) — +which rules OQ-1b's renumber+reserve alternative OUT unless Matt overrides an +Active row. The cutover ADDS three rows on merge (next-free at authoring; +confirm at freeze): + +- **DL-269**: every request-input account field on the compass proto + contract is handle-typed; the server resolves handle→account_id at the + service edge (the `from_handle` posture generalized); an unresolvable, + invisible, foreign, or wrong-subtype handle is one indistinguishable in-band + NOT_FOUND. No agent or client UI ever resolves an id. +- **DL-270**: response, stored, and event account fields stay id-typed + (ids are the stable join keys clients already hold); a response that needs a + handle for display carries it as an explicit sibling field (the + `RosterEntry.agent_account_id`+`handle` dual, `comms.proto:723-724`), never + by retyping the id field. +- **DL-271**: handle→id resolution is owner-namespaced, stored in a dedicated + `account_handles(account_id, handle, owner_user_id NULL)` table with two + partial-unique indexes (user/system handles globally unique; agent handles + unique per owner). Agents are addressed owner-qualified (`matt/compass-ux`, + bare = caller's own owner); users/system bare. Rename is in-place for both + tiers; reclaim is allowed for both tiers (no history, tombstone, or + reservation). Cross-human reclaim safety is the owner-peering authorization + edge (RIG-2796), not handle reservation. The handle column stays on + `accounts` for display only; `account_handles` is the resolution key. + +## Rulings (Open Questions — all closed) + +Matt ratified the storage/format contract on 2026-08-26 ("LGTM, can start"), +which CLOSED the negotiation forks: storage shape (`account_handles` + two +partial-unique indexes), the owner-qualified wire (agents `owner/handle`, +users/system bare), and rename/reclaim policy (in-place both tiers, reclaim +allowed, no history/reservation, cross-human safety → RIG-2796). The remaining +wire-cutover open questions (OQ-1…7) were then Matt-ruled 2026-08-27 (RIG-2751 +comment f59001de). All are closed below; the record is ready to freeze. + +1. **Field naming (OQ-1) — RULED as proposed.** `*_account_ids`→`*_handles`, + `agent_account_id`→`agent_handle`, `new_parent_agent_id`→`new_parent_handle`, + `owner_account_id`→`owner_handle`, `parent_agent_id`→`parent_handle`. +2. **Field numbering (OQ-1b) — RULED rename-in-place.** Field numbers kept + (Active DL-186; renumber+reserve would re-add `reserved` markers DL-186 + stripped). Rename-in-place is also what makes the §Sequencing skew window + degrade gracefully (an old bundle's id string parses fine and fails + resolution in-band, never a decode error). +3. **Repeated-field NOT_FOUND semantics (OQ-2) — RULED ATOMIC.** One bad handle + fails the whole request (matching the store's one-tx posture, + `channels.go:407-410`), the error naming ALL unresolved handles in their + submitted spelling, not just the first. +4. **GetRoster vantage field name (OQ-3) — RULED `vantage_handle`.** Not + `agent_handle`, which would invite misuse on a field agents must leave empty. +5. **compass.proto admin lane (OQ-4) — RULED EXTEND.** The three admin/ops + fields (`SpawnAgentRequest.agent_account_id` `compass.proto:650`, + `ProvisionAgentWorkspaceRequest.agent_account_id` `compass.proto:567`, + `IssueTokenRequest.account_id` `compass.proto:702`) flip to handles too, so + no id-typed request field survives anywhere (inventory rows 14–16; T8). + IssueToken-by-handle also helps the IaC path name a bot account by `@handle`. +6. **Agent-facing author id (OQ-5) — RULED ship the additive sibling now.** Add + `author_handle` to `Message`; the agent fence renders handle-only and drops + the id; `author_account_id` stays on the wire for the UI join (DL-270's + response-sibling path; T9). +7. **Member-resolution visibility scoping (OQ-6) — RULED SCOPED (option a).** + The T2 resolver intersects `accountVisibleFromWhere` + (`go/internal/store/accounts.go:683-698`) after the namespace split, so + invisible ≡ unknown holds and naming-a-member gains the visibility gate the + id path never had — aligned with the roster clip, which reads the same + predicate (`go/internal/comms/roster.go:43-64`). **Follow-on (Matt-directed, + NOT in this freeze):** widen D9 so an agent always sees every agent owned by + its own owner-user (today same-owner siblings are visible only via a shared + channel; `accountVisibleFromWhere`'s `ag.owner_user_id = $1` matches only a + *user* caller). Because both the OQ-6 resolver and the roster intersect the + one shared predicate, widening it lights up both surfaces at once. This lands + as its OWN compass-server change to `accountVisibleFromWhere`; the resolver + spec here stays "intersects the shared D9 predicate" and inherits the wider + one. Filed as a separate compass-server issue. +8. **Owner-qualifier grammar (OQ-7) — RULED as proposed.** `/` separator, + exactly one level (no nesting), leading `/` illegal, both segments obey the + existing handle charset. A bare handle is a user/system handle OR the + caller's own agent; on a bare-handle collision the user/system global index + wins (a human is never shadowed by one of your agents), so address your own + agent that collides with a username by qualifying it (`matt/compass-ux`).