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 `