From cc3295e7d6ebd6b5e4708e945081cd385f11610e Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 25 Aug 2026 23:47:55 -0400 Subject: [PATCH 1/8] =?UTF-8?q?docs(product):=20design=20contract-wide=20i?= =?UTF-8?q?d=E2=86=92handle=20request=20cutover=20(RIG-2751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design record for the contract-wide id→handle cutover of the compass request surface (RIG-2751), per Matt's Option-A ruling: every request-input account field takes a `@handle`; the server resolves it to an account id exactly as the existing `from_handle` field does; no agent or client UI ever resolves an id; an unresolved handle surfaces as an in-band NOT_FOUND. ## Scope Field inventory verified complete against the current tree (all 7 proto files grepped): the request-input account fields across `comms.proto` (CreateChannel members, UpdateChannelMembers add/remove/subscribe/unsubscribe, ReparentAgent agent+new-parent, SetChannelPolicy owner, GetRoster vantage, OpenAgentWorkspace, CreateAgent parent) and `agent_gateway.proto` (DespawnPeer), plus the three new `CommsCallRequest` arms held in draft on #628 (re-authored handle-first here). `agents_spawn_peer` already takes a handle — the precedent this mirrors. Explicit non-goal (a decided boundary, not a reflex flip): response/stored/event fields keep ids — handing a handle back on a response pushes resolution onto the client, the opposite of the ruling. The one exception is surfaced in OQ-5. ## Design-critic pass folded Red-teamed by `CritiqueHandleCutover`; the architecture (edge-not-store resolution, in-band NOT_FOUND, dual-vantage-by-emptiness, T1-first sequencing) survived. Seven oracle-safety and grounding improvements folded: a per-handler NOT_FOUND remap table (two handlers today leak an unknown-vs-foreign oracle via PermissionDenied); T4's ordering preserves the lifecycle constant-query-shape bar; GetRoster's invisible-vantage posture is now defined, not inherited; the agent ListMessages author-id fence named as the concrete non-goal exception; error names all unresolved handles; a bounded skew window stated; and two load-bearing grounding corrections (the store returns ErrInvalidArgument, not NOT_FOUND, for unknown members). ## Open questions (freeze-gate decisions for Matt) - **OQ-1** — field naming (taste). - **OQ-1b** — field numbering: a CONFIRM of DL-186's rename-in-place, not an open fork. - **OQ-2** — repeated-field NOT_FOUND: a CONFIRM of atomic-failure, error naming all unresolved handles. - **OQ-3** — GetRoster vantage field name (taste). - **OQ-4** — compass.proto admin lane: extend the ruling to the 3 admin fields or keep ids (real scope fork). - **OQ-5** — response handles: ship the DL-NEW-2 additive `author_handle` sibling with this cutover (the agent's ListMessages author-id fence is the identified surface) or defer. - **OQ-6 (NEW, load-bearing)** — member-resolution visibility scoping: (a) viewer-scoped `AccountsByHandles` [rec — the only option consistent with the oracle posture] vs (b) unscoped; T2's resolver signature depends on this ruling. ## Ledger-impact DL-NEW-1 (handle-typed request inputs, server-edge resolution, one indistinguishable in-band NOT_FOUND), DL-NEW-2 (responses/stored/events keep ids; display handles only as additive sibling fields). No existing DL row constrains request addressing (DL-094/186/188/191/202 checked; DL-186 rules the rename-in-place field-numbering). Next-free ids assigned at freeze. Ref: RIG-2751 Co-authored-by: Matt Wilkinson --- .../design.md | 512 ++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 docs/designs/product/compass-handle-addressing-cutover/design.md 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..e29fe39cb --- /dev/null +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -0,0 +1,512 @@ +# 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 +cutover is frozen; this record designs the how) + +## 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 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`). + +Two store lookups back the resolvers, both existing-or-minimal: + +- `AgentByHandle` already exists and is exactly right for agent-typed fields: + non-elevating, agent-asserting, unknown/non-agent handle → + `ErrNotFound` with a message indistinguishable from unknown + (`go/internal/store/accounts.go:631-668`). +- A new general `AccountsByHandles(ctx, handles []string) (map[string]AccountID, error)` + batch lookup (one `WHERE handle = ANY($1)` query) for member/owner fields, + which legitimately name users as well as agents. 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-NEW-1): 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-NEW-1'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. Whether member resolution is additionally +visibility-scoped (invisible ≡ unknown) is OQ-6, the one new load-bearing +fork; this record recommends scoped. + +### 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 an Open +Question (below), not its mechanics. + +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 global — no +viewer scoping, `accounts.go:638-668`) 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: 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`) — a concrete surface carried +in OQ-5, fixable by DL-NEW-2's own additive-sibling mechanism without +reopening this non-goal. Unchanged, deliberately: +`Channel.{member,subscriber}_account_ids` / `owner_account_id` +(`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 an Open Question to +surface, not a reflex flip (OQ-5). + +### 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` 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) | + +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 lane is OQ-4. + +## 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:199`, 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 + +### T1 — proto flip + regen (lands first; #628/#630 rebase onto it) + +Rename and re-comment every row of the inventory in `comms.proto` and +`agent_gateway.proto` to its handle form (working names, pending OQ-1: +`member_handles`, `add_member_handles`, `remove_member_handles`, +`subscribe_handles`, `unsubscribe_handles`, `agent_handle`, +`new_parent_handle`, `owner_handle`, `parent_handle`). Semantics comment on +each: "a `@handle`; the server resolves it to an account id; unknown → +NOT_FOUND". Regenerate all four lanes. + +- **Interfaces**: proto fields per the inventory table (same string wire type; + field numbers kept in place under the OQ-1b working assumption). + `GetRosterRequest.agent_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` — its signature +depends on OQ-6 (member-resolution visibility scoping). Recommended form: +`Store.AccountsByHandles(ctx context.Context, viewer AccountID, handles []string) (map[string]AccountID, error)`, +one `WHERE a.handle = ANY($2)` query over `accounts` intersected with +`accountVisibleFromWhere` (`accounts.go:670-683`) so an invisible handle +misses exactly like an unknown one; if Matt rules unscoped (OQ-6 option b) the +viewer param drops. No subtype assertion (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 batch query returns the full hit map, so +the set-difference is free and the error names ALL unresolved handles, not +just the first, with the same message template as `AgentByHandle` +(`accounts.go:655,665`). Agent-typed singular fields reuse the existing +`AgentByHandle` (`accounts.go:638`) unchanged. + +- **Interfaces**: `AccountsByHandles(ctx context.Context, viewer AccountID, + handles []string) (map[string]AccountID, error)` — the `viewer` param is + RULING-DEPENDENT on OQ-6; atomic — any missing handle fails the whole call, + the error naming every unresolved handle (per the OQ-2 working assumption). +- **Test cycle**: pgtests — round-trip, 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. + +- **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. + +## Tasks + +- [ ] T1 — proto flip (inventory rows 1–12) + 4-lane regen; lint/drift/fence + green +- [ ] T2 — `Store.AccountsByHandles` batch resolver + 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 + +## 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:199`, 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 two rows on merge: + +- **DL-NEW-1**: 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-NEW-2**: 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. + +## Open Questions + +1. **Field naming (OQ-1)** — working assumption: `*_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`. Pure + taste. Matt picks the final names. +2. **Field numbering (OQ-1b) — a confirm, not an open fork** — DL-186 + (`docs/designs/DECISIONS.md:199`, Active) already rules rename IN PLACE + keeping field numbers: the renumber+reserve alternative would ADD + `reserved` markers, contradicting an Active DL (same string wire type; + pre-GA, single-repo, the breaking gate is off — `proto/moon.yml:169`). + Caveat checked: DL-187's later `reserved 3` on `SpawnPeerRequest` + (`agent_gateway.proto:170-171`) guards the semantic revival of + `initial_prompt`, not wire compat — it does not reopen this. + 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). Matt confirms rename-in-place, or + explicitly overrides DL-186. +3. **Repeated-field NOT_FOUND semantics (OQ-2) — a confirm** — working + assumption ATOMIC: one bad handle in `add_member_handles` fails the whole + request (matches the store's all-or-nothing transaction posture — + `UpdateChannelMembers` runs one tx, `channels.go:407-410`), with the error + naming ALL unresolved handles, not just the first — the batch + `WHERE handle = ANY($1)` returns the full hit map, so the complete missing + set is a free set-difference (a 50-member CreateChannel with 3 typos fails + once naming all 3, not across 3 round trips). Alternative: partial + success plus a per-handle error list — needs a response-shape change and + weakens the idempotent-retry story; dismissed. Matt confirms atomic. +4. **GetRoster vantage field name (OQ-3)** — the dual-path mechanics are + settled (empty ⇒ session-resolved caller; non-empty ⇒ UI-named handle), but + the name `agent_handle` on a field an agent must always leave empty invites + misuse. Alternative: `vantage_handle` — the better fit given the vantage + posture §GetRoster now defines. Matt picks the name. +5. **compass.proto admin lane (OQ-4)** — `SpawnAgentRequest.agent_account_id` + (`compass.proto:650`), `ProvisionAgentWorkspaceRequest.agent_account_id` + (`compass.proto:567`), and `IssueTokenRequest.account_id` + (`compass.proto:702`) are also request-input account fields, but on the + admin/ops lane (adminOnly door; DL-253 dropped the spawn UI). Does the + contract-wide ruling extend to them in this cutover, or do admin/ops + callers (which receive ids from prior admin responses) keep ids? +6. **Response handles (OQ-5)** — no response field flips (the non-goal + above), and the UI lane needs nothing today (it joins via its account + directory, `apps/ui/src/comms.ts:139-151`). But one concrete surface IS + identified: 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 — the same cannot-resolve argument that motivated this cutover, + pointed at a response. The fix is DL-NEW-2's OWN additive-sibling + mechanism (an additive `author_handle` on `Message`, or a + `from_handle`-style denorm on the agent list result), never a retype of + the id field. Matt rules: ship the sibling with this cutover, or defer? +7. **Member-resolution visibility scoping (OQ-6, NEW, load-bearing)** — T2's + batch resolver must pick a side the record previously assumed both of: + its parity sentence promises invisible ≡ unknown, but a viewer-less + `AccountsByHandles(ctx, handles)` cannot distinguish visible from + invisible — every real handle resolves, making member-add a global + handle-existence oracle AND letting a caller ATTACH an account outside its + D9-visible set to its channel by guessing the handle (today's by-id path + is FK-only, `channels.go:151-161,529-573`, with no visibility gate either + — but ids are unguessable, so it never mattered). The options: + (a) visibility-scoped — `AccountsByHandles(ctx, viewer AccountID, handles + []string)` intersecting `accountVisibleFromWhere` + (`go/internal/store/accounts.go:670-683`); invisible ≡ unknown holds, and + naming-a-member GAINS a visibility gate the id path never had (a semantic + TIGHTENING: who may be named as a channel member). (b) unscoped — every + real handle resolves, preserving today's FK-only permissiveness at the + cost of an enumeration oracle under guessable handles. A real user-facing + policy choice: can I add a teammate's agent I can't see to my channel? + **Recommended: (a)** — the only option consistent with the ruling's + oracle posture and DL-NEW-1. The T2 interface signature depends on this + ruling. From 94edc2f93e0580f78b9d0742292c9cce1a5661bb Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 26 Aug 2026 22:08:22 -0400 Subject: [PATCH 2/8] docs(product): fold ratified storage contract + owner-qualified wire into RIG-2751 cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matt ratified the RIG-2751 handles/id contract (2026-08-26, "LGTM, can start"). Fold the frozen contract into the cutover record, which previously designed only the wire flip against a flat global handle lookup: - New §"The storage contract": account_handles table + two partial-unique indexes (user/system handles globally unique; agent handles unique per owner), owner-qualified resolution (matt/compass-ux, bare = caller's own owner), in-place rename + reclaim allowed both tiers (no history/reservation), cross-human safety relocated to owner peering (RIG-2796). - Re-spec the edge resolvers for owner-qualified parsing and re-key AgentByHandle / AccountsByHandles onto account_handles. - New T0 migration task (0002_account_handles.sql + backfill + relax accounts.handle global-unique); T2 resolver signature carries callerOwner + parsed QualifiedHandle; T4 despawn handle owner-qualified. - Ledger rows assigned concrete numbers (DL-269 request handle-typed / DL-270 responses keep ids / DL-271 owner-namespaced storage; next-free after the DL-268 dedup this stack sits on). - OQ section: ratification preamble (which forks closed), OQ-7 owner-qualifier grammar added, OQ-5/OQ-6 DL refs + stale signature corrected. Ref: RIG-2751 Co-authored-by: Matt Wilkinson --- .../design.md | 288 ++++++++++++++---- 1 file changed, 227 insertions(+), 61 deletions(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index e29fe39cb..ed079065d 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -2,8 +2,10 @@ Status: Draft -Tracking: RIG-2751 (Matt ruled Option A, contract-wide, 2026-08-25 — the -cutover is frozen; this record designs the how) +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" — the whole +contract is frozen; this record designs the how and freezes on merge) ## Problem / Intent @@ -20,6 +22,67 @@ 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 @@ -47,17 +110,26 @@ 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`). -Two store lookups back the resolvers, both existing-or-minimal: - -- `AgentByHandle` already exists and is exactly right for agent-typed fields: - non-elevating, agent-asserting, unknown/non-agent handle → - `ErrNotFound` with a message indistinguishable from unknown - (`go/internal/store/accounts.go:631-668`). -- A new general `AccountsByHandles(ctx, handles []string) (map[string]AccountID, error)` - batch lookup (one `WHERE handle = ANY($1)` query) for member/owner fields, - which legitimately name users as well as agents. No general public - by-handle lookup exists today — `adminByHandle`/`systemByHandle` are - private and subtype-asserting (`accounts.go:107-111,178-183`). +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 @@ -74,7 +146,7 @@ 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-NEW-1): for every handle-addressed target, ANY post-resolution +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: @@ -88,7 +160,7 @@ handle. Per-handler remap table: 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-NEW-1's oracle invariant, not an +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 @@ -141,7 +213,7 @@ 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`) — a concrete surface carried -in OQ-5, fixable by DL-NEW-2's own additive-sibling mechanism without +in OQ-5, fixable by DL-270's own additive-sibling mechanism without reopening this non-goal. Unchanged, deliberately: `Channel.{member,subscriber}_account_ids` / `owner_account_id` (`comms.proto:233,238,243`), `AgentWorkspace.agent_account_id` @@ -257,6 +329,37 @@ client-supplied). The compass.proto admin lane is OQ-4. ## Plan +### T0 — schema: `account_handles` migration + backfill + +A new migration `go/internal/store/migrations/0002_account_handles.sql` (the +first after `0001_init.sql`; the migrations dir holds exactly that one file +today) creates `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`). It backfills one row per existing +account — `owner_user_id` = the agent's `owner_user_id` for agents +(`agent_accounts.owner_user_id`, `0001_init.sql:76`), NULL for users/system — +then drops the now-redundant global-unique constraint on `accounts.handle` +(`0001_init.sql:38`). The `accounts.handle` COLUMN stays (display reads and the +`0001_init.sql` seed still populate it; it is no longer the resolution key, and +dropping the column is a separate cleanup out of scope here); the resolution +source of truth becomes `account_handles`. + +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 — backfill parity (every pre-migration account has + exactly one handle row with the right `owner_user_id`), 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` and @@ -278,29 +381,44 @@ NOT_FOUND". Regenerate all four lanes. ### T2 — store batch handle resolver -Add a batch resolver in `go/internal/store/accounts.go` — its signature -depends on OQ-6 (member-resolution visibility scoping). Recommended form: -`Store.AccountsByHandles(ctx context.Context, viewer AccountID, handles []string) (map[string]AccountID, error)`, -one `WHERE a.handle = ANY($2)` query over `accounts` intersected with -`accountVisibleFromWhere` (`accounts.go:670-683`) so an invisible handle -misses exactly like an unknown one; if Matt rules unscoped (OQ-6 option b) the -viewer param drops. No subtype assertion (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 batch query returns the full hit map, so -the set-difference is free and the error names ALL unresolved handles, not -just the first, with the same message template as `AgentByHandle` -(`accounts.go:655,665`). Agent-typed singular fields reuse the existing -`AgentByHandle` (`accounts.go:638`) unchanged. +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 also depends on OQ-6 (member-resolution +visibility scoping); recommended form +`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:670-683`) so an invisible handle misses +exactly like an unknown one; if Matt rules unscoped (OQ-6 option b) the `viewer` +param drops. 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, - handles []string) (map[string]AccountID, error)` — the `viewer` param is - RULING-DEPENDENT on OQ-6; atomic — any missing handle fails the whole call, - the error naming every unresolved handle (per the OQ-2 working assumption). -- **Test cycle**: pgtests — round-trip, 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. + callerOwner AccountID, handles []QualifiedHandle) (map[string]AccountID, error)` + over `account_handles` — the `viewer` param is RULING-DEPENDENT on OQ-6; + `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 working assumption). +- **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 @@ -349,6 +467,13 @@ 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. @@ -405,9 +530,13 @@ UI suite against the regenerated client. ## Tasks +- [ ] T0 — `0002_account_handles.sql` migration (table + two partial-unique + indexes) + backfill + relax `accounts.handle` global-unique; store handle + read/write re-pointed at `account_handles` + pgtests - [ ] T1 — proto flip (inventory rows 1–12) + 4-lane regen; lint/drift/fence green -- [ ] T2 — `Store.AccountsByHandles` batch resolver + pgtests +- [ ] 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 @@ -426,21 +555,42 @@ the mechanics: **DL-186** (`DECISIONS.md:199`, 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 two rows on merge: +Active row. The cutover ADDS three rows on merge (next-free at authoring; +confirm at freeze): -- **DL-NEW-1**: every request-input account field on the compass proto +- **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-NEW-2**: response, stored, and event account fields stay id-typed +- **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. ## Open Questions +Matt ratified the storage/format contract on 2026-08-26 ("LGTM, can start"), +which CLOSED the forks that were open during negotiation: 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). Those are now the frozen contract above, not open questions. The +questions below are the remaining wire-cutover forks the ratification did NOT +decide — mostly proto-naming taste plus two real scope forks (OQ-4 admin lane, +OQ-6 member visibility) — plus one new fork the owner qualifier introduces +(OQ-7, the qualifier grammar). + 1. **Field naming (OQ-1)** — working assumption: `*_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`. Pure @@ -486,27 +636,43 @@ Active row. The cutover ADDS two rows on merge: model-visible fence (`author="${attr(m.authorAccountId, fence)}"`, `packages/compass-agent/src/comms.ts:694`) and the agent has no id→handle resolver — the same cannot-resolve argument that motivated this cutover, - pointed at a response. The fix is DL-NEW-2's OWN additive-sibling + pointed at a response. The fix is DL-270's OWN additive-sibling mechanism (an additive `author_handle` on `Message`, or a `from_handle`-style denorm on the agent list result), never a retype of the id field. Matt rules: ship the sibling with this cutover, or defer? -7. **Member-resolution visibility scoping (OQ-6, NEW, load-bearing)** — T2's +7. **Member-resolution visibility scoping (OQ-6, load-bearing)** — T2's batch resolver must pick a side the record previously assumed both of: - its parity sentence promises invisible ≡ unknown, but a viewer-less - `AccountsByHandles(ctx, handles)` cannot distinguish visible from - invisible — every real handle resolves, making member-add a global - handle-existence oracle AND letting a caller ATTACH an account outside its - D9-visible set to its channel by guessing the handle (today's by-id path - is FK-only, `channels.go:151-161,529-573`, with no visibility gate either - — but ids are unguessable, so it never mattered). The options: - (a) visibility-scoped — `AccountsByHandles(ctx, viewer AccountID, handles - []string)` intersecting `accountVisibleFromWhere` - (`go/internal/store/accounts.go:670-683`); invisible ≡ unknown holds, and - naming-a-member GAINS a visibility gate the id path never had (a semantic - TIGHTENING: who may be named as a channel member). (b) unscoped — every - real handle resolves, preserving today's FK-only permissiveness at the - cost of an enumeration oracle under guessable handles. A real user-facing - policy choice: can I add a teammate's agent I can't see to my channel? - **Recommended: (a)** — the only option consistent with the ruling's - oracle posture and DL-NEW-1. The T2 interface signature depends on this - ruling. + its parity sentence promises invisible ≡ unknown, but an unscoped resolver + cannot distinguish visible from invisible — every real handle resolves, + making member-add a global handle-existence oracle AND letting a caller + ATTACH an account outside its D9-visible set to its channel by guessing the + handle (today's by-id path is FK-only, `channels.go:151-161,529-573`, with + no visibility gate either — but ids are unguessable, so it never mattered). + The owner qualifier does NOT close this — it disambiguates namespaces, not + visibility (`alice/compass-ux` is well-formed whether or not the caller can + see it). The options: (a) visibility-scoped — the T2 resolver intersects + `accountVisibleFromWhere` (`go/internal/store/accounts.go:670-683`) after + the namespace split; invisible ≡ unknown holds, and naming-a-member GAINS a + visibility gate the id path never had (a semantic TIGHTENING: who may be + named as a channel member). (b) unscoped — every real handle resolves, + preserving today's FK-only permissiveness at the cost of an enumeration + oracle under guessable handles. A real user-facing policy choice: can I add + a teammate's agent I can't see to my channel? **Recommended: (a)** — the + only option consistent with the ruling's oracle posture and DL-269. The T2 + interface signature depends on this ruling. +8. **Owner-qualifier grammar (OQ-7, NEW — introduced by the storage + contract)** — the owner-qualified form `matt/compass-ux` needs a defined + grammar before T1 comments the fields. Working assumption: `/` is the + separator, the owner segment is a bare user/system handle (never itself + qualified — no nesting), a leading `/` is illegal, exactly one `/` is + permitted, and both segments obey the existing handle charset (the same + grammar constraint the ownership layer already names for `owner=`, + `docs/designs/product/compass-server-ownership-layer/design.md:559-563`). A + bare handle (no `/`) is a user/system handle OR the caller's own agent — + disambiguated by which index resolves it, users/system first. Open sub-fork: + if a bare handle matches BOTH a user handle and one of the caller's own + agents, which wins? Working assumption: user/system global index takes + precedence (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`). Matt confirms the separator + the collision precedence, + or picks another grammar. From 6133592300d78647b6f5a3fbc955a6dd1ac4523d Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 26 Aug 2026 22:37:41 -0400 Subject: [PATCH 3/8] =?UTF-8?q?docs(product):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20DL-186=20line=20cite=20+=20stale=20global=20claim=20+=20OQ-5?= =?UTF-8?q?=20scoping=20(RIG-2751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (ReviewPR645disposition): 0 high / 2 medium / 1 low. All fixed: - medium: DL-186 citation was DECISIONS.md:199 (that's DL-129); DL-186 is at :203. Fixed all 3 refs (Global Constraints, Ledger-impact, OQ-1b). - medium: \u00a7GetRoster called AgentByHandle 'global' in a post-flip frame, contradicting the record's own re-key to owner-namespaced. Reworded to 'owner-namespaced but NOT viewer-scoped', keeping the load-bearing no-viewer- scoping point that motivates the post-resolve ListAccounts visibility check. - low: OQ-5 conflated two response-handle mechanisms under DL-270; scoped them (author_handle sibling = DL-270's path; from_handle denorm = control-plane, outside DL-270's field wording). OQ-5 stays open for Matt. Co-authored-by: Matt Wilkinson --- .../design.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index ed079065d..d0911e9cc 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -192,8 +192,10 @@ 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 global — no -viewer scoping, `accounts.go:638-668`) and return a clipped, likely empty, +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 @@ -304,7 +306,7 @@ client-supplied). The compass.proto admin lane is OQ-4. - **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:199`, Active) rules rename-in-place keeping + (`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 @@ -551,7 +553,7 @@ No existing DL row mandates id-typed request addressing 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:199`, Active) strips pre-dogfood +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 @@ -596,7 +598,7 @@ OQ-6 member visibility) — plus one new fork the owner qualifier introduces `owner_account_id`→`owner_handle`, `parent_agent_id`→`parent_handle`. Pure taste. Matt picks the final names. 2. **Field numbering (OQ-1b) — a confirm, not an open fork** — DL-186 - (`docs/designs/DECISIONS.md:199`, Active) already rules rename IN PLACE + (`docs/designs/DECISIONS.md:203`, Active) already rules rename IN PLACE keeping field numbers: the renumber+reserve alternative would ADD `reserved` markers, contradicting an Active DL (same string wire type; pre-GA, single-repo, the breaking gate is off — `proto/moon.yml:169`). @@ -636,10 +638,11 @@ OQ-6 member visibility) — plus one new fork the owner qualifier introduces model-visible fence (`author="${attr(m.authorAccountId, fence)}"`, `packages/compass-agent/src/comms.ts:694`) and the agent has no id→handle resolver — the same cannot-resolve argument that motivated this cutover, - pointed at a response. The fix is DL-270's OWN additive-sibling - mechanism (an additive `author_handle` on `Message`, or a - `from_handle`-style denorm on the agent list result), never a retype of - the id field. Matt rules: ship the sibling with this cutover, or defer? + pointed at a response. Two mechanisms, never a retype of the id field: an + additive `author_handle` sibling on `Message` (squarely DL-270's + response-field path), or a `from_handle`-style denorm on the agent list + result (a control-plane denorm, outside DL-270's response/stored/event-field + wording). Matt rules: ship the sibling with this cutover, or defer? 7. **Member-resolution visibility scoping (OQ-6, load-bearing)** — T2's batch resolver must pick a side the record previously assumed both of: its parity sentence promises invisible ≡ unknown, but an unscoped resolver From 286cabf0d74e88bbb6cb268240c970bbbe3c9be1 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 27 Aug 2026 13:56:31 -0400 Subject: [PATCH 4/8] =?UTF-8?q?docs(product):=20T0=20schema=20in=200001,?= =?UTF-8?q?=20drop=20backfill=20=E2=80=94=20pre-dogfood=20no=20data=20(RIG?= =?UTF-8?q?-2751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matt's ruling on #645: don't backfill (Compass isn't in use yet), collapse the account_handles schema into the single existing 0001_init.sql migration rather than shipping an incremental 0002. Rewrites T0: account_handles is authored directly into 0001_init.sql with accounts.handle authored without its former global-unique constraint; no incremental migration, no backfill step (no deployed rows exist to migrate). Drops the backfill-parity pgtest assertion from the test cycle and updates the Tasks checklist row. Refs RIG-2751 Co-authored-by: Matt Wilkinson --- .../design.md | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index d0911e9cc..02661ae0e 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -331,22 +331,21 @@ client-supplied). The compass.proto admin lane is OQ-4. ## Plan -### T0 — schema: `account_handles` migration + backfill - -A new migration `go/internal/store/migrations/0002_account_handles.sql` (the -first after `0001_init.sql`; the migrations dir holds exactly that one file -today) creates `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`). It backfills one row per existing -account — `owner_user_id` = the agent's `owner_user_id` for agents -(`agent_accounts.owner_user_id`, `0001_init.sql:76`), NULL for users/system — -then drops the now-redundant global-unique constraint on `accounts.handle` -(`0001_init.sql:38`). The `accounts.handle` COLUMN stays (display reads and the -`0001_init.sql` seed still populate it; it is no longer the resolution key, and -dropping the column is a separate cleanup out of scope here); the resolution -source of truth becomes `account_handles`. +### 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 and the `0001_init.sql` seed still populate it) 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) @@ -355,12 +354,10 @@ 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 — backfill parity (every pre-migration account has - exactly one handle row with the right `owner_user_id`), 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. +- **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) @@ -532,9 +529,9 @@ UI suite against the regenerated client. ## Tasks -- [ ] T0 — `0002_account_handles.sql` migration (table + two partial-unique - indexes) + backfill + relax `accounts.handle` global-unique; store handle - read/write re-pointed at `account_handles` + pgtests +- [ ] 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–12) + 4-lane regen; lint/drift/fence green - [ ] T2 — `Store.AccountsByHandles` owner-qualified batch resolver + From 1f39931efbeb92037ebc9f3c6e4110b1e82d8201 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 27 Aug 2026 14:17:09 -0400 Subject: [PATCH 5/8] =?UTF-8?q?docs(product):=20fix=20T0=20handle-populati?= =?UTF-8?q?on=20attribution=20=E2=80=94=20runtime=20writers,=20not=20a=200?= =?UTF-8?q?001=20seed=20(RIG-2751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (#645, low): the T0 rewrite said accounts.handle is populated by 'the 0001_init.sql seed', but 0001_init.sql has no INSERT/seed statements — the handle column is populated at runtime by store.CreateUser/CreateAgent (accounts.go:29,81). Re-attributes to the true runtime writers so an implementing agent doesn't hunt for a nonexistent seed. Design conclusion (column retained for display) is unchanged. Refs RIG-2751 Co-authored-by: Matt Wilkinson --- .../product/compass-handle-addressing-cutover/design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index 02661ae0e..e821c0aac 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -343,8 +343,9 @@ 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 and the `0001_init.sql` seed still populate it) but is no longer the -resolution key; the resolution source of truth becomes `account_handles`. There +reads still use it and `store.CreateUser`/`CreateAgent` still populate it at +runtime, `accounts.go:29,81`) 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 From 7ea24373af4825d02a1e1de7844bbe30144b3aab Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 27 Aug 2026 14:21:59 -0400 Subject: [PATCH 6/8] docs(product): correct CreateAgent handle-writer line cite (RIG-2751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review (#645, low): the prior fix cited accounts.go:29,81 for CreateUser/CreateAgent, but :81 is BootstrapAdmin's INSERT — CreateAgent's is at :254. Corrected to accounts.go:29,254 (both verified: CreateUser func at :13/INSERT :29, CreateAgent func at :234/INSERT :254). Substance unchanged; accurate line pointer. Refs RIG-2751 Co-authored-by: Matt Wilkinson --- .../designs/product/compass-handle-addressing-cutover/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index e821c0aac..ff77b6b52 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -344,7 +344,7 @@ 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,81`) but is no longer the resolution key; the +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. From 27dc90f7981e068b25e1cd87399454e734b7a427 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 27 Aug 2026 15:36:59 -0400 Subject: [PATCH 7/8] docs(product): fold Matt's OQ-1..7 rulings into handle-cutover record + close OQ section (RIG-2751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matt ruled all remaining wire-cutover open questions (RIG-2751 comment f59001de, 2026-08-27). Fold the rulings into the design record and close the Open Questions section as Rulings, so the record is ready to freeze on merge. - OQ-4 EXTEND: admin/ops lane (SpawnAgent / ProvisionAgentWorkspace / IssueToken on CompassService) flips to handles too — inventory rows 14-16, new task T8. No id-typed request field survives anywhere. - OQ-5 ship-now: additive Message.author_handle sibling; agent fence renders handle-only and drops the id; author_account_id stays on the wire for the UI join — new task T9. - OQ-6 SCOPED: T2 resolver intersects accountVisibleFromWhere; note the Matt-directed D9-widening follow-on as its OWN compass-server change (filed separately), not folded into this freeze. - OQ-1/1b/2/3/7 confirmed at documented defaults (names, rename-in-place, atomic NOT_FOUND, vantage_handle, '/' owner-qualifier grammar). markdownlint 0 errors; design-ledger-gate OK (DL-269/270/271 collision-free). --- .../design.md | 290 ++++++++++-------- 1 file changed, 160 insertions(+), 130 deletions(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index ff77b6b52..415f77071 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -4,8 +4,11 @@ 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" — the whole -contract is frozen; this record designs the how and freezes on merge) +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 @@ -110,6 +113,18 @@ 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"): @@ -170,9 +185,13 @@ 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. Whether member resolution is additionally -visibility-scoped (invisible ≡ unknown) is OQ-6, the one new load-bearing -fork; this record recommends scoped. +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 @@ -185,8 +204,8 @@ 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 an Open -Question (below), not its mechanics. +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, @@ -210,14 +229,17 @@ 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: the agent's ListMessages tool renders raw author ids into the -model-visible fence (`author="${attr(m.authorAccountId, fence)}"`, +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`) — a concrete surface carried -in OQ-5, fixable by DL-270's own additive-sibling mechanism without -reopening this non-goal. Unchanged, deliberately: -`Channel.{member,subscriber}_account_ids` / `owner_account_id` +`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`), @@ -236,8 +258,9 @@ held in draft specifically so their three new `CommsCallRequest` arms 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` request fields flip, all four gen lanes regenerate. +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 @@ -281,6 +304,9 @@ current tree at authoring time. | 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 @@ -295,7 +321,8 @@ server owns the id (`SpawnPeerResponse.agent_account_id`, **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 lane is OQ-4. +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 @@ -362,18 +389,20 @@ read `account_handles`, so T0 lands before or with T2 in the stack. ### T1 — proto flip + regen (lands first; #628/#630 rebase onto it) -Rename and re-comment every row of the inventory in `comms.proto` and -`agent_gateway.proto` to its handle form (working names, pending OQ-1: -`member_handles`, `add_member_handles`, `remove_member_handles`, -`subscribe_handles`, `unsubscribe_handles`, `agent_handle`, -`new_parent_handle`, `owner_handle`, `parent_handle`). Semantics comment on -each: "a `@handle`; the server resolves it to an account id; unknown → -NOT_FOUND". Regenerate all four lanes. +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`, 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 under the OQ-1b working assumption). - `GetRosterRequest.agent_handle`: empty ⇒ caller vantage (unchanged - session-resolved semantics), non-empty ⇒ server-resolved agent handle. + 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 @@ -389,13 +418,13 @@ first `/`, resolves the owner segment in the user/system index 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 also depends on OQ-6 (member-resolution -visibility scoping); recommended form +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:670-683`) so an invisible handle misses -exactly like an unknown one; if Matt rules unscoped (OQ-6 option b) the `viewer` -param drops. No subtype assertion beyond the index split (member/owner fields +`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 @@ -408,7 +437,8 @@ reuse `AgentByHandle`, re-keyed to `(owner_user_id, handle)` over - **Interfaces**: `AccountsByHandles(ctx context.Context, viewer AccountID, callerOwner AccountID, handles []QualifiedHandle) (map[string]AccountID, error)` - over `account_handles` — the `viewer` param is RULING-DEPENDENT on OQ-6; + 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 @@ -528,13 +558,54 @@ UI suite against the regenerated client. - **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,166` +for Provision, `: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:322`, +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–12) + 4-lane regen; lint/drift/fence - green +- [ ] 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 @@ -543,6 +614,10 @@ UI suite against the regenerated client. - [ ] 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 @@ -578,102 +653,57 @@ confirm at freeze): edge (RIG-2796), not handle reservation. The handle column stays on `accounts` for display only; `account_handles` is the resolution key. -## Open Questions +## Rulings (Open Questions — all closed) Matt ratified the storage/format contract on 2026-08-26 ("LGTM, can start"), -which CLOSED the forks that were open during negotiation: 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). Those are now the frozen contract above, not open questions. The -questions below are the remaining wire-cutover forks the ratification did NOT -decide — mostly proto-naming taste plus two real scope forks (OQ-4 admin lane, -OQ-6 member visibility) — plus one new fork the owner qualifier introduces -(OQ-7, the qualifier grammar). - -1. **Field naming (OQ-1)** — working assumption: `*_account_ids`→`*_handles`, +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`. Pure - taste. Matt picks the final names. -2. **Field numbering (OQ-1b) — a confirm, not an open fork** — DL-186 - (`docs/designs/DECISIONS.md:203`, Active) already rules rename IN PLACE - keeping field numbers: the renumber+reserve alternative would ADD - `reserved` markers, contradicting an Active DL (same string wire type; - pre-GA, single-repo, the breaking gate is off — `proto/moon.yml:169`). - Caveat checked: DL-187's later `reserved 3` on `SpawnPeerRequest` - (`agent_gateway.proto:170-171`) guards the semantic revival of - `initial_prompt`, not wire compat — it does not reopen this. - 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). Matt confirms rename-in-place, or - explicitly overrides DL-186. -3. **Repeated-field NOT_FOUND semantics (OQ-2) — a confirm** — working - assumption ATOMIC: one bad handle in `add_member_handles` fails the whole - request (matches the store's all-or-nothing transaction posture — - `UpdateChannelMembers` runs one tx, `channels.go:407-410`), with the error - naming ALL unresolved handles, not just the first — the batch - `WHERE handle = ANY($1)` returns the full hit map, so the complete missing - set is a free set-difference (a 50-member CreateChannel with 3 typos fails - once naming all 3, not across 3 round trips). Alternative: partial - success plus a per-handle error list — needs a response-shape change and - weakens the idempotent-retry story; dismissed. Matt confirms atomic. -4. **GetRoster vantage field name (OQ-3)** — the dual-path mechanics are - settled (empty ⇒ session-resolved caller; non-empty ⇒ UI-named handle), but - the name `agent_handle` on a field an agent must always leave empty invites - misuse. Alternative: `vantage_handle` — the better fit given the vantage - posture §GetRoster now defines. Matt picks the name. -5. **compass.proto admin lane (OQ-4)** — `SpawnAgentRequest.agent_account_id` - (`compass.proto:650`), `ProvisionAgentWorkspaceRequest.agent_account_id` - (`compass.proto:567`), and `IssueTokenRequest.account_id` - (`compass.proto:702`) are also request-input account fields, but on the - admin/ops lane (adminOnly door; DL-253 dropped the spawn UI). Does the - contract-wide ruling extend to them in this cutover, or do admin/ops - callers (which receive ids from prior admin responses) keep ids? -6. **Response handles (OQ-5)** — no response field flips (the non-goal - above), and the UI lane needs nothing today (it joins via its account - directory, `apps/ui/src/comms.ts:139-151`). But one concrete surface IS - identified: 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 — the same cannot-resolve argument that motivated this cutover, - pointed at a response. Two mechanisms, never a retype of the id field: an - additive `author_handle` sibling on `Message` (squarely DL-270's - response-field path), or a `from_handle`-style denorm on the agent list - result (a control-plane denorm, outside DL-270's response/stored/event-field - wording). Matt rules: ship the sibling with this cutover, or defer? -7. **Member-resolution visibility scoping (OQ-6, load-bearing)** — T2's - batch resolver must pick a side the record previously assumed both of: - its parity sentence promises invisible ≡ unknown, but an unscoped resolver - cannot distinguish visible from invisible — every real handle resolves, - making member-add a global handle-existence oracle AND letting a caller - ATTACH an account outside its D9-visible set to its channel by guessing the - handle (today's by-id path is FK-only, `channels.go:151-161,529-573`, with - no visibility gate either — but ids are unguessable, so it never mattered). - The owner qualifier does NOT close this — it disambiguates namespaces, not - visibility (`alice/compass-ux` is well-formed whether or not the caller can - see it). The options: (a) visibility-scoped — the T2 resolver intersects - `accountVisibleFromWhere` (`go/internal/store/accounts.go:670-683`) after - the namespace split; invisible ≡ unknown holds, and naming-a-member GAINS a - visibility gate the id path never had (a semantic TIGHTENING: who may be - named as a channel member). (b) unscoped — every real handle resolves, - preserving today's FK-only permissiveness at the cost of an enumeration - oracle under guessable handles. A real user-facing policy choice: can I add - a teammate's agent I can't see to my channel? **Recommended: (a)** — the - only option consistent with the ruling's oracle posture and DL-269. The T2 - interface signature depends on this ruling. -8. **Owner-qualifier grammar (OQ-7, NEW — introduced by the storage - contract)** — the owner-qualified form `matt/compass-ux` needs a defined - grammar before T1 comments the fields. Working assumption: `/` is the - separator, the owner segment is a bare user/system handle (never itself - qualified — no nesting), a leading `/` is illegal, exactly one `/` is - permitted, and both segments obey the existing handle charset (the same - grammar constraint the ownership layer already names for `owner=`, - `docs/designs/product/compass-server-ownership-layer/design.md:559-563`). A - bare handle (no `/`) is a user/system handle OR the caller's own agent — - disambiguated by which index resolves it, users/system first. Open sub-fork: - if a bare handle matches BOTH a user handle and one of the caller's own - agents, which wins? Working assumption: user/system global index takes - precedence (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`). Matt confirms the separator + the collision precedence, - or picks another grammar. + `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`). From 101d4dd4be1c026df14cf3fda7e9cdc47d11b867 Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 27 Aug 2026 15:54:45 -0400 Subject: [PATCH 8/8] =?UTF-8?q?docs(product):=20tighten=20OQ-fold=20review?= =?UTF-8?q?=20nits=20=E2=80=94=20stale=20OQ=20labels=20+=20grounding=20cit?= =?UTF-8?q?es=20(RIG-2751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-floor review fixes on the OQ-fold record (all doc-only, no design change): - Drop stale (OQ-5) tag from the non-goal forward-looking sentence (OQ-5 is ruled/closed; §6 ships additive author_handle). - Rewrite T2's 'per the OQ-2 working assumption' → 'per the OQ-2 ruling'. - Enumerate account_handle (IssueToken row 16) in T1's final-names list. - T8: split service.go:152 (GetAccount lookup) from :166 (hub.Provision relay) so the cite labels each construct correctly. - T9: tighten Message.author_account_id cite comms.proto:322 -> :332 (the field, not the message-block doc-comment). --- .../compass-handle-addressing-cutover/design.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/designs/product/compass-handle-addressing-cutover/design.md b/docs/designs/product/compass-handle-addressing-cutover/design.md index 415f77071..c575efb7d 100644 --- a/docs/designs/product/compass-handle-addressing-cutover/design.md +++ b/docs/designs/product/compass-handle-addressing-cutover/design.md @@ -247,8 +247,8 @@ deliberately: `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 an Open Question to -surface, not a reflex flip (OQ-5). +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 @@ -394,7 +394,7 @@ Rename and re-comment every row of the inventory in `comms.proto`, 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`, and the roster vantage field `vantage_handle` (OQ-3). Semantics +`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. @@ -442,7 +442,7 @@ reuse `AgentByHandle`, re-keyed to `(owner_user_id, handle)` over `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 working assumption). + 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 @@ -568,8 +568,9 @@ CompassService handler edge, mirroring T3's comms edge. `SpawnAgent` `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,166` -for Provision, `:420,425` for IssueToken); an unresolvable handle → the same +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. @@ -582,7 +583,7 @@ in-band NOT_FOUND the existing `GetAccount` miss already returns ### T9 — additive `author_handle` on `Message` + agent handle-only fence -Add an additive `author_handle` field to `Message` (`proto/compass/v1/comms.proto:322`, +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`).