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
Section titled “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
Section titled “Approach”The storage contract: account_handles + owner-qualified resolution
Section titled “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_idis NULL for user and system accounts, the owning user’s id for agent accounts (it mirrorsagent_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’saccounts.handleglobal-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→ resolvemattbare in the user/system index → itsaccount_idis theowner_user_id→ resolvecompass-uxin the agent index under that owner.- A bare handle from an agent caller defaults to the caller’s own owner
namespace (
compass-uxfrom one of matt’s agents meansmatt/compass-ux), resolved from the session→account→owner_user_idthe 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
Section titled “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
Section titled “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”):
AgentByHandleresolves owner-qualified: it takes the owner id (from the parsedowner/qualifier, or the caller’s ownowner_user_idfor 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 →ErrNotFoundwith a message indistinguishable from unknown. Today’sAgentByHandle(go/internal/store/accounts.go:631-668) resolves against the globalaccounts.handle; the cutover re-keys it to(owner_user_id, handle)overaccount_handles.- A new general
AccountsByHandlesbatch 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/systemByHandleare private and subtype-asserting (accounts.go:107-111,178-183).
Error contract: in-band NOT_FOUND, oracle-safe
Section titled “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
Section titled “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
Section titled “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
Section titled “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:
- This cutover’s proto change lands first (T1):
comms.proto,agent_gateway.proto, andcompass.proto(admin lane) request fields flip, all four gen lanes regenerate. - #628 rebases onto it (regen-only delta — its arms reference the now-handle-typed payloads verbatim).
- #630’s handlers rebase to do the server-side resolution for the new arms (its adapters call the T3 resolvers).
- compass-agent #632 rewires its tool params to handles
(
member_handlesetc. instead ofmember_account_ids). - 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)
Section titled “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
Section titled “Global Constraints”- Proto conventions: buf lint runs as
compass-proto:lint; the envelope naming exceptions are documented inagent_gateway.proto:22-28. Comment every flipped field with its resolution semantics (mirror thefrom_handlecomments,agent.proto:194-199). - Pre-GA breaking allowance: the buf breaking gate is removed pre-dogfood
(
proto/moon.yml:169— “RE-ADD AT GA”, RIG-1922/RIG-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-addreservedmarkers DL-186 stripped (OQ-1b is a confirm of this, not an open fork). - Codegen: after any schema edit run
moon run compass-proto:genfrom 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 oncompass-proto:driftandcompass-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. Mirrorsfrom_handle’s server-side-resolution posture (go/internal/delivery/consumer.go:398-413). - Error posture: unresolvable handle →
store.ErrNotFound→connect.CodeNotFoundviaedgeError(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 peraccounts.go:659-665. - Red-green: every task lands tests first (rule://red-green-testing); pgtests for store/handler tasks, bun:test for TS tasks.
T0 — schema: account_handles in 0001_init.sql (no backfill)
Section titled “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/CreateAgentinsert 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
mattrejected; a secondmatt/compass-uxrejected;matt/compass-uxandalice/compass-uxcoexist; 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)
Section titled “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
Section titled “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)overaccount_handles— theviewerparam carries the OQ-6-ruled visibility scope;callerOwnersupplies the bare-agent-handle default namespace; aQualifiedHandlecarries 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-uxvsalice/compass-uxdisambiguated 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
Section titled “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 ingo/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.goextends).
T4 — lifecycle despawn resolution
Section titled “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, mirroringlifecycle_e2e_pgtest_test.go:348-433), self-despawn by own handle, idempotent re-despawn by handle.
T5 — rebase the org-management stack (#628, #630)
Section titled “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
Section titled “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’screate_channel/update_membersparams); wire messages from the T1 regen. - Test cycle:
lifecycle.test.ts/comms.test.tswire-shape asserts updated red→green;bun testinpackages/compass-agent.
T7 — UI/client sweep + smoke
Section titled “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 testinapps/ui; grep-verify no_account_idsrequest construction remains outside response adapters.
T8 — admin/ops lane (CompassService) handle resolution
Section titled “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
Section titled “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_handlepopulated on posted messages;comms.test.tsfence-shape assert renders handle, no id;bun testinpackages/compass-agent.
- T0 —
account_handlesin0001_init.sql(table + two partial-unique indexes, no backfill) + authoraccounts.handlewithout global-unique; store handle read/write re-pointed ataccount_handles+ pgtests - T1 — proto flip (inventory rows 1–16, incl. admin lane) + 4-lane regen; lint/drift/fence green
- T2 —
Store.AccountsByHandlesowner-qualified batch resolver +AgentByHandlere-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
Section titled “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_handleposture 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+handledual,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 onaccountsfor display only;account_handlesis the resolution key.
Rulings (Open Questions — all closed)
Section titled “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.
- 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. - Field numbering (OQ-1b) — RULED rename-in-place. Field numbers kept
(Active DL-186; renumber+reserve would re-add
reservedmarkers 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). - 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. - GetRoster vantage field name (OQ-3) — RULED
vantage_handle. Notagent_handle, which would invite misuse on a field agents must leave empty. - compass.proto admin lane (OQ-4) — RULED EXTEND. The three admin/ops
fields (
SpawnAgentRequest.agent_account_idcompass.proto:650,ProvisionAgentWorkspaceRequest.agent_account_idcompass.proto:567,IssueTokenRequest.account_idcompass.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. - Agent-facing author id (OQ-5) — RULED ship the additive sibling now. Add
author_handletoMessage; the agent fence renders handle-only and drops the id;author_account_idstays on the wire for the UI join (DL-270’s response-sibling path; T9). - 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’sag.owner_user_id = $1matches 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 toaccountVisibleFromWhere; the resolver spec here stays “intersects the shared D9 predicate” and inherits the wider one. Filed as a separate compass-server issue. - 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).