diff --git a/docs/designs/DECISIONS.md b/docs/designs/DECISIONS.md index 2910ed78..dc8b0343 100644 --- a/docs/designs/DECISIONS.md +++ b/docs/designs/DECISIONS.md @@ -223,6 +223,8 @@ check enforces the mechanical half. Full rationale: | DL-324 | The forge live-test Linear credential is app-actor ONLY — DL-308's retain-one-Linear-user-credential carve-out is dropped (Matt's RIG-3096 pivot): the `livegithub` oracle's Linear legs gate on a single env var, `LINEAR_FORGE` (`go/internal/forge/livegithub_test.go:65`, consumed by `requireLinear` :91-99), minted as an app-actor token per CI run by `tools/forge-linear-token/index.ts` via the client_credentials grant from the `LINEAR_FORGE_CLIENT_ID`/`_SECRET` Actions secrets (`.github/workflows/ci.yml:1002-1004`) — with NO `LINEAR_FORGE_USER_TOKEN` (zero code hits) and no human→app delegation-setup step in CI. Supersedes DL-308's Linear-user-cred clause ONLY — DL-308's GitHub App-installation-token-mint clause stays live, so DL-308 itself remains Active | Active (Matt, 2026-09-02) | [forge app credentials](server/compass-forge-app-credentials/design.md) | | DL-342 | The forge state-transition write op is a parallel-arm pair (`TransitionIssueState` / `TransitionPullRequestState` on `forge.Provider`, `transition_issue_state` / `transition_pull_request_state` on `ForgeCallRequest`) over a portable `{open, closed}` core plus per-provider refinements carried in one `TransitionState` input (`close_reason` for GitHub issues, `workflow_state` for Linear). Every mismatch fails LOUD and in-band as `invalid_argument` before any provider call: a refinement the target provider cannot express, an unknown or ambiguous Linear state name, a named state whose type contradicts the portable target, and — per the OQ-2 ruling — a default resolution finding MORE THAN ONE candidate state, which names the candidates and demands an explicit `workflow_state` rather than taking a positional guess. Defaulting happens only where the target is unambiguous (default-close resolves against `completed`, never `canceled`). The Linear PR half returns `ErrUnsupported` (Linear is issues-only). Transitions are NOT F3-deduped and NOT DL-055-recorded — not the comment arm's reason (a comment has no representable coordinate) but because a transition targets a coordinate whose row is a write-once authorship fact whose `client_request_id` backs the create's F3 memo, so routing a transition through the record path would overwrite it. Amends DL-241's tool count by citation (twelve tools, rule unchanged). | Active (Matt, 2026-09-07) | [forge state transition](server/compass-forge-state-transition/design.md) | | DL-343 | Forge transition actor attribution rides a CONSUMABLE `forge_state_transitions` memo, never a parsed-text or author-row proxy: written after a successful transition at the same attribution chokepoint every forge write rides, then consumed on match at the notify lane so the provider-echoed STATE event resolves to the acting agent. Tenant-scoped like its `forge_authored_artifacts` sibling, and fail-open on a miss (a late webhook loses attribution; it never blocks or misattributes). This is the contract surface the RIG-3326 self-origin suppression record's STATE arm keys on, and it settles that cross-record question in the memo's favour: RIG-3326's frozen text describes the actor as stamped onto the emitted event, so its STATE arm bends to a memo lookup at the actor-resolution seam. The rejected alternative was a synthetic STATE event emitted at the chokepoint — equivalent for the suppression outcome, but it invents an event the forge never sent. Landing the seam is what closes RIG-3326's interim-open STATE arm. | Active (Matt, 2026-09-07) | [forge state transition](server/compass-forge-state-transition/design.md) | +| DL-344 | Channels fold into the agent tree as an additive `Channel.parent_agent_id` attach edge (field 11) plus a `ReparentChannel` RPC, and membership becomes a SECOND AXIS from kind/placement: a new `ChannelMembershipMode` (field 12, column `channels.membership_mode`) with `EXPLICIT = 0` keeping today's stored `channel_members` behaviour and `TREE = 1` deriving the member set from the anchoring agent's subtree PLUS that agent's `owner_user_id` (the owner term is load-bearing: it is the second disjunct of the participant probe and the reason a human owner can post at all), with NO managed member list (Matt's RIG-1622 ruling: tree-only membership is the right default for tree-hung channels, but individually-managed members must stay possible). Rejected: implying the mode from `parent_agent_id IS NOT NULL` (forecloses a hand-membered channel that still lives in the tree) and a third `ChannelKind` value (kind is the DM-vs-channel axis; the retired `CHANNEL_KIND_GROUP_DM = 2` is the cautionary tale). Consequence: a new `ChannelParticipant` probe with a recursive actor-to-root ancestor arm is added BESIDE the surviving `ChannelMemberExists` (which two callers still need for stored-member-row semantics) and `requireChannelMember` rebinds onto it, so the `AppendMessage` post gate GAINS a derived arm rather than staying unchanged; the five `FROM channel_members` delivery queries need their DRIVING relation unioned with the derived set, not a new WHERE disjunct; `channel_members.subscribed` has no home on a row-less channel, so TREE subscription moves to an override-only `channel_subscriptions` table. `ChannelGroup` is NOT deleted — it survives as machinery for the reserved `__dm__`/`__coordination__` namespaces and as the home of SHARED spaces, but stops being the sidebar organizer | Active (Matt, 2026-09-07) | [channels in the agent tree §3](ui/compass-channels-in-agent-tree/design.md#3-two-membership-modes-posting-follows-the-tree) | +| DL-345 | An agent-attached channel's read grant is the OWNER set — the anchoring agent's `owner_user_id` and that user's agents — as a deliberate product decision (Matt: "by default, yes, all agents under an owner can read all channels from that owner"), NOT as a reuse of existing OWNER-group semantics: the owner-set `viewer` CTE exists only in the two GROUP queries (`go/internal/store/queries/channels.sql:93`, `:118`), while all three CHANNEL predicates grant non-member access solely through the SHARED-group arm (`:152-153`, `:181-182`, `:211-212`), so the disjunct mints a new visibility class rather than matching a precedent. Attachment bounds only the NON-MEMBER grant — members read regardless of owner, since cross-owner members exist (a converted DM carries both owners). A per-channel ACL is the named successor that will gate specific channels off, slotting in as one more conjunct in the three predicate copies plus the participant probe; this design keeps that insertion cheap and does not attempt the ACL | Active (Matt, 2026-09-07) | [channels in the agent tree §2](ui/compass-channels-in-agent-tree/design.md#2-the-visibility-invariant) | ## Agent roles & prompts diff --git a/docs/designs/ui/compass-channels-in-agent-tree/design.md b/docs/designs/ui/compass-channels-in-agent-tree/design.md new file mode 100644 index 00000000..4b73de3d --- /dev/null +++ b/docs/designs/ui/compass-channels-in-agent-tree/design.md @@ -0,0 +1,1805 @@ +# Compass channels in the agent tree + +Status: Active + +Tracker: RIG-1622 + +This record is filed under `ui` because the change is driven by the +left-sidebar hierarchy and compass-ui owns that surface, but it cross-cuts +the proto contract (`proto/compass/v1/comms.proto`) and the Go comms +service. Tasks below name the owning lane per slice. + +## Problem / Intent + +The left sidebar renders two parallel hierarchies: a Channels section +partitioned by `ChannelGroup` rows and an Agent workspaces section derived +from `AgentAccount.parent_agent_id` +(`apps/ui/src/components/LeftSidebar.tsx:414-416`: "then two collapsible +sections — Channels above Agent workspaces"). The frozen agent-trees record +names this work: "this record makes the agent tree the primitive RIG-1622 +will fold channels into" (`docs/designs/agent/compass-agent-trees/design.md:233-234`) +and fences it off: "channels stay on their own `ChannelGroup` tree until +RIG-1622" (`design.md:297-298`). Fold channels into the agent tree so the +sidebar shows one hierarchy and posting boundaries follow the tree. + +## Global Constraints + +- **Additive wire change only.** No proto field is removed or renumbered, so + no `reserved` statement is needed. The one removed-field precedent is + explicitly pre-dogfood: "the oneof and field 7 parent_message_id are both + REMOVED, not reserved (F9: pre-dogfood, zero stored payloads)" + (`proto/compass/v1/comms.proto:341-344`). This design removes nothing. +- **Visibility predicate copies stay textually identical.** The store repeats + the effective-visibility CTE per read: "The copies MUST stay textually + identical so the stream edge's single-id visibility check cannot drift from + the list read" (`go/internal/store/queries/channels.sql:10-12`). Any + predicate change lands in every copy in one commit. The same anti-drift + rule already binds the membership probe this design widens: + "requireChannelMember / isChannelMember reuse ChannelMemberExists + (channels.sql) — the statement is textually identical" + (`go/internal/store/queries/authz.sql:4-5`). +- **The coordination-hook invariant governs the agent edge only.** The + invariant "every write of parent_agent_id must invoke the registered + coordination hook (RIG-1722 T5)" + (`go/internal/store/migrations/0001_init.sql:86-87`) names + `agent_accounts.parent_agent_id`, whose two writers are `CreateAgent` + (`go/internal/store/accounts.go:363-372`) and `ReparentAgent` + (`accounts.go:614-633`). It holds unchanged: the hook reconciles the + stored membership of coordination channels, which stay + explicitly-membered. `channels.parent_agent_id` is a NEW edge with its + own writers (`CreateChannel` with attach, `ReparentChannel`); the hook + never fires on those writes, and a tree-derived channel needs no + membership write on any tree move (Approach leg 3), so this design + extends no hook. +- **Not-found/forbidden merge on every authz failure.** "a non-member gets + ErrNotFound (the not-found/forbidden merge), never a hint" + (`go/internal/store/messages.go:56`). New authz branches keep the merge. +- **The caller is never a request field.** "the caller is the account + authenticated on the connection … never a field in a request, which would + be spoofable" (`proto/compass/v1/comms.proto:27-29`). +- **Reserved namespaces are untouchable.** The per-owner `__dm__` group + (`go/internal/store/dm.go:19`) and the `__coordination__` group it is + "distinct from" (`dm.go:15`) keep their storage shape; OpenDM and the + coordination reconcile depend on them. + +## Approach + +**A channel gains an optional owning-agent edge and a membership mode; +`ChannelGroup` survives as server machinery only.** The recommended shape +has five legs. + +### 1. What replaces `ChannelGroup` + +Add `Channel.parent_agent_id` (new field 11; field 10 is the last used, +`repeated PinnedEntry pinned_entries = 10;` at `proto/compass/v1/comms.proto:259`). +Empty = a tree-root channel. When set, the channel hangs off that +`AgentAccount` in the one sidebar tree. The field name follows the existing +vocabulary: `AgentAccount.parent_agent_id` already "Mirrors +ChannelGroup.parent_group_id" (`comms.proto:188`). + +`ChannelGroup` is NOT deleted. Two server subsystems depend on group rows as +storage machinery, not as a user-facing hierarchy: + +- OpenDM ensures "the owner's reserved `__dm__` group, then upsert the" + DM channel under it (`go/internal/comms/dm.go:34`; the reserved name + constant is `go/internal/store/dm.go:19`). +- The coordination reconcile keeps a manager's channel provisioned from tree + edges, in a `__coordination__` group `__dm__` is "distinct from" + (`go/internal/store/dm.go:15`). + +What changes is the group's role: it stops being the sidebar organizer and +stops accepting user-facing nesting. `CreateChannelGroup` remains wire-legal +(additive contract, Global Constraints) but the UI drops it as an +organizing surface; a group survives as a flat namespace for reserved +machinery and for SHARED spaces (leg 2). + +An agent's home channel needs no new edge at all: it is already joined to +its agent by `AgentAccount.home_channel_id`, "minted at CreateAgent" +(`comms.proto:182-184`), created "ungrouped (owner-scoped)" +(`go/internal/store/accounts.go:375`). The UI derives its placement under +the agent node from that existing field. + +### 2. The visibility invariant + +This is the load-bearing decision. Today the lattice lives on groups: "This +group's own visibility; the server rejects a value more open than the parent +group's (child ≤ parent). Effective visibility is the most restrictive on +the path to the root" (`comms.proto:214-216`), enforced at write +(`go/internal/store/channels.go:48`: `if int32(g.Visibility) > int32(parentVis)`) +and computed at read by a recursive CTE +(`go/internal/store/queries/channels.sql:130-142`: +`LEAST(a.min_vis, g.visibility)` then `MIN(min_vis) AS eff_vis`). +`AgentAccount` has an owner (`owner_user_id`, `comms.proto:181`) but no +visibility field. + +**Decided (Matt, RIG-1622): by default every agent under an owner reads +every channel of that owner; a later ACL system will gate individual +channels off.** The read grant below is that product decision. It is NOT a +reuse of existing predicate behaviour: today the owner-set `viewer` CTE +("SELECT owner_user_id AS uid FROM agent_accounts WHERE account_id = $1", +`channels.sql:94`) exists only in the two GROUP queries (`channels.sql:93`, +`:118`, applied at `:101` and `:126`); all three CHANNEL predicate copies +grant non-member access solely through the SHARED-group arm (`c.kind = 0 +AND c.group_id IS NOT NULL AND … e.eff_vis = 1`, `channels.sql:152-153`, +`:181-182`, `:211-212`). The disjunct this record adds is therefore a new +visibility class for agent-attached channels, justified by the ruling, not +by precedent. Precisely: + +- **Read grant.** A channel with `parent_agent_id` set is visible to its + members OR to the anchor agent's owner set: the agent's `owner_user_id` + plus every agent with that `owner_user_id`. The three channel predicate + copies gain the `viewer` CTE (the group queries' shape) plus one + disjunct resolving the anchor's owner through `agent_accounts`. All + three copies change in one commit (Global Constraints). +- **Visibility is not readability.** Message reads keep their membership + gate — `ListMessages` joins members ("JOIN channel_members cm ON + cm.channel_id = t.channel_id AND cm.account_id = $1", + `go/internal/store/queries/messages.sql:71`) — exactly as a SHARED + channel is browsable today without message access. A non-member in the + owner set sees the channel row (sidebar, browse/join); reading history + and posting require membership, explicit or derived (leg 3). +- **Attachment bounds the non-member grant, not total readership.** + Members read regardless of owner: cross-owner member sets exist — a + converted DM keeps both owners' accounts, and `CreateChannel` adds "for + each agent in the requested member set that agent's owning user(s)" + (`go/internal/store/channels.go:78-79`). Attachment caps what a + NON-member can see at the owner set; it never subtracts membership. +- **SHARED channels still cannot hang on an agent.** The owner-set grant + is the widest non-member access an agent-attached channel can carry; + shared spaces stay in SHARED groups at the tree root, served by the + existing SHARED arm. +- **ACL forward-compatibility.** The future per-channel ACL slots in as + one more conjunct inside the same three predicate copies and the + participant probe of leg 3 (e.g. `AND NOT EXISTS (SELECT 1 FROM + channel_acl …)`). The identical-copies rule (`channels.sql:10-12`) + keeps the insertion surface to exactly those places, so nothing here + hardens against an ACL landing later. The ACL itself is out of scope. + +### 3. Two membership modes; posting follows the tree + +**Decided (Matt, RIG-1622): a channel is either tree-derived — its member +set computed from the agent subtree it hangs on, with no managed member +list — or explicitly managed, keeping today's stored `channel_members` +behaviour.** Matt's reasoning: tree-only membership is the right default +for tree-hung channels, but some channels need individually managed +members, so both modes must exist. + +**Encoding: a dedicated membership-mode field, not a `ChannelKind` value +and not an implication from placement.** Three candidates were weighed: + +- **(a) Explicit mode enum — chosen.** `ChannelMembershipMode` on + `Channel` (proto field 12; column `channels.membership_mode`), values + `EXPLICIT = 0` (default, today's behaviour) and `TREE = 1`. Membership + management is its own axis, independent of both kind and placement. +- **(b) Mode implied by `parent_agent_id IS NOT NULL` — loses.** It + forecloses an explicitly-managed channel that still lives in the tree, + which is exactly Matt's individually-managed case composed with + placement: a user should be able to hang a hand-membered channel under + an agent for organization. Placement and membership policy must vary + independently. +- **(c) New `ChannelKind` value 3 — loses.** `kind` is the DM-vs-channel + axis: `CHANNEL_KIND_CHANNEL = 0`, `CHANNEL_KIND_DM = 1`, and the + retired `CHANNEL_KIND_GROUP_DM = 2 [deprecated = true]` + (`proto/compass/v1/comms.proto:289-294`) — whose retirement comment is + itself the cautionary tale for widening that enum. A tree channel IS a + `CHANNEL` in every kind-switch in the codebase; a third value would + make every kind check grow an arm that behaves as `CHANNEL`. + +A TREE channel MUST have an anchor: `CHECK (membership_mode = 0 OR +parent_agent_id IS NOT NULL)` (leg 4). An EXPLICIT channel may be +attached or root. + +**The derived member set.** For a TREE channel anchored at agent `A`: +`A`, every agent in `A`'s subtree, and `A`'s `owner_user_id`. Subtree +semantics are what make "posting boundaries follow the tree" literal: an +agent may post to TREE channels anchored at itself or at any of its +ancestors. The set is owner-bounded because a reparent cannot cross an +owner boundary: `validateNewParent` rejects a cross-owner parent +(`go/internal/store/accounts.go:666-673`) and its sole caller is +`ReparentAgent` (`accounts.go:594`). Note the scope precisely — this is +a `ReparentAgent` guard, NOT a store-wide invariant: `CreateAgent`'s +store path writes `parent_agent_id` with no cycle or same-owner check at +all (`accounts.go:333-352` — the `InsertAgentAccount` call relies on the +FK, whose only bespoke handling is the +`agent_accounts_parent_agent_id_fkey` missing-referent arm at `:346-348`), +and the same-owner check on that path lives at the comms EDGE +(`go/internal/comms/comms.go:134-140`). So the proto comment +"The server validates same-owner and no-cycle on every write" +(`comms.proto:188-189`) describes the edge, not the store, and is NOT +cited here as a store invariant. + +**The gate mechanics: a new probe beside the old one.** +`requireChannelMember` / `isChannelMember` wrap the `ChannelMemberExists` +probe (`SELECT EXISTS (SELECT 1 FROM channel_members WHERE channel_id = +$1 AND account_id = $2);`, `go/internal/store/queries/channels.sql:36-37`). +A NEW query `ChannelParticipant` is added — the explicit arm OR a tree +arm that walks the actor's ancestor chain and matches the anchor, or +matches the actor as the anchor's owner — and the two wrappers are +rebound onto it. + +**`ChannelMemberExists` SURVIVES; it is not replaced.** Two callers +bypass the wrappers and use the sqlc query directly, and both genuinely +mean "has a stored member row", not "participates": + +- `hasGenuineAdd` (`go/internal/store/channels.go:550`) drives the R4 + DM-conversion decision — "an update that is not a remove and not an + unsubscribe, naming an account not already a member" — which is a + statement about rows in this tx's snapshot. Giving it the derived arm + would make an add of an already-derived participant a non-add and + silently skip a conversion. +- The OWNER_ONLY coherence check (`channels.go:765`) requires "the owner + MUST be a member of the channel", because "the post gate demands the + author be BOTH a member AND the owner" (`channels.go:757-775`). + +**Decided: `SetChannelPolicy` refuses `OWNER_ONLY` on a TREE channel** +(`ErrInvalidArgument`), rather than converting `channels.go:765` to a +participant check. Reason: the coherence check exists to keep an +OWNER_ONLY channel postable, and on a TREE channel it cannot do that job +honestly — the owner's participation is derived from `parent_agent_id`, +so a later move can silently un-participate the named owner and render +the channel unpostable with no write to the policy at all. Precisely +which move, because the two owner cases differ. When the policy owner is +a subtree AGENT, both moves can do it: a `ReparentAgent` carrying that +agent out of the subtree, or a `ReparentChannel` re-anchoring the +channel elsewhere. When the policy owner is the anchor's +`owner_user_id` — the case the owner term in the derived set exists for +— `ReparentChannel` canNOT un-participate them, because the source +authz rule below requires the destination agent's owner to equal the +caller's resolved owner, so every legal destination is same-owner and +the user stays a participant. The agent-owner case alone settles it: a +check that a subsequent, unrelated move can invalidate is not a +coherence check. The refusal is the same shape T5 already uses for +`mandatory_subscription` on TREE, and it keeps `channels.go:765` +reachable only where member rows genuinely exist. + +**The refusal binds BOTH writers.** Nothing in the argument above is +specific to `SetChannelPolicy`. `CreateChannel` can mint the same +incoherent state directly, and its own coherence check cannot catch it: +that check is `if c.Policy.OwnerAccountID != "" && +!slices.Contains(members, c.Policy.OwnerAccountID)` (`channels.go:170`) +against the `expandOwnerMembership` result (`channels.go:160`), and a +TREE create writes no member rows, so that expansion is not the +channel's member set and the check passes vacuously. So `CreateChannel` +refuses `OWNER_ONLY` together with `membership_mode = TREE` +(`ErrInvalidArgument`, T2), exactly as it already refuses +`mandatory_subscription` on TREE. + +**A TREE create still runs `expandOwnerMembership`, but writes no member +rows from it.** The expansion is not skipped: the attach authz needs the +resolved owner set, and the expansion result is what `CreateChannel` +carries back today as `MemberAccountIDs` (`channels.go:211`). What a TREE +create skips is the `EnsureChannelMember` loop over it +(`channels.go:174-184`). The list it returns is instead the derived +participant set — the same materialization T4 applies on every read +(leg 5) — so a TREE `CreateChannel` and a later `ListChannels` report +the same member list for the same channel. T4's hop (v) makes that +identity structural rather than a coincidence of two code paths +agreeing: `CreateChannel` stops hand-writing the returned `Channel` at +`channels.go:206-213` and returns the same post-commit `getChannel` +read a `ListChannels` row goes through, so "the same member list" is +the same projection, not a reconstruction of it. The expansion at +`channels.go:160` still runs — the authz needs it — it just no longer +feeds the return value. + +**Invariant: `membership_mode` is immutable after create.** This is +load-bearing, not a deferred nicety: it is the sole reason the two TREE +policy refusals above cannot be bypassed by creating an EXPLICIT +channel, setting `OWNER_ONLY` or `mandatory_subscription` on it, and +then converting it to TREE. Today the property holds by absence of a +writer — T1 adds `membership_mode` to `CreateChannelRequest` only, and +`SetChannelPolicy` writes the post policy, the owner and the mandatory +flag and nothing else (`UpdateChannelPolicy`, `channels.go:777-782`) — +and "safe because no writer exists" is exactly the property a later task +deletes without noticing. So it is stated here as an invariant and +guarded: no RPC may write `membership_mode` after the create, and +`ReparentChannel` moves placement only. T2 pins it with a test. Open +Question 3 records the conversion FORK; the invariant itself is not +open. + +Sketch of the new probe (the recursive-CTE precedent is the `ancestry` +CTE, `channels.sql:130-137`): + +```sql +SELECT EXISTS ( + SELECT 1 FROM channel_members + WHERE channel_id = $1 AND account_id = $2 +) OR ( + -- Mode test HOISTED out of the recursion: an EXPLICIT channel never + -- enters the CTE, so the rejection path on the post gate stays a + -- single indexed lookup. + EXISTS (SELECT 1 FROM channels + WHERE id = $1 AND membership_mode = 1) + AND EXISTS ( + WITH RECURSIVE chain AS ( + SELECT account_id, parent_agent_id + FROM agent_accounts WHERE account_id = $2 + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id + ) + SELECT 1 FROM channels c + WHERE c.id = $1 AND ( + c.parent_agent_id IN (SELECT account_id FROM chain) + OR $2 = (SELECT owner_user_id FROM agent_accounts + WHERE account_id = c.parent_agent_id) + ) + ) +); +``` + +**Termination: `UNION`, not `UNION ALL`, because the data is not +trusted.** The walk is actor-to-root and normally bounded by tree depth, +but nothing in the store guarantees acyclic rows — as established above, +the no-cycle guard is a `ReparentAgent` guard only. The Go precedent this +mirrors makes the same assumption explicitly: it carries a visited set +with the comment "The visited set bounds the walk so a pre-existing cycle +in the data cannot spin it forever" (`accounts.go:678-679`), and on +meeting a cycle it `break`s rather than rejecting (`accounts.go:686-689`) +— that is, the code positively contemplates cyclic rows existing. Postgres +does no cycle detection on `UNION ALL`, so `UNION ALL` here would spin +forever inside the post-gate transaction. `UNION`'s distinct semantics +terminate on a repeated row, which is the SQL equivalent of the Go +visited set. (The `ancestry` precedent at `channels.sql:130-137` does not +transfer: it walks `channel_groups`, which the repo treats as immutable +after create — stated there as a load-bearing soundness condition, +"Sound only because groups are immutable post-create: the sole +channel_groups mutation is the CreateChannelGroup INSERT (no +UpdateChannelGroup / re-parent RPC)" +(`go/internal/store/queries/authz.sql:19-22`).) + +**Cost on the rejection path.** `AppendMessage` runs this probe in-tx on +every post (`messages.go:59`), so the mode test is hoisted OUT of the +recursion above: a post by a non-member of an EXPLICIT channel resolves +with one indexed `channels` lookup and never materializes an ancestor +chain. Only a TREE channel pays the recursion, and then only for the +actor's own chain (tree depth). Written the other way — the mode filter +inside the recursive `EXISTS` — every rejected post in the system would +build the actor's full ancestor chain first. + +**Two walks, in opposite directions.** The probe above answers "is THIS +actor a participant of THIS channel?" and walks UP: the walk is +actor-to-root, seeded `WHERE account_id = $2`. Several sites downstream +ask the opposite question — "who are ALL the participants of THIS +channel?" — and are keyed by channel with no actor to seed from +(`SubscribedAgents` and `ChannelAgentMembers` take an account parameter +only to EXCLUDE the author, `delivery_reads.sql:15`, `:23`). That +question needs a second, DOWNWARD walk, seeded at the channel's anchor. +It is a different recursion, not a re-parameterization of the probe's, +so it is specified here in full rather than referred to: + +```sql +-- Anchor-to-subtree DESCENT: the participant set of one TREE channel. +WITH RECURSIVE subtree AS ( + SELECT account_id + FROM agent_accounts + WHERE account_id = (SELECT parent_agent_id FROM channels WHERE id = $1) + UNION + SELECT a.account_id + FROM agent_accounts a + JOIN subtree s ON a.parent_agent_id = s.account_id +) +SELECT account_id FROM subtree +UNION +SELECT aa.owner_user_id +FROM channels c +JOIN agent_accounts aa ON aa.account_id = c.parent_agent_id +WHERE c.id = $1; +``` + +The recursive step reads `agent_accounts` by `parent_agent_id`, which is +the direction 0001 indexes for exactly this: "The 'children of this +parent' read direction for the agent tree", +`CREATE INDEX agent_accounts_parent_idx ON agent_accounts +(parent_agent_id);` (`0001_init.sql:118-119`). `UNION`, not `UNION ALL`, +for the same reason as the ascent: the store does not guarantee acyclic +rows, and a descent through a cycle spins forever under `UNION ALL`. The +trailing `UNION` adds the anchor's `owner_user_id`, the second disjunct +of the probe. So both walks compute the SAME set — `A`, `A`'s subtree, +and `A`'s `owner_user_id` — read from opposite ends, and the two +rewrites below cannot disagree on a row. + +**The descent has two forms, and each site uses the one its key +demands.** The sketch above is the SINGLE-CHANNEL form: it takes one +`channel_id` as `$1` and projects bare `account_id`s, which is all a +caller holding exactly one channel needs. The two channel-keyed delivery +queries T5 rewrites use that form, each being called with a single +`channel_id` (`delivery_reads.sql:13`, `:22`). + +`loadChannelMembers` cannot. It is handed a whole id set and must +attribute every returned account back to the channel it belongs to — its +loop keys each row by `m.ChannelID` through +`idx := byID[ChannelID(m.ChannelID)]` (`channels.go:895-902`) — and its +contract forbids one query per channel (leg 5). So it uses the ID-SET +form, which carries the originating channel id through the recursion and +projects attributable `(channel_id, account_id)` PAIRS. A union of +several anchors' subtrees projecting bare accounts would merge two TREE +channels' participants with no way to tell them apart, which is a wrong +ANSWER, not a slow one: + +```sql +-- Anchor-to-subtree DESCENT, ID-SET form: the participant set of every +-- TREE channel in $1, each row attributed to its own channel. +WITH RECURSIVE subtree AS ( + SELECT c.id AS channel_id, c.parent_agent_id AS account_id + FROM channels c + WHERE c.id = ANY($1::text[]) AND c.membership_mode = 1 + UNION + SELECT s.channel_id, a.account_id + FROM agent_accounts a + JOIN subtree s ON a.parent_agent_id = s.account_id +) +SELECT channel_id, account_id FROM subtree +UNION +SELECT c.id AS channel_id, aa.owner_user_id AS account_id +FROM channels c +JOIN agent_accounts aa ON aa.account_id = c.parent_agent_id +WHERE c.id = ANY($1::text[]) AND c.membership_mode = 1; +``` + +It is the same recursion in the same direction over the same index; only +the seed and the projection widen by one column. Two properties carry +over unchanged and one strengthens. The `membership_mode = 1` hoist moves +INTO the seed, so an EXPLICIT channel in the id set never enters the +recursion. The trailing `UNION` adds each anchor's `owner_user_id` +against its own `channel_id`, so the owner term stays attributed too. +`UNION` still terminates, and on the pair rather than the account: a +cycle re-emits an already-seen `(channel_id, account_id)` row, the +working table empties and the recursion halts — and because the pair is +per-channel, one channel's cycle cannot truncate another's subtree. + +Cost differs by direction, and the descent is the more expensive one: +the ascent is bounded by tree DEPTH (one actor's ancestor chain), the +descent by subtree SIZE. The `membership_mode = 1` filter hoists out of +the descent the same way, so an EXPLICIT channel never enters it. + +Because both wrappers are rebound, the derived arm lands on every wrapper +caller at once. The callers were enumerated by grep over `go/**/*.go` for +`isChannelMember(` / `ChannelMemberExists(`, not by recall: + +- `AppendMessage` — the post gate (`go/internal/store/messages.go:59`), + keeping the not-found/forbidden merge ("never a hint that the channel + exists", `messages.go:56`). The old draft's promise that the post gate + stays unchanged is withdrawn: the gate gains the derived arm. +- `UpdateChannelMembers` (`go/internal/store/channels.go:387`) — plus the + TREE-mode refusals below. +- `SetChannelPolicy` (`channels.go:704`). +- `requireBoardMutator` — pin-board mutations + (`go/internal/store/channel_pins.go:179-180`); its OWNER_ONLY owner + gate is unchanged. +- `ListTopics` (`go/internal/store/topics.go:23`) — it calls the + UNEXPORTED `isChannelMember` directly, so it is a separate caller from + the stream filter below and inherits the derived arm the same way. T3 + carries its acceptance case; without naming it here it would gain + derived membership with no test. +- The stream filters `IsChannelMember` (`go/internal/store/authz.go:45`) + and `IsTopicChannelMember` (`authz.go:70`; its + `TopicChannelMemberExists` query at `authz.sql:8-10` gains the same + arm with the channel resolved through `topics.channel_id`). + +The two direct `ChannelMemberExists` callers above (`channels.go:550`, +`:765`) are deliberately NOT in this list — they keep member-row +semantics. + +Read paths that join `channel_members` directly switch to the same +participant shape: `GetPageCursorSeq` (`messages.sql:64`), `ListMessages` +(`:71`), `SearchMessages` (`:81`), `FindAskMessage` (`:92`), +`UpdateMessageBlocksAsAuthor` (`:53`), and `ResolveTopicForUpdate` +(`go/internal/store/queries/topics.sql:16`). Two member-row oracles stay +unchanged and are accepted as under-inclusive for TREE channels in v1: +`SharesVisibleChannel` (`go/internal/store/queries/presence_reads.sql:17-18`) +and the visible-accounts arm (`go/internal/store/queries/accounts.sql:133-134`) +— two accounts related ONLY through a TREE channel are not mutually +visible through them. Stated, not hidden. + +**No seeding; no reconcile.** The `SeedHomeChannelMembers` pattern +(`VALUES ($1, $2, FALSE), ($1, $3, TRUE)`, +`go/internal/store/queries/accounts.sql:41-42`) applies to EXPLICIT +channels only. A TREE attach writes no member rows, and a +`ReparentAgent` needs NO membership write for TREE channels — the +subtree is recomputed at query time, so the move commits the agent edge +and nothing else. The coordination hook keeps existing solely for the +coordination channels' stored membership (Global Constraints). + +**`UpdateChannelMembers` on a TREE channel is rejected, not ignored.** +An add or remove returns `ErrInvalidArgument` (the actor is a derived +member, so channel existence is already known to it; no merge needed). A +subscription toggle is allowed — see next. + +**Subscription state.** `channel_members.subscribed` is a per-row column +(`go/internal/store/migrations/0001_init.sql:219`) and TREE channels +have no rows, so subscription needs a new home. Three options weighed: + +- **Full subscription rows for every derived member — loses.** It mints + a row per (channel, subtree agent) and must rewrite them on every + reparent: the reconcile the ruling just eliminated, back under another + name. +- **Derive subscription from the tree (all derived members subscribed) — + loses.** Every post to a TREE channel would hit every subtree agent's + turn-end delivery; the existing `mandatory_subscription` class already + covers "everyone gets it", and the stored default today is + unsubscribed (`EnsureChannelMember` inserts `FALSE`, + `accounts.sql:44-46`). +- **Override rows only — chosen.** A new table + `channel_subscriptions (channel_id, account_id, subscribed)` holds a + row ONLY where an account explicitly toggled; default is unsubscribed. + An override row is effective only while the account is still a derived + member (the delivery queries conjoin the participant check), so a row + left behind by a reparent-out is inert — no cleanup write is needed on + any tree move, and a lazy GC may prune later. The subscribe toggle + seeds the D2 delivery cursor, the same seed-at-subscribe discipline + the explicit path uses ("insert when it is subscribed (D2 + seed-at-subscribe)", `go/internal/store/channels.go:642`). + +**Delivery: the driving relation changes, not a predicate.** This is the +consequence the old draft got wrong, so it is stated precisely. There are +FIVE delivery-side membership sites, and every one of them reads +`FROM channel_members cm` as its DRIVING relation. Which of the two +walks above each one needs is decided by what it is KEYED on, so the key +is tabled beside the site: + +| Query | File:line | Keyed on | Walk | Consumer | +| --- | --- | --- | --- | --- | +| `SubscribedAgents` | `delivery_reads.sql:10` | `cm.channel_id = $1` (`:13`) | descent | turn-end delivery fan-out | +| `ChannelAgentMembers` | `delivery_reads.sql:18-24` | `cm.channel_id = $1` (`:22`) | descent | @mention routing | +| `SweepChannels` | `delivery_reads.sql:41` | `cm.account_id = $1` (`:44`) | ascent | the D1 sweep set | +| `UndeliveredMessages` | `delivery_cursors.sql:83` | `cm.account_id = $1` (`:90`) | ascent | undelivered replay | +| `InSweepSet` | `delivery_cursors.sql:102` | `cm.account_id = $1 AND cm.channel_id = $2` (`:105-106`) | ascent | sweep-set membership probe | + +A TREE channel has zero `channel_members` rows by construction, so each +of these yields the EMPTY SET before any `WHERE` clause runs. Extending +the existing subscription disjunct (`delivery_reads.sql:14`, `:45`; +`delivery_cursors.sql:91`, `:107`) can therefore never admit a derived +member: a predicate cannot filter a row into existence. What is required +is a rewrite of each query's FROM: a `participants` CTE that UNIONs the +stored `channel_members` rows with the derived participant set, LEFT +JOINing `channel_subscriptions` to supply `subscribed` for the derived +arm. The existing disjunct then reads `subscribed` off that CTE +unchanged. + +The derived arm of that CTE is the ASCENT for the three account-keyed +sites and the DESCENT for the two channel-keyed ones. The split is +forced, not stylistic: an account-keyed query is asking "which of THIS +actor's channels does it participate in", which the ascent answers from +the actor it already has; a channel-keyed query is asking "who are all +the participants of THIS channel", and the ascent cannot answer it +because it has no actor to seed from — `SubscribedAgents`' and +`ChannelAgentMembers`' only account parameter is the author to EXCLUDE +(`cm.account_id <> $2`, `delivery_reads.sql:15`, `:23`). `InSweepSet` +takes both keys and uses the ascent, because it is a single-actor probe +and the ascent is bounded by depth rather than subtree size. + +`subscribed` is NULLABLE on the derived arm and MUST be coalesced: the +CTE selects `COALESCE(cs.subscribed, FALSE) AS subscribed` from the LEFT +JOIN, so a derived participant with no override row reads FALSE. Without +the COALESCE the arm reads NULL and the disjunct +`(cm.subscribed OR cm.channel_id = aa.home_channel_id OR +ch.mandatory_subscription)` (`delivery_reads.sql:14`) evaluates to NULL, +which a `WHERE` treats as not-true — the same answer today, but only +because the other two disjuncts are structurally FALSE on a TREE +channel: home channels stay EXPLICIT (below) and `mandatory_subscription` +is refused on TREE (below). Both are separate decisions elsewhere in this +record, so relaxing either would silently flip delivery for every +un-overridden derived participant. The COALESCE makes the CTE's own +semantics total and independent of them. + +`ChannelAgentMembers` has no subscription predicate and needs the union +alone; it is the site that carries @mentions — `resolveMentioned` +(`go/internal/delivery/dispatch.go:278`) reads it for both the reserved +`@everyone`/`@agents` expansion and the per-handle membership check, and +`dispatch.go:272-273` states "a resolved agent that is not a channel +member is also a no-op", so without this rewrite every mention in a TREE +channel is silently dropped. T5 owns all five. + +One further channel-keyed site is deliberately NOT rewritten: +`SeedChannelDeliveryCursors` (`delivery_cursors.sql:15-22`, keyed +`WHERE cm.channel_id = $1` at `:21`) would need the descent by the same +argument, but its two callers both fire only on a mandatory channel +(`channels.go:198`, `:795`) and `mandatory_subscription` is refused on +TREE, so it can never see a mode-1 channel in v1. Stated so a later +relaxation of that refusal knows this query is the sixth site. + +**Cost, stated honestly.** This puts a recursive CTE on the delivery +fan-out path, evaluated per post, where today's shape is a +`channel_members` index scan (`0001_init.sql:216-222`). The ascent is +one actor's ancestor chain — depth of the agent tree, small; the descent +is the anchor's whole subtree, so the two fan-out sites pay +proportionally to subtree SIZE, served by `agent_accounts_parent_idx` +(`0001_init.sql:119`). The `membership_mode = 1` hoist below keeps an +EXPLICIT channel out of either recursion, so the EXPLICIT path is +unchanged. It is still a real new cost on the hottest write path, +accepted here rather than discovered in production. + +One v1 restriction follows: `mandatory_subscription` is refused on a +TREE channel (`CreateChannel` and `SetChannelPolicy` guards), because +mandatory delivery is defined over member rows (`delivery_reads.sql:14`) +and so is its D2 seeding (`SeedChannelDeliveryCursors`, +`delivery_cursors.sql:15-22`, `FROM channel_members cm`; the +newly-mandatory flip that calls it is `channels.go:794-795`), and a TREE +channel has none. Revisit when the ACL record re-cuts this surface. + +**Home channels stay EXPLICIT.** They are minted ungrouped with seeded +members (`accounts.go:375-384`) and place under their agent by UI +derivation alone (leg 1); no home channel is ever TREE. + +**`ReparentChannel` invariants.** Channels have no owner column — +`owner_account_id` is the post-policy operator, legal only on OWNER_ONLY +and rejected on OPEN (`channels.go:93-104`) — so "same owner" needs a +real referent. The rules, **in the order they MUST be implemented** — +the authz gate first, the shape refusals after: + +- Source authz, FIRST: the caller must be a channel participant (the + probe), and for a non-empty destination the caller's resolved owner + must equal the destination agent's owner. Unknown channel, unknown + agent, and non-participant all merge to `ErrNotFound`. +- **The ordering is load-bearing.** Every refusal below is + `ErrInvalidArgument`, which is itself a channel-existence oracle: it + tells the caller the channel exists AND is grouped / is a DM / is a + home channel. The repo already treats exactly this discipline as + load-bearing at the analogous gate — "This is InvalidArgument and MUST + stay after the no-oracle owner gate above: a non-owner already + collapsed to ErrNotFound and never reaches here, so no InvalidArgument + signal leaks channel existence to an unauthorized caller" + (`go/internal/store/channels.go:750-752`). The Global Constraint + not-found/forbidden merge is only real if the merge runs first. +- **Anchor-side authority: any participant may re-anchor within the + owner set.** Decided, not omitted. On a TREE channel the participant + probe derives from `parent_agent_id` — the very column being mutated — + so "participant" means anywhere in the current subtree, and a + descendant may therefore move a channel its manager anchored, or (on + an EXPLICIT channel) detach it to the root. This is consistent with + DL-345's owner-set trust: the owner set is the trust boundary, and + placement inside it is not separately privileged. No anchor-side check + (caller-is-the-anchor, or an ancestor of it) is added. T2 pins the + behaviour with a descendant-re-anchors-its-ancestor's-channel test so + a later reader sees a decision, not a gap. +- Refuse `group_id IS NOT NULL` (`ErrInvalidArgument`): attach applies + to root ungrouped channels only. This also keeps "reserved namespaces + untouchable" structural — every live DM sits in a `__dm__` group, and + the create-guard `isReservedDMGroupTx` (`channels.go:126-140`) is + create-only, so without this refusal a reparent could pull a DM out of + the reserved namespace. +- Refuse `kind != CHANNEL_KIND_CHANNEL` (kind 0, `comms.proto:290`). A + converted DM is attachable — "a third party converts it to a named + CHANNEL" (`comms.proto:285-286`), and conversion leaves it ungrouped + (post-convert `GroupID` empty, + `go/internal/store/dm_pgtest_test.go:425-427`); a live DM is not. +- Refuse a home channel (any channel referenced by an agent's + `home_channel_id`): home channels place by derivation, never by edge. +- A TREE channel must keep an anchor: an empty destination on a TREE + channel is `ErrInvalidArgument` (the CHECK makes it structural). An + EXPLICIT channel may detach to the root. +- No cycle check is needed — a channel is a leaf; the test documents it. + +Cross-owner note: either member of a converted DM may attach it to an +agent of its OWN owner; the other owner's accounts remain members via +the explicit arm, so an attach never subtracts access. + +### 4. Migration + +- **Schema**: one new numbered migration adds to `channels`: + `parent_agent_id TEXT REFERENCES agent_accounts (account_id) ON DELETE + RESTRICT`, nullable, NULL = root — the same shape as + `agent_accounts.parent_agent_id` + (`go/internal/store/migrations/0001_init.sql:103`) — plus + `membership_mode SMALLINT NOT NULL DEFAULT 0 CHECK (membership_mode IN + (0, 1))` — the value CHECK matching every other enum column on the + table (`kind`, `0001_init.sql:195`; `post_policy`, `:197`) — plus two + further CHECK constraints making the invariants structural (`group_id + IS NULL OR parent_agent_id IS NULL`; `membership_mode = 0 OR + parent_agent_id IS NOT NULL`), an + index mirroring `channel_groups_parent_idx` (`0001_init.sql:174`), and + a partial unique index `ON channels (parent_agent_id, name) WHERE + parent_agent_id IS NOT NULL` — the agent-namespace mirror of + `channels_group_name_key` (`0001_init.sql:210`), without which two + same-name channels under one agent would both insert (the group index + covers only `group_id IS NOT NULL`). The migration also creates + `channel_subscriptions` (leg 3), shaped like `channel_members` + (`0001_init.sql:216-222`) minus the membership meaning — **including + its account-direction index**, the mirror of + `channel_members_account_idx` (`0001_init.sql:224`), which the + 216-222 range stops one line short of: the composite PK serves + channel-first lookups only, and 0001 states the reason both directions + are indexed ("by channel (list a channel's members) and by account + (the visible-channels query for a caller)", `:214-215`). The same + asymmetry binds here — the three account-keyed delivery queries are + exactly where leg 3 LEFT JOINs this table — **and, for a + new tenant-owned table, its own `ENABLE`/`FORCE ROW LEVEL SECURITY`, + its own `tenant_isolation` policy and its own grants.** 0001 applies + those through a hardcoded `tenant_tables` array literal + (`0001_init.sql:939-950`) and a `GRANT … ON ALL TABLES` + (`0001_init.sql:927`) with no `ALTER DEFAULT PRIVILEGES` anywhere, so a + table added by a later migration inherits NEITHER. The grant half fails + CLOSED (permission denied under the request-path `compass_app` role, + `0001_init.sql:881-882`); the RLS half fails OPEN — cross-tenant reads + with a green test suite. T2 carries the exact DDL and its acceptance + case. This is a standing hazard for every future table, not a quirk of + this one. +- **Data**: no rewrite of `channel_groups` rows. Reserved groups + (`__dm__`, `__coordination__`) keep working untouched. Existing + user-created grouped channels keep `group_id` and render in the band + leg 5 names for their group's visibility — SHARED groups in the + shared-spaces band, OWNER groups in the root band — until a user + attaches them to an agent via the + new reparent RPC; home channels relocate under their agents purely by + UI derivation (no data change). Every existing channel is + `membership_mode = 0` by default — behaviour-preserving. +- **Wire**: purely additive — two new fields on `Channel`, two on + `CreateChannelRequest`, one new enum, one new RPC. Nothing removed or + renumbered, so no `reserved` statements and no breaking wire change + (Global Constraints cites the repo's removed-vs-reserved precedent). + +### 5. UI surface: one tree in the sidebar + +Today `LeftSidebar` mounts `` then `` +(`apps/ui/src/components/LeftSidebar.tsx:509-510`). `ChannelsSection` +partitions channels by group via +`channelSections(memberChannels(), store.channelGroups())` +(`LeftSidebar.tsx:312-313`; the partition function is +`apps/ui/src/comms.ts:114-117`, producing `ChannelSection { group: +ChannelGroup | undefined; channels: Channel[] }` at `comms.ts:104-107`). +`AgentsSection` renders "the existing user-organized folder tree of agents" +(`LeftSidebar.tsx:375-376`) from `agentTree(agents)` — the derivation over +`parentAgentId` at `apps/ui/src/stub-data.ts:387`, producing +`AgentTreeNode { agent: Agent; children: AgentTreeNode[] }` +(`stub-data.ts:367-370`). + +After the fold the sidebar renders ONE section. Its bands are exactly +these five, and the rest of this record uses these five names for them: + +- **The agent tree**, as today (`AgentLeaf` at `LeftSidebar.tsx:30`, + `Branch` at `:94`, `Node` at `:129`), where each agent node additionally + lists its attached channels as child rows: the agent's home channel + (matched by `Agent.account` → `home_channel_id`) plus every channel + whose `parentAgentId` names that agent. `AgentTreeNode` grows a + `channels: Channel[]` member populated by the tree derivation. A home + channel is an ordinary `CHANNEL`-kind ungrouped channel + (`go/internal/store/accounts.go:377-380`, `Kind: + int16(ChannelKindChannel)`), so today it lands in `channelSections`' + trailing ungrouped section (`apps/ui/src/comms.ts:124-128`); after the + fold the deriver claims it for the agent band and excludes it from + every other band, so it renders exactly once. Clicking a home-channel + child row opens the channel view; the agent row keeps its existing + click behaviour, so the workspace stays reachable there. +- **Shared spaces** at the root: sections for SHARED groups — the badge + branch that already exists at `LeftSidebar.tsx:341`. +- **The root band**: one section for everything the agent tree did not + claim and the shared-spaces band does not cover — channels in OWNER + groups and ungrouped channels alike. This band is NOT optional + and is the common case, because OWNER is the DEFAULT group visibility + (`CHANNEL_GROUP_VISIBILITY_OWNER = 0`, `comms.proto:226`; + `VisibilityOwner ChannelGroupVisibility = 0`, + `go/internal/store/types.go:64`), so every group a user creates + without asking for SHARED lands here rather than in shared spaces. + Read the shared-spaces filter as selecting INTO that band, never + as excluding OWNER-grouped channels from the sidebar. +- **Direct messages**, unchanged: the existing DM subsection + (`LeftSidebar.tsx:358-359`) stays its own band; a DM's surface is not a + tree concern (1:1 agent DMs are already excluded from the channel list, + `LeftSidebar.tsx:306-307`). +- **Browse**, unchanged and surviving the fold: `BrowseChannels` + (`LeftSidebar.tsx:267`, mounted at `:366-367`) over + `browsableChannels(store.channels())` (`:321`). It is not a band of + the fold at all — the other four render `railChannels`, the + `membership !== "none"` set (`apps/ui/src/comms.ts:59`), and this one + renders the complement. It is listed because a channel the agent band + fails to claim falls HERE, which is what T7's assertion discriminates. + +**How those two bands are built, since neither is what `channelSections` +does today.** `channelSections` (`apps/ui/src/comms.ts:114-129`) +partitions by group MEMBERSHIP, not by visibility, and takes no +visibility parameter: it emits one section per group in `groups` that +has channels (`comms.ts:120-122`), then ONE trailing +`group: undefined` section holding the channels whose group is absent or +not in `groups` — `grouped.filter((c) => c.groupId === undefined || +!groups.some((g) => g.id === c.groupId))` (`:124-126`). So an +OWNER-grouped channel lands in its own group's section today, and +"widening the trailing section" is not a filter that exists to widen. + +The mechanism is to narrow the ARGUMENT rather than change the function, +and the function's existing unknown-group arm then does the widening for +free. The sidebar calls `channelSections` ONCE, passing only the SHARED +groups: + +```ts +// LeftSidebar.tsx:312-313 today: +// channelSections(memberChannels(), store.channelGroups()) +const sharedGroups = () => + store.channelGroups().filter((g) => g.visibility === "shared"); +const sections = () => + channelSections(unclaimedChannels(), sharedGroups()); +``` + +`unclaimedChannels()` is `memberChannels()` (`LeftSidebar.tsx:311`) +minus what the agent band claimed, which the first T7 bullet already +owns; the change this mechanism makes is the SECOND argument. + +Every SHARED group gets its own section, exactly the shared-spaces +band; every OWNER-grouped channel now names a group NOT in `groups` and +so falls through the `:124-126` predicate into the single trailing +section, alongside the genuinely ungrouped ones — exactly the root band, +as ONE section, which a post-hoc filter over the RESULT could not +produce (it would yield one section per OWNER group). The two bands are +then the leading `group !== undefined` sections and the trailing +`group === undefined` one, which is the order `channelSections` already +returns them in, so the render is a split of one list rather than two +calls. `channelSections` itself, its signature and all three of its +existing tests are UNCHANGED — the OWNER-visibility group in +`comms.test.ts:321-341` still gets its own section when it is passed in +`groups`, because the behaviour that changes is the caller's argument, +not the partition. `ChannelGroupVisibility` is `"owner" | "shared"` +(`apps/ui/src/comms-stub.ts:45`), so the filter is total and needs no +default arm. The one cost: the trailing section's header reads +`"channels"` (`LeftSidebar.tsx:340`, `section.group?.name ?? "channels"`) +— fine as the root band's label, and T7 owns it if it should read +otherwise. + +**The wire gap this band depends on.** A TREE channel cannot reach the +agent band on today's contract, and the sidebar work alone cannot fix +it. `Channel.member_account_ids` (`comms.proto:243`) and +`subscriber_account_ids` (`:248`) are populated from member rows only: +`loadChannelMembers` (`go/internal/store/channels.go:883`) reads +`ChannelMembersByChannelIDs` (`:893`) and appends into +`MemberAccountIDs`, mapped to the wire at +`go/internal/comms/mapping.go:73-74`. A TREE channel has no member rows, +so both lists ship EMPTY. The UI then derives the caller's membership +entirely from them — `deriveMembership` +(`apps/ui/src/live/adapt.ts:172-176`) returns `"none"` when the caller is +in neither list, and the doc comment above it states the model: "the wire +carries no per-caller membership enum — the domain's join/subscribe model +is a UI projection over member_account_ids + subscriber_account_ids" +(`adapt.ts:161-171`). `railChannels` (`apps/ui/src/comms.ts:58-59`) keeps +only `membership !== "none"`, so every TREE channel would land in the +browse list behind the permanently disabled join button +(`LeftSidebar.tsx:289-296`, title "Joining is not wired up yet") and +never under its agent. + +**Decided: the server materializes.** `loadChannelMembers` gains a +branch that, for each `membership_mode = 1` channel, fills +`MemberAccountIDs` with the derived participant set — the anchor-to- +subtree DESCENT of leg 3 in its ID-SET form, since `loadChannelMembers` +is keyed by +channel and by nothing else (`ChannelMembersByChannelIDs` takes +`$1::text[]` of channel ids, `channels.sql:73-76`) and must attribute +each row back to its channel — and +`SubscriberAccountIDs` from the `channel_subscriptions` overrides +INTERSECTED with that derived set. The existing UI projection then works +unchanged, and so does every other consumer of those two lists. + +The intersection is load-bearing, not tidiness. Leg 3 leaves a stale +override row in place after a reparent-out and calls it inert — but +inert FOR DELIVERY only, because the delivery queries conjoin the +participant check. `loadChannelMembers` is a new site that no such +conjunction covers. Filled from raw override rows, a reparented-out +agent would appear in `subscriber_account_ids` and not in +`member_account_ids`, and `deriveMembership` checks subscribers FIRST +(`adapt.ts:173`) — so it would return `"subscribed"`, the top tier, and +the agent's rail would show a channel every server read gate answers +with `ErrNotFound`. It would also break the domain type's stated +invariant, "SubscriberAccountIDs is the subset of members … A subset of +MemberAccountIDs" (`go/internal/store/types.go:215-219`), and the +premise the adapter relies on: "A subscriber is by definition a member +(the server enforces 'subscribe only a current/added member')" +(`adapt.ts:169-171`). + +**Cost of the materialization, on the read path.** The descent runs on +every read that loads member lists, not just the sidebar's first paint: +`loadChannelMembers` has four call sites (`channels.go:296`, `:348`, +`:829`, `:856`), so `ListChannels` pays it too. It MUST NOT become one +query per channel. The function's own contract forbids that — it +"populates each channel's member and subscriber sets with one follow-up +query over the whole id set, so member loading is O(1) round-trips +rather than one per channel" (`channels.go:879-882`) — so the derived +branch is ONE set-based recursive query over the whole mode-1 id set +(the descent seeded from every mode-1 channel's anchor at once), keeping +the round-trip count at two rather than restoring the N+1 that comment +designed out. Accepted consequence on the wire: `member_account_ids` +becomes O(subtree) per TREE channel, where today it is bounded by a +hand-managed member set and the proto documents it only as "The accounts +party to the channel" (`comms.proto:242-243`) with no size expectation. + +The alternative — a per-caller membership field on the wire `Channel` — +is rejected: a larger contract change that contradicts the model +`adapt.ts:161-171` states. T4 owns the server half and pins it with the +pgtest that matters: a TREE channel's derived participants arrive in +`MemberAccountIDs` with its `channel_members` row count asserted zero in +the same test. T8 pins the client half — a TREE `WireChannel` whose +materialized `member_account_ids` CONTAINS the subtree agent derives +`"joined"` (and `"subscribed"` with the override also present). Note +what T8 canNOT assert: it is a unit test over a hand-built fixture, so +it cannot observe what the server produced. Under this shape a channel +arriving with BOTH lists genuinely empty SHOULD derive `"none"`, and +`deriveMembership` (`adapt.ts:172-176`) stays unchanged and correct in +doing so; asserting otherwise would be asserting the rejected +alternative. The regression risk lives on the server, and T4's pgtest is +where it is caught. + +Dead residue removed in the same slice: + +- The header button `