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
Section titled “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
Section titled “Global Constraints”- Additive wire change only. No proto field is removed or renumbered, so
no
reservedstatement 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) namesagent_accounts.parent_agent_id, whose two writers areCreateAgent(go/internal/store/accounts.go:363-372) andReparentAgent(accounts.go:614-633). It holds unchanged: the hook reconciles the stored membership of coordination channels, which stay explicitly-membered.channels.parent_agent_idis a NEW edge with its own writers (CreateChannelwith 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
Section titled “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
Section titled “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 isgo/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
Section titled “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_idset is visible to its members OR to the anchor agent’s owner set: the agent’sowner_user_idplus every agent with thatowner_user_id. The three channel predicate copies gain theviewerCTE (the group queries’ shape) plus one disjunct resolving the anchor’s owner throughagent_accounts. All three copies change in one commit (Global Constraints). - Visibility is not readability. Message reads keep their membership
gate —
ListMessagesjoins 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
CreateChanneladds “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
Section titled “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.
ChannelMembershipModeonChannel(proto field 12; columnchannels.membership_mode), valuesEXPLICIT = 0(default, today’s behaviour) andTREE = 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
ChannelKindvalue 3 — loses.kindis the DM-vs-channel axis:CHANNEL_KIND_CHANNEL = 0,CHANNEL_KIND_DM = 1, and the retiredCHANNEL_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 aCHANNELin every kind-switch in the codebase; a third value would make every kind check grow an arm that behaves asCHANNEL.
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):
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 breaks 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:
-- 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 subtreeUNIONSELECT aa.owner_user_idFROM channels cJOIN agent_accounts aa ON aa.account_id = c.parent_agent_idWHERE 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_ids, 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:
-- 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 subtreeUNIONSELECT c.id AS channel_id, aa.owner_user_id AS account_idFROM channels cJOIN agent_accounts aa ON aa.account_id = c.parent_agent_idWHERE 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 UNEXPORTEDisChannelMemberdirectly, 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) andIsTopicChannelMember(authz.go:70; itsTopicChannelMemberExistsquery atauthz.sql:8-10gains the same arm with the channel resolved throughtopics.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_subscriptionclass already covers “everyone gets it”, and the stored default today is unsubscribed (EnsureChannelMemberinsertsFALSE,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-guardisReservedDMGroupTx(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-convertGroupIDempty,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
Section titled “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 asagent_accounts.parent_agent_id(go/internal/store/migrations/0001_init.sql:103) — plusmembership_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 mirroringchannel_groups_parent_idx(0001_init.sql:174), and a partial unique indexON channels (parent_agent_id, name) WHERE parent_agent_id IS NOT NULL— the agent-namespace mirror ofchannels_group_name_key(0001_init.sql:210), without which two same-name channels under one agent would both insert (the group index covers onlygroup_id IS NOT NULL). The migration also createschannel_subscriptions(leg 3), shaped likechannel_members(0001_init.sql:216-222) minus the membership meaning — including its account-direction index, the mirror ofchannel_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 ownENABLE/FORCE ROW LEVEL SECURITY, its owntenant_isolationpolicy and its own grants. 0001 applies those through a hardcodedtenant_tablesarray literal (0001_init.sql:939-950) and aGRANT … ON ALL TABLES(0001_init.sql:927) with noALTER DEFAULT PRIVILEGESanywhere, so a table added by a later migration inherits NEITHER. The grant half fails CLOSED (permission denied under the request-pathcompass_approle,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_groupsrows. Reserved groups (__dm__,__coordination__) keep working untouched. Existing user-created grouped channels keepgroup_idand 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 ismembership_mode = 0by default — behaviour-preserving. - Wire: purely additive — two new fields on
Channel, two onCreateChannelRequest, one new enum, one new RPC. Nothing removed or renumbered, so noreservedstatements and no breaking wire change (Global Constraints cites the repo’s removed-vs-reserved precedent).
5. UI surface: one tree in the sidebar
Section titled “5. UI surface: one tree in the sidebar”Today LeftSidebar mounts <ChannelsSection /> then <AgentsSection />
(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 (
AgentLeafatLeftSidebar.tsx:30,Branchat:94,Nodeat:129), where each agent node additionally lists its attached channels as child rows: the agent’s home channel (matched byAgent.account→home_channel_id) plus every channel whoseparentAgentIdnames that agent.AgentTreeNodegrows achannels: Channel[]member populated by the tree derivation. A home channel is an ordinaryCHANNEL-kind ungrouped channel (go/internal/store/accounts.go:377-380,Kind: int16(ChannelKindChannel)), so today it lands inchannelSections’ 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) overbrowsableChannels(store.channels())(:321). It is not a band of the fold at all — the other four renderrailChannels, themembership !== "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:
// 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
<button type="button" class="icon-btn" title="New folder">(LeftSidebar.tsx:437) has no click handler — nothing in the component wires it. T7 deletes it; a per-agent-row “new channel here” affordance is deferred (Open Questions). app.cssretains folder-tree classes with live consumers only inside the agent tree:.folder-caret(apps/ui/src/app.css:270),.folder-caret.collapsed(:280),.folder-badge(:284),.folder-children(:301), plus the.folderwrapper class used atLeftSidebar.tsx:99. These are renamed totree-*so the “folder” vocabulary (retired with the folder tree in PR #91) leaves the codebase.
Alternatives considered
Section titled “Alternatives considered”- B — keep
ChannelGroupas the user-facing namespace; UI-only merge. The sidebar interleaves group sections into the agent tree by matching group owner to agent owner. Loses: posting boundaries do not follow the tree (membership still managed per-group), and the sidebar needs a fragile name/owner join between two unrelated hierarchies. The issue’s stated goal — one hierarchy with tree-following boundaries — is not met; this is a rendering trick. - C — agent tree subsumes grouping entirely; drop
ChannelGroup. Delete the group table, move SHARED to a channel-level visibility field. Loses: OpenDM and the coordination reconcile both depend on reserved group rows (go/internal/comms/dm.go:34,go/internal/comms/coordination.go:15-17), so this forces a rewrite of two working subsystems; and removingChannelGroupmessages/RPCs is a breaking wire change requiringreservedbookkeeping. Far more blast radius for the same sidebar outcome. Can be revisited after this record ships if groups atrophy to machinery-only. - D — per-agent server-minted groups. Model attachment as a reserved
group per agent, reusing the whole existing lattice — predicates,
per-group name uniqueness — the way the coordination reconcile already
mints per-owner machinery groups. Loses: a group’s owner is a user, not
an agent (the
channel_groupsDDL carriesowner_user_idand no agent reference at all,go/internal/store/migrations/0001_init.sql:165-172), so agent identity must be encoded in reserved names crowding the__dm__/__coordination__namespace; the sidebar still needs an agent-to-group join; and group visibility grants reads, not posting membership — member rows would still need a reconcile on every reparent, which is exactly what the membership ruling eliminates. - E — materialized-derived membership (member rows as a cache the hook
rewrites). Keeps every membership JOIN working unchanged, but a
subtree reparent must rewrite member rows on every TREE channel of
every affected ancestor chain — unbounded write amplification against
the hook’s current cost of reconciling one manager’s one channel
(
go/internal/comms/coordination.go:14-19) — and any missed write is a stored-membership leak. Matt’s “purely derived from the tree” is delivered by computing at query time, not by caching. - A (chosen) — channel gains an owning-agent edge plus a membership mode; groups demoted to machinery. Smallest additive delta; two membership modes cover both ruled cases; the non-member read grant is the ruled owner-set default; reserved-namespace machinery untouched.
T1 — proto: attach edge, membership mode, ReparentChannel (lane: proto owner)
Section titled “T1 — proto: attach edge, membership mode, ReparentChannel (lane: proto owner)”Add to proto/compass/v1/comms.proto:
string parent_agent_id = 11;onmessage Channel(last used field isrepeated PinnedEntry pinned_entries = 10;,comms.proto:259). Comment states the owner-set read grant and the SHARED exclusion.ChannelMembershipMode membership_mode = 12;onmessage Channel, with the new enum below.ChannelKindis not touched.string parent_agent_handle = 5;andChannelMembershipMode membership_mode = 6;onCreateChannelRequest(last used field isrepeated string member_handles = 4;,comms.proto:663), the handle mutually exclusive withgroup_id. Handle addressing follows the account conventionReparentAgentRequestset: “A@handle; the server resolves it to an account id; unknown → NOT_FOUND” (comms.proto:714-715).rpc ReparentChannel(ReparentChannelRequest) returns (ReparentChannelResponse);besideReparentAgent(comms.proto:82).
Interfaces:
- Consumes: existing
Channel,CreateChannelRequest,ChannelChanged. - Produces:
enum ChannelMembershipMode { // Stored channel_members rows; UpdateChannelMembers manages them. CHANNEL_MEMBERSHIP_MODE_EXPLICIT = 0; // Membership derived from the agent subtree under parent_agent_id; no // stored member rows; UpdateChannelMembers add/remove is rejected. CHANNEL_MEMBERSHIP_MODE_TREE = 1;}
message ReparentChannelRequest { string channel_id = 1; // Destination agent; empty detaches an EXPLICIT channel to the root. // A `@handle`; the server resolves it; unknown → NOT_FOUND. string new_parent_agent_handle = 2;}
message ReparentChannelResponse { Channel channel = 1;}Test cycle: buf lint / codegen build; wire-compat check that no existing
field moved.
T2 — store: schema migration + attach writes (lane: compass-server)
Section titled “T2 — store: schema migration + attach writes (lane: compass-server)”New numbered migration (shapes per Approach leg 4):
ALTER TABLE channels ADD COLUMN parent_agent_id TEXT REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, ADD COLUMN membership_mode SMALLINT NOT NULL DEFAULT 0 CHECK (membership_mode IN (0, 1)), ADD CONSTRAINT channels_group_xor_agent CHECK (group_id IS NULL OR parent_agent_id IS NULL), ADD CONSTRAINT channels_tree_mode_needs_agent CHECK (membership_mode = 0 OR parent_agent_id IS NOT NULL);CREATE INDEX channels_parent_agent_idx ON channels (parent_agent_id);CREATE UNIQUE INDEX channels_agent_name_key ON channels (parent_agent_id, name) WHERE parent_agent_id IS NOT NULL;CREATE TABLE channel_subscriptions ( channel_id TEXT NOT NULL REFERENCES channels (id) ON DELETE RESTRICT, account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE RESTRICT, subscribed BOOLEAN NOT NULL DEFAULT FALSE, tenant_id TEXT NOT NULL DEFAULT current_setting('compass.tenant_id', TRUE), PRIMARY KEY (channel_id, account_id));-- Both directions indexed, the mirror of channel_members_account_idx-- (0001_init.sql:224) and for 0001's stated reason (:214-215): the composite-- PK serves channel-first lookups, and the three account-keyed delivery-- queries drive account-first.CREATE INDEX channel_subscriptions_account_idx ON channel_subscriptions (account_id);-- The tenant_id column is only HALF the isolation mechanism. 0001's ENABLE-- + FORCE + policy loop runs over a HARDCODED array literal-- (0001_init.sql:939-950), and its GRANT is `ON ALL TABLES` at grant time-- (0001_init.sql:927) with no ALTER DEFAULT PRIVILEGES anywhere, so a new-- table inherits NEITHER. The grant half fails CLOSED (permission denied-- under SET LOCAL ROLE compass_app, caught by the first pgtest); the RLS-- half fails OPEN (cross-tenant reads, a green suite). Both halves are-- therefore restated here explicitly, the policy in 0001's frozen T2 form-- (0001_init.sql:955-961): scalar-subquery GUC read, non-empty guard,-- tenant_id equality, as both USING and WITH CHECK.ALTER TABLE channel_subscriptions ENABLE ROW LEVEL SECURITY;ALTER TABLE channel_subscriptions FORCE ROW LEVEL SECURITY;CREATE POLICY tenant_isolation ON channel_subscriptions USING ((SELECT current_setting('compass.tenant_id', TRUE)) <> '' AND tenant_id = (SELECT current_setting('compass.tenant_id', TRUE))) WITH CHECK ((SELECT current_setting('compass.tenant_id', TRUE)) <> '' AND tenant_id = (SELECT current_setting('compass.tenant_id', TRUE)));GRANT SELECT, INSERT, UPDATE, DELETE ON channel_subscriptions TO compass_app, compass_system;0001’s hardcoded tenant_tables array is a standing hazard for every
future table, not just this one: any migration that adds a tenant-owned
table must restate RLS, the policy and the grants itself, because
nothing in the schema does it automatically and the omission is silent.
Store writes:
CreateChannelacceptsParentAgentID+MembershipMode; rejects bothGroupIDandParentAgentIDset, TREE without an anchor, TREE withmandatory_subscription, and TREE with anOWNER_ONLYpost policy (ErrInvalidArgumenteach — the last one per the leg-3 decision, which binds both writers, and which the existing coherence check atchannels.go:170cannot catch because a TREE create writes no member rows); authorizes attach — the caller must be the agent’s owner or an agent with the same owner, unknown agent merged toErrNotFound(the gate shape atgo/internal/store/channels.go:114-117). EXPLICIT attach keeps today’s member augmentation (channels.go:76-79); a TREE create still runsexpandOwnerMembership(channels.go:160) for the authz and the returned list, but skips theEnsureChannelMemberloop over it (channels.go:174-184) and returns the derived participant set asMemberAccountIDsinstead (leg 3) — which after T4’s hop (v) it gets from the post-commits.getChannelre-read rather than from a hand-written literal. T4 owns that swap because it owns the two columns the literal would otherwise drop; T2 owns only the two new input fields, and MUST NOT set them on the literal as a shortcut, because a fifthChannelconstruction site is the defect, not the missing assignment.membership_modeis immutable after create (leg 3 invariant): no store write may change it, pinned by a test.Store.ReparentChannelenforcing every invariant in Approach leg 3 in the order leg 3 lists them: the participant-plus-same-owner authz gate with theErrNotFoundmerge FIRST, then the grouped-channel, kind, home-channel and TREE-keeps-anchor refusals. The order is load-bearing, not cosmetic — see leg 3.
Interfaces:
- Consumes:
store.NewChannel(go/internal/store/inputs.go:52; gainsParentAgentID AccountIDandMembershipMode ChannelMembershipMode), new sibling ofrequireGroupCreateAuthz(authz.go:89):requireAgentAttachAuthz(ctx context.Context, q db.DBTX, actor AccountID, agentID AccountID) error. - Produces:
func (s *Store) ReparentChannel(ctx context.Context, actor AccountID, channelID ChannelID, newParentAgentID AccountID) (Channel, error);ChannelMembershipModedomain type intypes.go; the migration file.
Test cycle: pgtest + sql-migration-gate (squawk + sqruff over
go/internal/store/migrations/*.sql,
tools/sql-migration-gate/index.ts:38) — this is the FIRST migration
after the bootstrap, so .squawk.toml’s own revisit-note is now live:
“They stay OFF only for this bootstrap posture — a future incremental
migration against live data would want them back (revisit this list when
the first post-live migration lands)” (.squawk.toml:10-13). Confirmed
for THIS migration, with the reason per exclusion: channels is small
and the added column carries a constant default (no table rewrite on PG
11+), the two index builds are non-CONCURRENT but run pre-live, and
prefer-bigint-over-smallint (.squawk.toml:44) still holds because
membership_mode is a two-value CHECK-able enum, the same shape as
channels.post_policy (0001_init.sql:197,
SMALLINT NOT NULL DEFAULT 0 CHECK (post_policy IN (0, 1))). The
exclusions are re-confirmed here, not narrowed; the first migration
against genuinely live data must narrow them. sqruff’s capitalisation
rules stay ON and lint this DDL block.
pgtest cases: create-under-agent happy path in both modes; cross-owner
attach → ErrNotFound; both-parents-set, TREE-without-anchor,
grouped-channel reparent, DM-kind reparent, home-channel reparent, and
TREE-detach-to-root → ErrInvalidArgument; a non-participant
reparenting a grouped channel gets ErrNotFound, not
ErrInvalidArgument (the M1 ordering, pinned by a test rather than by
bullet order); a descendant re-anchors its ancestor’s channel and
succeeds (the M4 permissive decision, pinned so a later reader cannot
read it as an omission); a second tenant reads zero
channel_subscriptions rows written under the first tenant’s GUC (the
RLS policy, which fails OPEN if the DDL is missed and so cannot be left
to the grant’s permission-denied); an OWNER_ONLY post policy on a
TREE create → ErrInvalidArgument (the N2 both-writers refusal, the
create half of the pair T5 asserts on the update half);
mandatory_subscription on a TREE create → ErrInvalidArgument
(the create half of its own pair, whose update half T5 asserts as the
mandatory flip — the refusal binds both writers exactly as the
OWNER_ONLY one does, so its coverage is symmetric too); no store path
changes membership_mode after create (the leg-3 immutability
invariant, asserted rather than assumed absent); duplicate name under
one agent → ErrConflict; converted-DM attach by each of its two
owners; a channel is a leaf, so no cycle is possible (test documents
it).
T3 — store: the participant probe (lane: compass-server)
Section titled “T3 — store: the participant probe (lane: compass-server)”Add ChannelParticipant — the explicit arm OR the tree arm of Approach
leg 3 — BESIDE the existing ChannelMemberExists
(channels.sql:36-37), and rebind the wrappers requireChannelMember /
isChannelMember (go/internal/store/authz.go:23 and :51) onto it.
ChannelMemberExists SURVIVES: two callers genuinely mean
stored-member-row semantics, not participation, and must not inherit the
derived arm (Approach leg 3). Give TopicChannelMemberExists
(authz.sql:8-10) the same tree arm with the channel resolved through
topics.channel_id.
Wrapper signatures and the not-found/forbidden merge (authz.go:31-35)
are unchanged, so every wrapper caller inherits derived membership
without an edit: AppendMessage (messages.go:59),
UpdateChannelMembers (channels.go:387), SetChannelPolicy
(channels.go:704), requireBoardMutator
(channel_pins.go:179-180), IsChannelMember (authz.go:45),
IsTopicChannelMember (authz.go:70), and ListTopics
(go/internal/store/topics.go:23, which calls the unexported
isChannelMember directly, not the exported stream filter).
Interfaces:
- Consumes:
channels.membership_mode,channels.parent_agent_id,agent_accounts.parent_agent_id, and thechannels_tree_mode_needs_agentCHECK that makes a mode-1 row’s anchor non-NULL — without it the owner-arm scalar subquery compares against NULL for every mode-1 row (all T2). - Produces: sqlc queries
ChannelParticipant :oneandTopicChannelParticipant :one(additive —ChannelMemberExistsandTopicChannelMemberExistsboth stay generated); regenerateddbpackage;requireChannelMember(ctx context.Context, q db.DBTX, actor AccountID, channelID ChannelID) errorunchanged.
Test cycle: pgtest — subtree agent posts into an ancestor-anchored TREE
channel; a subtree agent calls ListTopics on an ancestor-anchored TREE
channel and gets its topics rather than ErrNotFound (the ListTopics
acceptance case); an agent outside the subtree and a foreign user →
ErrNotFound; the anchor’s owner posts; after ReparentAgent moves an
agent out of the subtree its next post → ErrNotFound with
channel_members row count asserted unchanged (no reconcile ran);
hasGenuineAdd still reports a genuine add for an account that is a
DERIVED participant but has no member row (the member-row semantics it
needs, proving the probe swap did not leak into it); the full existing
suite stays green (EXPLICIT behaviour identical).
T4 — store: visibility predicates + read paths (lane: compass-server)
Section titled “T4 — store: visibility predicates + read paths (lane: compass-server)”-
Extend all three channel predicate copies (
ListChannels,ChannelVisibleTo,ChannelsByNameForViewer;channels.sql:129,:159,:188) with theviewerCTE and the owner-set disjunct of Approach leg 2, textually identical perchannels.sql:10-12. -
The two new columns are carried from the row to the wire. T2 adds
channels.parent_agent_idandchannels.membership_mode, and T1 adds the wire fieldsChannel.parent_agent_id = 11/membership_mode = 12, but nothing between them moves either value: the shared channel projection is a fixed seven-column SELECT list, andchannelFromRow’s own doc comment names that shape — “the shared seven-column channel projection every channel read selects” (go/internal/store/channels.go:863). This task owns the carry rather than T2, because T2 owns the schema and the write paths while the carry is entirely a READ-path edit in thedbpackage this task already regenerates, on queries it mostly already rewrites. Note the two triples are NOT the same three queries, and both are complete: the PREDICATE copies of the bullet above areListChannels,ChannelVisibleToandChannelsByNameForViewer, while the PROJECTION copies areGetChannel,ListChannelsandChannelsByNameForViewer. They overlap in two.GetChannel(channels.sql:68-71) carries no visibility predicate and so is untouched by that bullet, andChannelVisibleToprojects a bareSELECT EXISTS (…)with no column list at all (channels.sql:173-186) and so is untouched by this one. Five hops, all five required. (i) The projection gains both columns in all three PROJECTION copies —GetChannel(channels.sql:69),ListChannels(:143) andChannelsByNameForViewer(:202), each of which today readsSELECT id, name, COALESCE(group_id, '') AS group_id, kind, post_policy,overCOALESCE(owner_account_id, '') AS owner_account_id, mandatory_subscription.parent_agent_idis nullable, so it projects asCOALESCE(parent_agent_id, '') AS parent_agent_id, the shape the list already uses for both of its other nullable columns;membership_modeisNOT NULLand projects bare. The identical-copies rule (channels.sql:10-12) already binds the three to change in one commit. (ii)channelFromRow(channels.go:865) takes the two new values, and every call site passes them:ListChannels(channels.go:294),ChannelByNameForViewer(:346),getChannel(:828) andscanChannels(:851, whose manualrows.Scanat:848grows the two scan destinations as well). Those four were enumerated bygit grep -nE 'channelFromRow'overgo/, not by recall, and its doc comment’s “seven-column” wording (:863) becomes nine. (iii)store.Channel(go/internal/store/types.go:204-225) gainsParentAgentID AccountID— empty = tree root, the same empty-means-root encodingAgentAccount.ParentAgentIDalready uses (“the agent’s parent in the agent tree; empty = root”,types.go:181-183) — andMembershipMode ChannelMembershipMode, the domain type T2 produces. (iv)channelToWire(go/internal/comms/mapping.go:67-79) maps both, beside the nine fields it sets today. (v)CreateChannel’s returned literal (channels.go:206-213) — becausechannelFromRow’s call sites are NOT the same set as the places aChannelis BUILT, and hops (i) through (iv) reach only the read paths.CreateChannelhand-writes its return value after commit rather than re-reading the row:return Channel{ID: ChannelID(id), Name: c.Name, GroupID: c.GroupID, Kind: c.Kind, MemberAccountIDs: members, Policy: c.Policy}, nil. It never callschannelFromRow, so every field it does not name stays at the zero value however correctly the four hops above are implemented. The fix is structural, not another assignment:CreateChannelreturnss.getChannel(ctx, ChannelID(id))after the commit, which is exactly what its three sibling writers already do —UpdateChannelMembers(channels.go:471),SetChannelPolicy(:803) and, at the comms layer,UpdatePinnedBoard(go/internal/comms/comms.go:625, whose own comment states the reason: “Re-read the channel so ChannelChanged and the response carry the current member/policy projection alongside the updated board”). That collapses the construction sites to ONE, so no later column addition can miss this path again, and it is the same one-line pattern rather than a new one. It also makes the create response’sMemberAccountIDsliterally the projection a laterListChannelsreports, which is the invariant leg 3 already claims (“a TREECreateChanneland a laterListChannelsreport the same member list for the same channel”, :274-275) and which the literal today only approximates by reconstructing it — leg 3’s citation ofchannels.go:211for that carry-back becomes the re-read, and the expansion it describes is still run, for the authz. The re-read is a pool read after commit, the postureGetChannel’s doc comment already states for the sibling path (“It is a pool read (post-commit), not a tx read”,channels.go:807-808). The re-read is fallible where the literal was not: a failure betweentx.Commit(channels.go:202) and the read — a pool blip, or a client-cancelled ctx — returns an error for a channel that IS durably created, and becauseCreateChannelis not idempotent the natural retry then conflicts (ErrConflict,channels.go:152) on T2’s own newchannels_agent_name_key(:1038-1039). Accepted, not softened, because both store siblings propagate their post-commit re-read error identically (channels.go:471-473,:803) — only the coordination EVENT path is best-effort, and only because there the read is not the return value (“A read failure is logged and skipped, never propagated”,coordination.go:154-155) — and a literal-shaped fallback on the error arm would reintroduce the fifth construction site this hop exists to remove, handing T2 back the shortcut the bullet above forbids. The write is durable and the channel appears on the nextListChannels. The enumeration behind this hop, since the four-call-site list above is what hid it: everyChannelconstruction in the store package was swept withgit grep -nE 'return Channel\{' c7c73135 -- go/internal/store/— 49 hits, of which 47 are zero-valueChannel{}error returns and exactly TWO populate fields,channelFromRow(:866) and this one (:206). The only non-returncomposite literal is[]Channel{channelFromRow(…)}(:828), which wraps hop (ii) and needs nothing of its own. Outside the package there are none:git grep -nE 'store\.Channel\{' c7c73135 -- go/returns nothing (rc=1; positive control in the same run,store.Channelinmapping.go= 18 hits), so everyChannelthat reaches a caller or an event originates at one of those two sites. With hop (v) there are no further bypass sites.Miss hops (i) through (iv) and a TREE channel reaches the client with
parentAgentIdunset, T7’s deriver — which slots channels byparentAgentId— sends every attached channel down its dangling-parent path, and the agent band this record exists to build renders empty. Miss hop (v) and the same thing happens on exactly one path — the one where the user is watching for the channel to appear.Comms.CreateChanneluses the store’s returned value TWICE:CreateChannelResponse.channelischannelToWire(ch)(go/internal/comms/comms.go:247), a wrong value on the wire rather than a missing one; andc.publishChannelChanged(ch, nil)(:246) maps that same literal into aChannelChanged(mapping.go:484-493), which the UI reducer upserts straight into state (case "channelChanged": return { ...state, channels: upsertById(…) },apps/ui/src/live/comms-state.ts:219-220, adapted atapps/ui/src/live/stream.ts:214). So a channel just created WITH an anchor enters the sidebar anchor-less and renders in the root band instead of under its agent, self-healing only on the nextListChannelsor a later event from a path that re-reads. The other fourpublishChannelChangedcallers are unaffected and that is what makes the create path singular:UpdateChannelMembers(:274),SetChannelPolicy(:575) andUpdatePinnedBoard(:625) all re-read throughgetChannel, the coordination reconcile re-reads viac.store.GetChannel(coordination.go:163), anddm.go:75forwards a value its caller already read. This is a DIFFERENT gap from the H3 materialization below: that one fills the two member LISTS, these are two other fields on the same message, and neither fix implies the other. -
Swap the direct member JOINs of the message/topic reads for the participant shape:
GetPageCursorSeq(messages.sql:64),ListMessages(:71),SearchMessages(:81),FindAskMessage(:92),UpdateMessageBlocksAsAuthor(:53),ResolveTopicForUpdate(topics.sql:16). -
The wire’s member lists gain a derived branch (H3).
Channel.member_account_ids(comms.proto:243) andsubscriber_account_ids(:248) are populated from member rows only —loadChannelMembers(go/internal/store/channels.go:883) callsChannelMembersByChannelIDs(:893) and appends intoMemberAccountIDs, mapped to the wire atgo/internal/comms/mapping.go:73-74. A TREE channel therefore ships with BOTH lists empty, and the UI derives the caller’s membership purely from them:deriveMembership(apps/ui/src/live/adapt.ts:172-176) returns"none"when the caller is in neither, andrailChannels(apps/ui/src/comms.ts:58-59) filtersmembership !== "none", so the channel lands in the browse list behind the permanently disabled join button (LeftSidebar.tsx:289-296) and never in the agent tree. Chosen fix: the SERVER materializes the derived participant set intoMemberAccountIDsat read time —loadChannelMembersgains a branch that takes the ANCHOR-TO-SUBTREE DESCENT of Approach leg 3, not the probe’s ascent: the function is keyed by channel and by nothing else (ChannelMembersByChannelIDstakes$1::text[]of channel ids,channels.sql:73-76), so it has no actor to seed an ascent from. The branch is ONE set-based recursive query over the whole mode-1 id set, NOT one per channel: the function’s contract is “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), and a per-channel descent would restore exactly the N+1 that comment designed out. It takes the descent’s ID-SET form (leg 3), which projects(channel_id, account_id)PAIRS rather than bare account ids, because this function attributes every row it consumes back to a channel throughidx := byID[ChannelID(m.ChannelID)](channels.go:895-902) and a union of several anchors’ subtrees projecting bare accounts cannot be attributed. The two channel-keyed delivery queries T5 rewrites use the SINGLE-CHANNEL form instead, each being called with onechannel_id(delivery_reads.sql:13,:22).SubscriberAccountIDsis filled from thechannel_subscriptionsoverride rows INTERSECTED with that derived set, so a stale override left by a reparent-out — which leg 3 keeps deliberately, inert for DELIVERY only — cannot escape onto the wire and makederiveMembershipreturn"subscribed"(it checks subscribers first,adapt.ts:173) for an agent every read gate refuses. The intersection also holds the domain invariant “SubscriberAccountIDs is the subset of members” (go/internal/store/types.go:215-219). Cost: the descent runs on all fourloadChannelMemberscall sites (channels.go:296,:348,:829,:856), so everyListChannelspays it, andmember_account_idsbecomes O(subtree) per TREE channel on the wire where the proto states no size expectation (“The accounts party to the channel”,comms.proto:242-243). Both accepted. The alternative — a per-caller membership field on the wireChannel— is rejected: it is a larger contract change and contradicts the stated model atadapt.ts:161-171(“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”). Materializing keeps every existing consumer of those lists working with no UI contract change. -
Out of scope, stated:
SharesVisibleChannel(presence_reads.sql:17-18) and the visible-accounts arm (accounts.sql:133-134) stay member-row-based (leg 3 degradation note).
Interfaces:
- Consumes: T2’s two columns and its
ChannelMembershipModedomain type; T3’s participant shape. - Produces: updated sqlc queries + regenerated
go/internal/store/db/*.sql.go; the two new columns in all three channel projections, inchannelFromRow(channels.go:865) and its four call sites, asChannel.ParentAgentID/Channel.MembershipMode(types.go:204-225) and inchannelToWire(mapping.go:67-79);CreateChannelreturnings.getChannel(ctx, ChannelID(id))in place of its hand-written literal (channels.go:206-213), sochannelFromRowbecomes the soleChannelconstruction point; a derived branch inloadChannelMembers(channels.go:883); predicate parity test.
Test cycle: pgtest — owner and a same-owner sibling agent see an attached
channel in ListChannels; another user does not; ChannelVisibleTo
agrees with ListChannels on every case (the stream-edge parity the
copies rule protects); a subtree agent reads ListMessages on an
ancestor TREE channel; an owner-set non-member sees the channel row but
reads no history; and — the H3 assertion, the one that catches a server
half regressing — a TREE channel returned by ListChannels carries
every derived participant in MemberAccountIDs despite having zero
channel_members rows, with the row count asserted zero in the same
test so the materialization cannot be mistaken for a seeding
regression; and a reparented-out agent holding a live
channel_subscriptions override appears in NEITHER list (the
intersection).
Three further assertions, all of which FAIL under this record as written before this task’s carry, create-path re-read and set-based projection land, and so are the acceptance for them:
- A channel created with an anchor comes back from
ListChannels— and fromChannelByNameForViewerandGetChannel, the other two projection copies — withParentAgentIDequal to that anchor andMembershipModeequal to the mode it was created with, not the zero value. Nothing before this task puts either column in a SELECT list, so this fails on today’s seven-column projection however correct T1 and T2 are. Covering all three copies is what the identical-copies rule (channels.sql:10-12) asks for: a fix applied to one or two copies still goes red, andGetChannelis the copy behinds.getChannel, the return path forSetChannelPolicy(channels.go:803),UpdateChannelMembers(:471),GetChannel(:815-816), the coordination reconcile’s post-commit event read (coordination.go:163) and — after hop (v) —CreateChannelitself. - The
ChannelthatCreateChannelITSELF returns — not a subsequent read — carries the anchor and the mode. This is the assertion for hop (v) specifically and nothing else in the record catches it: the assertion above goes green the moment the three projections andchannelFromRowland, while aCreateChannelstill returning its hand-written literal (channels.go:206-213) hands backParentAgentIDempty andMembershipModeat zero for a channel just created WITH an anchor. Assert it at the store boundary on the returned value, and — because the same value feeds both the RPC response and the event — at the comms boundary too: aCreateChannelRPC on a TREE channel returns aCreateChannelResponse.channelwithparent_agent_idset, and emits exactly oneChannelChangedwhosechannelcarries the same value (the fan-out T6 owns, asserted here on the payload rather than on the delivery). Both halves fail today, and they fail with a WRONG value on the wire rather than an absent one, which is why the read-path assertion cannot stand in for them. - The materialization runs over TWO TREE channels anchored at DIFFERENT
agents in one
ListChannels, and each channel’sMemberAccountIDsholds exactly its own anchor’s subtree. An id-set descent projecting bareaccount_idmerges the two subtrees and fails this, which makes it the assertion for the attributable(channel_id, account_id)projection specifically, rather than for the descent’s direction.
T5 — store: subscriptions + delivery for TREE channels (lane: compass-server)
Section titled “T5 — store: subscriptions + delivery for TREE channels (lane: compass-server)”UpdateChannelMemberson a TREE channel: add/remove →ErrInvalidArgument; aSubscribedtoggle (MemberUpdate,go/internal/store/inputs.go:69-74) for a derived member upserts achannel_subscriptionsrow (mirror ofUpsertChannelMember,channels.sql:25-28) and seeds the D2 delivery cursor on subscribe (the seed-at-subscribe discipline,channels.go:642); a toggle for an account outside the derived set →ErrNotFound.- The five delivery-side queries change their DRIVING RELATION, not a
WHERE disjunct. Every one of them reads
FROM channel_members cm(delivery_reads.sql:10SubscribedAgents,:18-24ChannelAgentMembers,:41SweepChannels;delivery_cursors.sql:83UndeliveredMessages,:102InSweepSet), so a TREE channel — which has zero member rows — yields the empty set before any predicate runs. Each query’schannel_membersscan is replaced by aparticipantsCTE: the UNION of the stored member rows and the derived participant set, withchannel_subscriptionsLEFT JOINed to supplyCOALESCE(cs.subscribed, FALSE) AS subscribedfor the derived arm — coalesced, not raw, so the arm’s semantics do not depend on the TREE-only refusals that keep the disjunct’s other two terms FALSE (leg 3). The existing subscription disjunct (delivery_reads.sql:14,:45;delivery_cursors.sql:91,:107) then readssubscribedoff that CTE unchanged. - Which of leg 3’s two walks the derived arm takes is per site, set by
the site’s key. The two channel-keyed queries take the
anchor-to-subtree DESCENT —
SubscribedAgents(WHERE cm.channel_id = $1,delivery_reads.sql:13) andChannelAgentMembers(:22) — because their only account parameter is the author to EXCLUDE (cm.account_id <> $2,:15,:23) and an ascent has nothing to seed from. The three account-keyed queries take the actor-to-root ASCENT:SweepChannels(WHERE cm.account_id = $1,:44),UndeliveredMessages(delivery_cursors.sql:90) andInSweepSet(:105-106, which keys on both but is a single-actor probe, so the depth-bounded ascent is the cheaper walk). Getting this backwards on any site yields the wrong question’s answer, not a slow one. Both descent sites take the SINGLE-CHANNEL form of leg 3’s descent — each is invoked with onechannel_idand needs no attribution, so the bare-account_idprojection is right for them. The ID-SET form that projects(channel_id, account_id)pairs belongs toloadChannelMembers(T4) alone, which is the only site handed a whole id set at once. ChannelAgentMembershas no subscription predicate at all — it needs only the union — and it is the site that makes @mentions work:resolveMentioned(go/internal/delivery/dispatch.go:278) reads it for both reserved expansion (@everyone/@agents) and the per-handle membership check, anddispatch.go:272-273documents that “a resolved agent that is not a channel member is also a no-op”. Without this change every mention in a TREE channel is silently dropped.- Cost, stated. This puts a recursive CTE on the delivery fan-out
path, evaluated per post rather than per turn, where today’s shape is
a
channel_membersindex scan on(channel_id, account_id)(0001_init.sql:216-222). The ascent is the actor’s ancestor chain (depth of the agent tree, small); the descent is the anchor’s whole subtree, so the two channel-keyed fan-out sites pay by subtree SIZE, served byagent_accounts_parent_idx(0001_init.sql:119). The samemembership_mode = 1hoist as the probe (leg 3) keeps an EXPLICIT channel out of either recursion, so the EXPLICIT hot path is unchanged. It is nonetheless a real new cost on the hottest write path and is accepted here, not hidden. SetChannelPolicyrefuses amandatory_subscriptionflip on a TREE channel (ErrInvalidArgument), pairing theCreateChannelguard (T2).SetChannelPolicyalso refusesOWNER_ONLYon a TREE channel (ErrInvalidArgument), per the leg-3 decision on the owner-is-member coherence check (channels.go:757-775).
Interfaces:
- Consumes:
channel_subscriptions(T2), participant shape (T3). - Produces:
UpsertChannelSubscription :exec; theparticipantsCTE rewritten into all five delivery queries, each with the walk its key requires; updatedMemberUpdatedoc contract.
Test cycle: pgtest — subscribe toggle on a TREE channel puts the agent in
SubscribedAgents; the same agent appears in SweepChannels,
UndeliveredMessages and InSweepSet for that channel (the four sites
that gate delivery, each asserted, since a WHERE-only change would pass
none of them, and a descent-for-ascent swap would pass the wrong two);
an @handle mention of a subtree agent in a TREE channel
resolves through ChannelAgentMembers and steers that agent, and
@agents expands to the derived agent set — the mention acceptance case,
and the assertion that the channel-keyed descent landed, since no ascent
can produce this row; a derived participant who never toggled a
subscription is absent from SubscribedAgents (the COALESCE default);
a reparent-out makes the same override row inert (no delivery, no
membership) with no cleanup write; add/remove on TREE →
ErrInvalidArgument; mandatory flip on TREE → ErrInvalidArgument;
OWNER_ONLY flip on TREE → ErrInvalidArgument (the update half of the
both-writers refusal T2 asserts at create).
T6 — comms service: RPC edge + events (lane: compass-server)
Section titled “T6 — comms service: RPC edge + events (lane: compass-server)”Wire ReparentChannel into Comms beside ReparentAgent
(go/internal/comms/comms.go:285), resolving new_parent_agent_handle
at the edge exactly as ReparentAgent resolves handles; pass
parent_agent_handle + membership_mode through CreateChannel
(comms.go:228); emit ChannelChanged post-commit. No coordination-hook
change — the hook stays agent-edge-only (Global Constraints).
Interfaces:
- Consumes:
store.ReparentChannel(T2), predicate reads (T4). - Produces:
func (c *Comms) ReparentChannel(ctx context.Context, req *connect.Request[compassv1.ReparentChannelRequest]) (*connect.Response[compassv1.ReparentChannelResponse], error);ChannelChangedfan-out on attach/move.
Test cycle: comms pgtest — reparent emits exactly one ChannelChanged,
delivered only to accounts the predicate admits; a subtree agent’s post
into a TREE channel succeeds end to end; a non-participant post still →
ErrNotFound.
T7 — UI: one sidebar tree (lane: compass-ui)
Section titled “T7 — UI: one sidebar tree (lane: compass-ui)”- Extend
AgentTreeNode(apps/ui/src/stub-data.ts:367-370) withchannels: Channel[]; extendagentTreeitself (stub-data.ts:387) — NOT a wrapping deriver incomms.ts, so the stable-input-order and dangling-parent contracts documented atstub-data.ts:372-386stay stated once where they are enforced — to slot each channel under itsparentAgentIdagent and each home channel under its agent viahome_channel_id. A channel claimed by the agent band (attached or home) is excluded from the shared-spaces and root bands — no double render (leg 5). The agent band admits a TREE channel only because T4 materializes its derived participant set intomember_account_ids(H3); without that server half the band renders nothing, whatever this deriver does. - Replace the
<ChannelsSection /><AgentsSection />pair (apps/ui/src/components/LeftSidebar.tsx:509-510) with one section: agent tree band (channels as child rows underAgentLeafatLeftSidebar.tsx:30,Branchat:94,Nodeat:129), shared-spaces band and root band from ONEchannelSectionscall (apps/ui/src/comms.ts:114-129) passed only the SHARED groups, per the leg-5 mechanism: its per-group sections are the shared-spaces band and its trailinggroup: undefinedsection is the root band, which is where every OWNER-grouped channel now falls via the existing unknown-group arm (comms.ts:124-126). The caller atLeftSidebar.tsx:312-313changes itsgroupsargument;channelSectionsitself does not change. DM band unchanged (LeftSidebar.tsx:358-359; 1:1 agent DM exclusion at:306-307), and the browse band untouched (LeftSidebar.tsx:366-367). - Dead residue: delete the handler-less
title="New folder"button (LeftSidebar.tsx:437); rename.folder,.folder-caret,.folder-caret.collapsed,.folder-badge,.folder-children(apps/ui/src/app.css:270,280,284,301; wrapper used atLeftSidebar.tsx:99) totree-*and update their consumers.
Interfaces:
- Consumes:
Channel.parentAgentId?: stringandChannel.membershipMode?: "explicit" | "tree"added to the UIChannel(apps/ui/src/comms-stub.ts:98-105);AgentAccount.homeChannelId; theagentTreecontract. - Produces:
AgentTreeNode { agent: Agent; channels: Channel[]; children: AgentTreeNode[] }; a single sidebar section component whosechannelSectionsargument is the SHARED-group subset; renamed CSS classes.
Test cycle: bun test on the tree deriver (channel slotting,
home-channel placement and single-render, dangling parentAgentId
promotes to the root band); a case that a TREE channel reaching the
deriver with a non-none membership lands in the agent band rather
than the browse band; a case that an OWNER-grouped channel lands in the
trailing root-band section rather than its own section, which is the
assertion for the SHARED-only groups argument specifically and fails
if the caller keeps passing every group; existing board.test.ts /
adapt.test.ts stay green, and so does comms.test.ts — its three
channelSections cases (comms.test.ts:294-354) are UNCHANGED by
design, including the OWNER-visibility group that still gets its own
section when passed in groups (:321-341), because the behaviour
that moves is this caller’s argument and not the partition. A red
comms.test.ts means the mechanism was implemented in
channelSections instead of at its call site.
apps/ui/src/components/LeftSidebar.test.tsx does NOT stay green, and
is rewritten here BY DESIGN — unlike comms.test.ts, a red one is
expected, not a sign the mechanism landed in the wrong place: its
two-section contract (:91-134 “both sections collapse and expand
independently”, driving findToggle(container, "Channels") and
findToggle(container, "Agent workspaces") and asserting each
collapses independently, plus the "Agent workspaces" toggle assertion
at :203) is what leg 5’s ONE section reverses, and its New-folder pin
(:411-418, “the keep-native new-folder button still carries its
native title (sweep boundary held)”, asserting a button.icon-btn with
title === "New folder" exists) is what Open Question 4’s delete
reverses. LeftSidebar.live.test.tsx is NOT affected: it asserts only
.tree-agent / .tree-empty rows inside the agent band (:103,
:110, :126, :169) and never locates a section header.
T8 — live adapter + fixtures (lane: compass-ui)
Section titled “T8 — live adapter + fixtures (lane: compass-ui)”Lift parentAgentId and membershipMode from the wire Channel in the
live adapter (the same lift pattern the agent arm uses — “agent account
lifts parentAgentId from the agent arm”, apps/ui/src/live/adapt.test.ts:151),
and extend the stub fixtures (apps/ui/src/comms-stub.ts:289-290 groups,
:314-378 channels) so vite dev exercises agent-attached channels in
both modes without a daemon.
Interfaces:
- Consumes: regenerated wire types from T1.
- Produces: adapter mappings
compassv1.Channel.parent_agent_id→Channel.parentAgentIdandmembership_mode→Channel.membershipMode; fixture channels attached to fixture agents.
Test cycle: adapt.test.ts cases asserting both lifts, plus the H3
client-half case: a TREE WireChannel whose member_account_ids
CONTAINS the subtree agent derives "joined", and the same channel with
that agent also in subscriber_account_ids derives "subscribed". That
is the shape T4 materializes — a TREE channel’s lists arrive NON-empty,
which IS the fix — so this case pins the lift, the fixture shape and the
adapter’s pass-through. It deliberately does NOT assert that both-empty
lists derive non-none: under the chosen shape genuinely empty lists
SHOULD derive "none", and deriveMembership (adapt.ts:172-176)
stays unchanged and correct in doing so. Asserting otherwise would
require deriveMembership to consult something beyond the two lists,
which is the per-caller-wire-field alternative leg 5 rejects. This is a
unit test over a hand-built fixture and so cannot observe what the
server produced; the regression risk lives on the server half and is
caught by T4’s pgtest (derived participants in MemberAccountIDs with
the channel_members row count asserted zero). Fixture channels in
both modes exercise the bands in vite dev, but the eyeball is not the
acceptance; the assertions on both sides are.
- T1 (proto owner):
Channel.parent_agent_id = 11,Channel.membership_mode = 12,ChannelMembershipMode,CreateChannelRequest.parent_agent_handle = 5/.membership_mode = 6,rpc ReparentChannel— additive only. - T2 (compass-server): migration (columns, CHECKs including the
membership_mode IN (0, 1)value check, indexes,channel_subscriptionswith RLS +tenant_isolationpolicy + grants + the account-direction index);CreateChannelattach authz, mode, and the OWNER_ONLY-on-TREE refusal (the create half); themembership_modeimmutability test;Store.ReparentChannelwith every leg-3 invariant in the participant-gate-first order. - T3 (compass-server):
ChannelParticipant/TopicChannelParticipantprobes beside the survivingChannelMemberExists— derived arm on every membership gate, includingListTopics. - T4 (compass-server): owner-set disjunct in all three predicate
copies +
parent_agent_id/membership_modecarried through those same three projections,channelFromRowand its four call sites,store.ChannelandchannelToWire, plusCreateChannel’s hand-written return literal replaced by a post-commitgetChannelre-read (the fifth construction site) + participant shape in message/topic reads + the derived DESCENT branch inloadChannelMembers, one set-based query over the whole id set projecting(channel_id, account_id)pairs, withSubscriberAccountIDsintersected against the derived set (H3 wire fix) + parity tests. - T5 (compass-server): TREE subscription overrides, the five
delivery queries’ participant-union driving relation — descent for
the two channel-keyed sites, ascent for the three account-keyed ones,
with
COALESCE(cs.subscribed, FALSE)— including mention routing,UpdateChannelMembersTREE refusals, mandatory guards, the OWNER_ONLY-on-TREE refusal (the update half). - T6 (compass-server):
Comms.ReparentChannel,CreateChannelpassthrough,ChannelChangedfan-out. - T7 (compass-ui): single sidebar tree;
AgentTreeNode.channels; home-channel single-render; the SHARED-onlygroupsargument tochannelSectionsthat yields both root bands; delete theNew folderbutton;folder-*→tree-*rename. - T8 (compass-ui): live-adapter lifts + stub fixtures for both
modes + the
adapt.test.tscase lifting a TREE channel whose materialized lists contain the subtree agent.
Open Questions
Section titled “Open Questions”The two forks the draft carried — derived-versus-stored membership, and the owner-set read grant — are decided by Matt and folded into Approach legs 2 and 3 above. They are settled; do not reopen them here.
Two further items the draft filed as load-bearing questions are decisions,
not forks, and are recorded as such rather than listed below. (a) A SHARED
channel cannot hang on an agent: Approach leg 2 states it, and the T2 CHECK
channels_group_xor_agent makes it structural — an agent-attached channel
has group_id IS NULL, so no SHARED-group arm exists to fire. A question a
CHECK constraint answers is not open. (b) Groups are frozen as machinery
plus shared-spaces-only, with user-facing nesting retired: Approach leg 1
decides it and T7 implements it; CreateChannelGroup/ListChannelGroups
(comms.proto:50,53) stay servable, the UI just offers no affordance. The
residual forks below are what is actually open.
- Load-bearing — does the owner-set read grant extend to OWNER-grouped channels? The ruling says agents under an owner read “all channels from that owner”, but this record applies the new disjunct to agent-attached channels only, where the anchor resolves the owner; an OWNER-grouped channel’s owner is resolvable via its group, so the extension is mechanical but widens read access for existing data. It is deferred to the ACL record, which re-cuts this surface anyway. Assumption designed against: agent-attached only in this record.
- Non-load-bearing — should the later ACL record relax the SHARED-on-an-agent refusal? This record refuses it structurally (the CHECK). Whether the ACL record should permit a shared space to hang in the tree, and under what visibility, is deferred there. Assumption designed against: stays refused.
- Non-load-bearing — mode conversion. Can an EXPLICIT channel become
TREE, or the reverse, after create? v1 refuses, and the refusal is a
stated invariant in Approach leg 3, not a gap here:
membership_modeis immutable after create, guarded and tested (T2), because it is what makes the two TREE policy refusals unbypassable. What is open is only whether a LATER record should add conversion, which needs a member-rows migration story (drop rows on EXPLICIT→TREE? mint the derived set on TREE→EXPLICIT?) that no current need justifies. Assumption designed against: immutable, per the leg-3 invariant. - Non-load-bearing — replacement for the dead
New folderbutton. Delete outright, or replace with a per-agent-row “new channel here” affordance callingCreateChannelwithparent_agent_handle? The plan deletes it in T7 and defers the create affordance to a follow-up record. Assumption designed against: delete. - Non-load-bearing — should existing user-created OWNER groups be auto-migrated? The plan leaves their channels in the root band until manually reparented (no data migration). A one-shot server-side migration mapping a user’s group channels under a chosen agent would need a group→agent mapping no data supplies. Assumption designed against: no auto-migration.