From 03fa72702c75065816ca0c3c2c3509836969e2ee Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 6 Oct 2026 04:58:26 -0400 Subject: [PATCH 1/2] feat(store): TREE subscriptions and delivery over derived participants (RIG-4189) UpdateChannelMembers on a TREE channel refuses add/remove and turns a subscribe toggle into a channel_subscriptions override, seeding the delivery cursor on subscribe. The five delivery membership queries drive from a participants CTE: the subtree descent for the channel-keyed sites and the ancestor ascent for the account-keyed ones, with unsubscribed as the default. SetChannelPolicy refuses mandatory and OWNER_ONLY on TREE. Co-authored-by: Matt Wilkinson --- .../channel_tree_delivery_pgtest_test.go | 150 ++++++++++++++++++ go/internal/store/channels.go | 53 +++++++ go/internal/store/db/channels.sql.go | 28 +++- go/internal/store/db/delivery_cursors.sql.go | 48 +++++- go/internal/store/db/delivery_reads.sql.go | 68 +++++++- go/internal/store/db/querier.go | 6 + go/internal/store/inputs.go | 4 +- go/internal/store/queries/channels.sql | 10 +- .../store/queries/delivery_cursors.sql | 48 +++++- go/internal/store/queries/delivery_reads.sql | 68 +++++++- 10 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 go/internal/store/channel_tree_delivery_pgtest_test.go diff --git a/go/internal/store/channel_tree_delivery_pgtest_test.go b/go/internal/store/channel_tree_delivery_pgtest_test.go new file mode 100644 index 000000000..13065608d --- /dev/null +++ b/go/internal/store/channel_tree_delivery_pgtest_test.go @@ -0,0 +1,150 @@ +//go:build pgtest + +package store + +import ( + "slices" + "testing" +) + +// subscribeTree toggles account's subscription on a TREE channel, acting as +// the account itself (any participant may toggle its own override). +func subscribeTree(t *testing.T, s *Store, account AccountID, channel ChannelID, subscribed bool) { + t.Helper() + u := MemberUpdate{AccountID: account, Subscribed: subscribed, Unsubscribe: !subscribed} + if _, _, err := s.UpdateChannelMembers(t.Context(), account, channel, []MemberUpdate{u}, MemberUpdatesOptions{}); err != nil { + t.Fatalf("UpdateChannelMembers(subscribe %v, %s): %v", subscribed, account, err) + } +} + +// deliverySites reports, for one agent and channel, the answer of each of +// the four sites that gate delivery. +type deliverySites struct { + subscribed, sweep, undelivered, inSweep bool +} + +func readDeliverySites(t *testing.T, s *Store, agent AccountID, channel ChannelID, author AccountID) deliverySites { + t.Helper() + subscribers, err := s.SubscribedAgents(t.Context(), channel, author) + if err != nil { + t.Fatalf("SubscribedAgents: %v", err) + } + sweep, err := s.SweepChannels(t.Context(), agent) + if err != nil { + t.Fatalf("SweepChannels: %v", err) + } + undelivered, err := s.UndeliveredMessages(t.Context(), agent) + if err != nil { + t.Fatalf("UndeliveredMessages: %v", err) + } + inSweep, err := s.InSweepSet(t.Context(), agent, channel) + if err != nil { + t.Fatalf("InSweepSet: %v", err) + } + return deliverySites{ + subscribed: slices.Contains(subscribers, agent), + sweep: slices.Contains(sweep, channel), + undelivered: len(undelivered[channel]) > 0, + inSweep: inSweep, + } +} + +func TestChannelTreeSubscriptionDrivesEveryDeliverySite(t *testing.T) { + s := newTestStore(t) + f := newTreeFixture(t, s) + + if got := readDeliverySites(t, s, f.leaf.ID, f.channel.ID, f.root.ID); got != (deliverySites{}) { + t.Fatalf("unsubscribed derived participant sites = %+v; want none (COALESCE default)", got) + } + + subscribeTree(t, s, f.leaf.ID, f.channel.ID, true) + if _, err := appendAsParticipant(t, s, f.root.ID, f.channel.ID, "after subscribe"); err != nil { + t.Fatalf("AppendMessage by anchor: %v", err) + } + want := deliverySites{subscribed: true, sweep: true, undelivered: true, inSweep: true} + if got := readDeliverySites(t, s, f.leaf.ID, f.channel.ID, f.root.ID); got != want { + t.Fatalf("subscribed derived participant sites = %+v; want %+v", got, want) + } + // The anchor and the middle agent never toggled, so the leaf's override + // must not leak to them. + if got := readDeliverySites(t, s, f.mid.ID, f.channel.ID, f.root.ID); got != (deliverySites{}) { + t.Fatalf("untoggled mid agent sites = %+v; want none", got) + } + + subscribeTree(t, s, f.leaf.ID, f.channel.ID, false) + if got := readDeliverySites(t, s, f.leaf.ID, f.channel.ID, f.root.ID); got != (deliverySites{}) { + t.Fatalf("unsubscribed-again participant sites = %+v; want none", got) + } +} + +func TestChannelTreeReparentOutMakesOverrideInert(t *testing.T) { + s := newTestStore(t) + f := newTreeFixture(t, s) + subscribeTree(t, s, f.leaf.ID, f.channel.ID, true) + + if _, err := s.ReparentAgent(t.Context(), f.owner.ID, f.leaf.ID, f.sibling.ID); err != nil { + t.Fatalf("ReparentAgent(leaf → sibling): %v", err) + } + if _, err := appendAsParticipant(t, s, f.root.ID, f.channel.ID, "after move"); err != nil { + t.Fatalf("AppendMessage by anchor: %v", err) + } + if got := readDeliverySites(t, s, f.leaf.ID, f.channel.ID, f.root.ID); got != (deliverySites{}) { + t.Fatalf("reparented-out agent sites = %+v; want none", got) + } + members, err := s.ChannelAgentMembers(t.Context(), f.channel.ID, f.root.ID) + if err != nil { + t.Fatalf("ChannelAgentMembers: %v", err) + } + if slices.Contains(members, f.leaf.ID) { + t.Fatalf("ChannelAgentMembers = %v; want reparented leaf absent", members) + } + var overrides int + if err := s.scopedPool().QueryRow(t.Context(), + `SELECT count(*) FROM channel_subscriptions WHERE channel_id = $1 AND account_id = $2`, + string(f.channel.ID), string(f.leaf.ID)).Scan(&overrides); err != nil { + t.Fatalf("count overrides: %v", err) + } + if overrides != 1 { + t.Fatalf("override rows after reparent = %d; want the row left in place", overrides) + } +} + +// ChannelAgentMembers backs both @handle resolution and @agents expansion; +// only the anchor-to-subtree descent can produce these rows. +func TestChannelTreeAgentMembersAreTheSubtree(t *testing.T) { + s := newTestStore(t) + f := newTreeFixture(t, s) + + members, err := s.ChannelAgentMembers(t.Context(), f.channel.ID, f.root.ID) + if err != nil { + t.Fatalf("ChannelAgentMembers: %v", err) + } + want := []AccountID{f.mid.ID, f.leaf.ID} + slices.Sort(want) + if !slices.Equal(members, want) { + t.Fatalf("ChannelAgentMembers(author=root) = %v; want subtree %v without author, owner, or sibling", members, want) + } +} + +func TestChannelTreeMembershipWritesRefused(t *testing.T) { + s := newTestStore(t) + f := newTreeFixture(t, s) + + for name, u := range map[string]MemberUpdate{ + "add": {AccountID: f.sibling.ID}, + "remove": {AccountID: f.leaf.ID, Remove: true}, + } { + _, _, err := s.UpdateChannelMembers(t.Context(), f.owner.ID, f.channel.ID, []MemberUpdate{u}, MemberUpdatesOptions{}) + sentinelIs(t, err, ErrInvalidArgument, "TREE "+name) + } + _, _, err := s.UpdateChannelMembers(t.Context(), f.owner.ID, f.channel.ID, + []MemberUpdate{{AccountID: f.sibling.ID, Subscribed: true}}, MemberUpdatesOptions{}) + sentinelIs(t, err, ErrNotFound, "TREE subscribe of a non-participant") + + _, err = s.SetChannelPolicy(t.Context(), f.owner.ID, f.channel.ID, ChannelPolicy{MandatorySubscription: true}) + sentinelIs(t, err, ErrInvalidArgument, "TREE mandatory flip") + _, err = s.SetChannelPolicy(t.Context(), f.owner.ID, f.channel.ID, ChannelPolicy{ + PostPolicy: ChannelPostPolicyOwnerOnly, OwnerAccountID: f.owner.ID, + }) + sentinelIs(t, err, ErrInvalidArgument, "TREE OWNER_ONLY") +} diff --git a/go/internal/store/channels.go b/go/internal/store/channels.go index 33f27dcaf..1154ded70 100644 --- a/go/internal/store/channels.go +++ b/go/internal/store/channels.go @@ -716,6 +716,16 @@ func (s *Store) UpdateChannelMembers(ctx context.Context, actor AccountID, chann } mandatory := lock.MandatorySubscription kind := ChannelKind(lock.Kind) + if ChannelMembershipMode(lock.MembershipMode) == ChannelMembershipModeTree { + if err := updateTreeSubscriptions(ctx, tx, channelID, updates); err != nil { + return Channel{}, nil, err + } + if err := tx.Commit(ctx); err != nil { + return Channel{}, nil, fmt.Errorf("store: commit tree subscriptions: %w", err) + } + ch, err := s.getChannel(ctx, channelID) + return ch, nil, err + } // The unsubscribe guard reads the PRE-convert mandatory state: a DM is // born-mandatory, so an unsubscribe batched with a genuine convert-add is // rejected even though the post-convert channel would permit it. Not @@ -776,6 +786,39 @@ func (s *Store) UpdateChannelMembers(ctx context.Context, actor AccountID, chann return ch, removed, nil } +// updateTreeSubscriptions applies a batch to a TREE channel, whose membership is +// derived: add/remove are refused, and a subscribe toggle writes the +// account's override row. Subscribe seeds the D2 cursor in the same tx, as the +// explicit path does; an account outside the derived set is ErrNotFound. +func updateTreeSubscriptions(ctx context.Context, tx pgx.Tx, channelID ChannelID, updates []MemberUpdate) error { + for _, u := range updates { + if u.AccountID == "" { + return fmt.Errorf("%w: member update missing account id", ErrInvalidArgument) + } + if u.Remove || (!u.Subscribed && !u.Unsubscribe) { + return fmt.Errorf("%w: TREE channel membership follows the agent tree; only subscription may change", ErrInvalidArgument) + } + } + for _, u := range updates { + if err := requireChannelMember(ctx, tx, u.AccountID, channelID); err != nil { + return err + } + if err := db.New(tx).UpsertChannelSubscription(ctx, db.UpsertChannelSubscriptionParams{ + ChannelID: string(channelID), + AccountID: string(u.AccountID), + Subscribed: u.Subscribed, + }); err != nil { + return fmt.Errorf("store: upsert channel subscription: %w", err) + } + if u.Subscribed { + if err := seedDeliveryCursor(ctx, tx, u.AccountID, channelID); err != nil { + return err + } + } + } + return nil +} + // maybeConvertDM applies the R4 DM-to-CHANNEL conversion when a genuine member // ADD targets a kind=DM channel, and returns the channel's kind afterward. A // genuine add on a DM MUST supply opts.ConvertChannelName (else @@ -1020,6 +1063,16 @@ func (s *Store) SetChannelPolicy(ctx context.Context, actor AccountID, channelID } wasMandatory := lock.MandatorySubscription currentOwner := lock.OwnerAccountID + // TREE delivery and the owner coherence check are defined over member rows, + // which a TREE channel never has; see SeedChannelDeliveryCursors. + if ChannelMembershipMode(lock.MembershipMode) == ChannelMembershipModeTree { + if p.MandatorySubscription != wasMandatory { + return Channel{}, fmt.Errorf("%w: mandatory subscription is not supported on a TREE channel", ErrInvalidArgument) + } + if p.PostPolicy == ChannelPostPolicyOwnerOnly { + return Channel{}, fmt.Errorf("%w: OWNER_ONLY is not supported on a TREE channel", ErrInvalidArgument) + } + } // T4 owner-only policy gate. On an ownerless channel any member may set the // first owner/policy. Once an owner exists, only that owner may change diff --git a/go/internal/store/db/channels.sql.go b/go/internal/store/db/channels.sql.go index ccc0e8a0e..93d4b26e7 100644 --- a/go/internal/store/db/channels.sql.go +++ b/go/internal/store/db/channels.sql.go @@ -726,35 +726,37 @@ func (q *Queries) LockChannelForReparent(ctx context.Context, id string) (LockCh } const lockChannelMandatoryKind = `-- name: LockChannelMandatoryKind :one -SELECT mandatory_subscription, kind FROM channels WHERE id = $1 FOR UPDATE +SELECT mandatory_subscription, kind, membership_mode FROM channels WHERE id = $1 FOR UPDATE ` type LockChannelMandatoryKindRow struct { MandatorySubscription bool Kind int16 + MembershipMode int16 } func (q *Queries) LockChannelMandatoryKind(ctx context.Context, id string) (LockChannelMandatoryKindRow, error) { row := q.db.QueryRow(ctx, lockChannelMandatoryKind, id) var i LockChannelMandatoryKindRow - err := row.Scan(&i.MandatorySubscription, &i.Kind) + err := row.Scan(&i.MandatorySubscription, &i.Kind, &i.MembershipMode) return i, err } const lockChannelPolicy = `-- name: LockChannelPolicy :one -SELECT mandatory_subscription, COALESCE(owner_account_id, '') AS owner_account_id +SELECT mandatory_subscription, COALESCE(owner_account_id, '') AS owner_account_id, membership_mode FROM channels WHERE id = $1 FOR UPDATE ` type LockChannelPolicyRow struct { MandatorySubscription bool OwnerAccountID string + MembershipMode int16 } func (q *Queries) LockChannelPolicy(ctx context.Context, id string) (LockChannelPolicyRow, error) { row := q.db.QueryRow(ctx, lockChannelPolicy, id) var i LockChannelPolicyRow - err := row.Scan(&i.MandatorySubscription, &i.OwnerAccountID) + err := row.Scan(&i.MandatorySubscription, &i.OwnerAccountID, &i.MembershipMode) return i, err } @@ -839,3 +841,21 @@ func (q *Queries) UpsertChannelMember(ctx context.Context, arg UpsertChannelMemb _, err := q.db.Exec(ctx, upsertChannelMember, arg.ChannelID, arg.AccountID, arg.Subscribed) return err } + +const upsertChannelSubscription = `-- name: UpsertChannelSubscription :exec +INSERT INTO channel_subscriptions (channel_id, account_id, subscribed) +VALUES ($1, $2, $3) +ON CONFLICT (channel_id, account_id) DO UPDATE SET subscribed = EXCLUDED.subscribed +` + +type UpsertChannelSubscriptionParams struct { + ChannelID string + AccountID string + Subscribed bool +} + +// A TREE channel's per-account subscribe override; it has no member rows. +func (q *Queries) UpsertChannelSubscription(ctx context.Context, arg UpsertChannelSubscriptionParams) error { + _, err := q.db.Exec(ctx, upsertChannelSubscription, arg.ChannelID, arg.AccountID, arg.Subscribed) + return err +} diff --git a/go/internal/store/db/delivery_cursors.sql.go b/go/internal/store/db/delivery_cursors.sql.go index e71f8a08a..944b91081 100644 --- a/go/internal/store/db/delivery_cursors.sql.go +++ b/go/internal/store/db/delivery_cursors.sql.go @@ -63,9 +63,32 @@ func (q *Queries) CountOwedMentions(ctx context.Context) (int64, error) { } const inSweepSet = `-- name: InSweepSet :one +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $1 + AND EXISTS (SELECT 1 FROM channels WHERE id = $2 AND membership_mode = 1) + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.account_id = $1 + UNION ALL + SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM channels c + JOIN chain ch ON ch.account_id = $1 + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = c.id AND cs.account_id = ch.account_id + WHERE c.membership_mode = 1 + AND c.id = $2 + AND c.parent_agent_id IN (SELECT account_id FROM chain) +) SELECT EXISTS( SELECT 1 - FROM channel_members cm + FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN channels ch ON ch.id = cm.channel_id WHERE cm.account_id = $1 @@ -335,8 +358,29 @@ func (q *Queries) SkippableSeqsAbove(ctx context.Context, arg SkippableSeqsAbove } const undeliveredMessages = `-- name: UndeliveredMessages :many +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $1 + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.account_id = $1 + UNION ALL + SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM channels c + JOIN chain ch ON ch.account_id = $1 + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = c.id AND cs.account_id = ch.account_id + WHERE c.membership_mode = 1 + AND c.parent_agent_id IN (SELECT account_id FROM chain) +) SELECT m.id, m.topic_id, t.channel_id, m.author_account_id, (CASE WHEN ah.owner_user_id IS NULL THEN COALESCE(ah.handle, '') WHEN oh.handle IS NULL THEN '' ELSE oh.handle || '/' || ah.handle END)::text AS author_handle, m.at_unix_ms, m.blocks, m.turn_sequence -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN topics t ON t.channel_id = cm.channel_id JOIN messages m ON m.topic_id = t.id diff --git a/go/internal/store/db/delivery_reads.sql.go b/go/internal/store/db/delivery_reads.sql.go index 90417bc8b..f02915c83 100644 --- a/go/internal/store/db/delivery_reads.sql.go +++ b/go/internal/store/db/delivery_reads.sql.go @@ -10,9 +10,27 @@ import ( ) const channelAgentMembers = `-- name: ChannelAgentMembers :many +WITH RECURSIVE subtree AS ( + SELECT c.id AS channel_id, c.parent_agent_id AS account_id + FROM channels c + WHERE c.id = $1 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 +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.channel_id = $1 + UNION ALL + SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM subtree s + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = s.channel_id AND cs.account_id = s.account_id +) SELECT aa.account_id, aa.owner_user_id, COALESCE(oh.handle, '') AS owner_handle, COALESCE(ah.handle, '') AS handle -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id LEFT JOIN account_handles ah ON ah.account_id = aa.account_id AND ah.owner_user_id = aa.owner_user_id AND ah.tenant_id = aa.tenant_id @@ -127,8 +145,27 @@ func (q *Queries) MessageChannel(ctx context.Context, id string) (string, error) const subscribedAgents = `-- name: SubscribedAgents :many + +WITH RECURSIVE subtree AS ( + SELECT c.id AS channel_id, c.parent_agent_id AS account_id + FROM channels c + WHERE c.id = $1 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 +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.channel_id = $1 + UNION ALL + SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM subtree s + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = s.channel_id AND cs.account_id = s.account_id +) SELECT aa.account_id -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN channels ch ON ch.id = cm.channel_id WHERE cm.channel_id = $1 @@ -152,6 +189,10 @@ type SubscribedAgentsParams struct { // The marked reach predicate is one gate shared with delivery_cursors.sql; // sql_parity_test.go fails if the copies drift. +// The five delivery membership sites (SubscribedAgents, ChannelAgentMembers, +// SweepChannels here; UndeliveredMessages, InSweepSet in delivery_cursors.sql) +// drive from a participants CTE: stored member rows UNION the TREE-derived set. +// Channel-keyed sites walk the anchor's subtree; account-keyed ones the chain up. // reach: the author may reach agent aa func (q *Queries) SubscribedAgents(ctx context.Context, arg SubscribedAgentsParams) ([]string, error) { rows, err := q.db.Query(ctx, subscribedAgents, arg.ChannelID, arg.AccountID) @@ -174,8 +215,29 @@ func (q *Queries) SubscribedAgents(ctx context.Context, arg SubscribedAgentsPara } const sweepChannels = `-- name: SweepChannels :many +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $1 + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.account_id = $1 + UNION ALL + SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM channels c + JOIN chain ch ON ch.account_id = $1 + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = c.id AND cs.account_id = ch.account_id + WHERE c.membership_mode = 1 + AND c.parent_agent_id IN (SELECT account_id FROM chain) +) SELECT cm.channel_id -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN channels ch ON ch.id = cm.channel_id WHERE cm.account_id = $1 diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index 81bb07f52..e9eb3ca1c 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -663,6 +663,10 @@ type Querier interface { SubscribeConvertedDMParties(ctx context.Context, channelID string) error // The marked reach predicate is one gate shared with delivery_cursors.sql; // sql_parity_test.go fails if the copies drift. + // The five delivery membership sites (SubscribedAgents, ChannelAgentMembers, + // SweepChannels here; UndeliveredMessages, InSweepSet in delivery_cursors.sql) + // drive from a participants CTE: stored member rows UNION the TREE-derived set. + // Channel-keyed sites walk the anchor's subtree; account-keyed ones the chain up. // reach: the author may reach agent aa SubscribedAgents(ctx context.Context, arg SubscribedAgentsParams) ([]string, error) // Exact-artifact subscribers, plus (on an opened event) the container-scope @@ -705,6 +709,8 @@ type Querier interface { UpdateModelRegistry(ctx context.Context, arg UpdateModelRegistryParams) (int64, error) UpdateTopicLastSeq(ctx context.Context, arg UpdateTopicLastSeqParams) error UpsertChannelMember(ctx context.Context, arg UpsertChannelMemberParams) error + // A TREE channel's per-account subscribe override; it has no member rows. + UpsertChannelSubscription(ctx context.Context, arg UpsertChannelSubscriptionParams) error UpsertExplicitPullRequestLink(ctx context.Context, arg UpsertExplicitPullRequestLinkParams) error UpsertForgeArtifactCursor(ctx context.Context, arg UpsertForgeArtifactCursorParams) error // Issue-domain queries (sqlc adoption T6, RIG-3034). These replace the inline diff --git a/go/internal/store/inputs.go b/go/internal/store/inputs.go index bb8ad3fec..18710d761 100644 --- a/go/internal/store/inputs.go +++ b/go/internal/store/inputs.go @@ -70,7 +70,9 @@ type NewChannel struct { // MemberUpdate is one add/remove/subscribe mutation for UpdateChannelMembers // (RT-1): the single membership-mutation carrier behind the RT-1 RPC. Add and // Remove are mutually exclusive per call; Subscribed flips the per-member -// subscribed flag on an existing or added row. +// subscribed flag on an existing or added row. On a TREE channel only a +// subscribe or explicit unsubscribe of a derived participant is accepted: it +// writes the account's channel_subscriptions override, never a member row. type MemberUpdate struct { AccountID AccountID // Remove removes the member instead of adding/updating it. diff --git a/go/internal/store/queries/channels.sql b/go/internal/store/queries/channels.sql index 68811c27d..c0340581f 100644 --- a/go/internal/store/queries/channels.sql +++ b/go/internal/store/queries/channels.sql @@ -71,6 +71,12 @@ INSERT INTO channel_members (channel_id, account_id, subscribed) VALUES ($1, $2, $3) ON CONFLICT (channel_id, account_id) DO UPDATE SET subscribed = EXCLUDED.subscribed; +-- name: UpsertChannelSubscription :exec +-- A TREE channel's per-account subscribe override; it has no member rows. +INSERT INTO channel_subscriptions (channel_id, account_id, subscribed) +VALUES ($1, $2, $3) +ON CONFLICT (channel_id, account_id) DO UPDATE SET subscribed = EXCLUDED.subscribed; + -- name: DeleteChannelMember :execrows DELETE FROM channel_members WHERE channel_id = $1 AND account_id = $2; @@ -81,7 +87,7 @@ SELECT owner_user_id FROM agent_accounts WHERE account_id = ANY($1::text[]); SELECT EXISTS (SELECT 1 FROM channel_members WHERE channel_id = $1 AND account_id = $2); -- name: LockChannelMandatoryKind :one -SELECT mandatory_subscription, kind FROM channels WHERE id = $1 FOR UPDATE; +SELECT mandatory_subscription, kind, membership_mode FROM channels WHERE id = $1 FOR UPDATE; -- name: ConvertDMChannel :exec UPDATE channels SET kind = $1, name = $2, group_id = NULL, mandatory_subscription = FALSE WHERE id = $3; @@ -103,7 +109,7 @@ SELECT EXISTS ( ); -- name: LockChannelPolicy :one -SELECT mandatory_subscription, COALESCE(owner_account_id, '') AS owner_account_id +SELECT mandatory_subscription, COALESCE(owner_account_id, '') AS owner_account_id, membership_mode FROM channels WHERE id = $1 FOR UPDATE; -- name: UpdateChannelPolicy :exec diff --git a/go/internal/store/queries/delivery_cursors.sql b/go/internal/store/queries/delivery_cursors.sql index 27c663b8a..ec2df0bb5 100644 --- a/go/internal/store/queries/delivery_cursors.sql +++ b/go/internal/store/queries/delivery_cursors.sql @@ -95,8 +95,29 @@ UPDATE agent_delivery_cursors SET acked_seq = $3, above_seqs = $4, acked_at = now() WHERE agent_account_id = $1 AND channel_id = $2; -- name: UndeliveredMessages :many +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $1 + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.account_id = $1 + UNION ALL + SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM channels c + JOIN chain ch ON ch.account_id = $1 + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = c.id AND cs.account_id = ch.account_id + WHERE c.membership_mode = 1 + AND c.parent_agent_id IN (SELECT account_id FROM chain) +) SELECT m.id, m.topic_id, t.channel_id, m.author_account_id, (CASE WHEN ah.owner_user_id IS NULL THEN COALESCE(ah.handle, '') WHEN oh.handle IS NULL THEN '' ELSE oh.handle || '/' || ah.handle END)::text AS author_handle, m.at_unix_ms, m.blocks, m.turn_sequence -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN topics t ON t.channel_id = cm.channel_id JOIN messages m ON m.topic_id = t.id @@ -124,9 +145,32 @@ WHERE cm.account_id = $1 ORDER BY t.channel_id, m.seq ASC; -- name: InSweepSet :one +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $1 + AND EXISTS (SELECT 1 FROM channels WHERE id = $2 AND membership_mode = 1) + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.account_id = $1 + UNION ALL + SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM channels c + JOIN chain ch ON ch.account_id = $1 + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = c.id AND cs.account_id = ch.account_id + WHERE c.membership_mode = 1 + AND c.id = $2 + AND c.parent_agent_id IN (SELECT account_id FROM chain) +) SELECT EXISTS( SELECT 1 - FROM channel_members cm + FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN channels ch ON ch.id = cm.channel_id WHERE cm.account_id = $1 diff --git a/go/internal/store/queries/delivery_reads.sql b/go/internal/store/queries/delivery_reads.sql index 552b3d2a3..0ddc181f9 100644 --- a/go/internal/store/queries/delivery_reads.sql +++ b/go/internal/store/queries/delivery_reads.sql @@ -1,9 +1,32 @@ -- The marked reach predicate is one gate shared with delivery_cursors.sql; -- sql_parity_test.go fails if the copies drift. +-- The five delivery membership sites (SubscribedAgents, ChannelAgentMembers, +-- SweepChannels here; UndeliveredMessages, InSweepSet in delivery_cursors.sql) +-- drive from a participants CTE: stored member rows UNION the TREE-derived set. +-- Channel-keyed sites walk the anchor's subtree; account-keyed ones the chain up. + -- name: SubscribedAgents :many +WITH RECURSIVE subtree AS ( + SELECT c.id AS channel_id, c.parent_agent_id AS account_id + FROM channels c + WHERE c.id = $1 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 +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.channel_id = $1 + UNION ALL + SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM subtree s + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = s.channel_id AND cs.account_id = s.account_id +) SELECT aa.account_id -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN channels ch ON ch.id = cm.channel_id WHERE cm.channel_id = $1 @@ -21,9 +44,27 @@ WHERE cm.channel_id = $1 ORDER BY aa.account_id; -- name: ChannelAgentMembers :many +WITH RECURSIVE subtree AS ( + SELECT c.id AS channel_id, c.parent_agent_id AS account_id + FROM channels c + WHERE c.id = $1 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 +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.channel_id = $1 + UNION ALL + SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM subtree s + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = s.channel_id AND cs.account_id = s.account_id +) SELECT aa.account_id, aa.owner_user_id, COALESCE(oh.handle, '') AS owner_handle, COALESCE(ah.handle, '') AS handle -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id -- Keep handle joins optional so missing handle rows do not hide @everyone members. LEFT JOIN account_handles ah ON ah.account_id = aa.account_id @@ -59,8 +100,29 @@ SELECT t.channel_id FROM messages m JOIN topics t ON t.id = m.topic_id WHERE m.i -- name: TopicChannelNames :one SELECT t.name AS topic_name, c.name AS channel_name FROM topics t JOIN channels c ON c.id = t.channel_id WHERE t.id = $1; -- name: SweepChannels :many +WITH RECURSIVE chain AS ( + SELECT aa.account_id, aa.parent_agent_id + FROM agent_accounts aa + WHERE aa.account_id = $1 + UNION + SELECT a.account_id, a.parent_agent_id + FROM agent_accounts a + JOIN chain ch ON a.account_id = ch.parent_agent_id +), participants AS ( + SELECT cm.channel_id, cm.account_id, cm.subscribed + FROM channel_members cm + WHERE cm.account_id = $1 + UNION ALL + SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed + FROM channels c + JOIN chain ch ON ch.account_id = $1 + LEFT JOIN channel_subscriptions cs + ON cs.channel_id = c.id AND cs.account_id = ch.account_id + WHERE c.membership_mode = 1 + AND c.parent_agent_id IN (SELECT account_id FROM chain) +) SELECT cm.channel_id -FROM channel_members cm +FROM participants cm JOIN agent_accounts aa ON aa.account_id = cm.account_id JOIN channels ch ON ch.id = cm.channel_id WHERE cm.account_id = $1 From 5b7fa1e0c50b35f5e1576134cc701a18a2746ee9 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 6 Oct 2026 05:51:48 -0400 Subject: [PATCH 2/2] fix(store): keep TREE delivery arms disjoint; pin TREE mention routing (RIG-4189) The stored arm of each delivery participants CTE now skips TREE channels. An outsider subscribe toggle names the account, not the channel. A delivery pgtest drives @handle and @agents through the consumer on a TREE channel, and the OWNER_ONLY assertion checks that the TREE guard fired. Co-authored-by: Matt Wilkinson --- .../pre_settle_closure_pgtest_test.go | 2 +- .../delivery/tree_mention_pgtest_test.go | 54 +++++++++++++++++++ .../channel_tree_delivery_pgtest_test.go | 17 ++++-- go/internal/store/channels.go | 7 ++- go/internal/store/db/delivery_cursors.sql.go | 2 + go/internal/store/db/delivery_reads.sql.go | 4 ++ go/internal/store/db/querier.go | 1 + go/internal/store/delivery_reads.go | 8 ++- .../store/queries/delivery_cursors.sql | 2 + go/internal/store/queries/delivery_reads.sql | 4 ++ 10 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 go/internal/delivery/tree_mention_pgtest_test.go diff --git a/go/internal/delivery/pre_settle_closure_pgtest_test.go b/go/internal/delivery/pre_settle_closure_pgtest_test.go index c7ced6d1e..0563f2f78 100644 --- a/go/internal/delivery/pre_settle_closure_pgtest_test.go +++ b/go/internal/delivery/pre_settle_closure_pgtest_test.go @@ -119,7 +119,7 @@ func owedTotal(t *testing.T, ctx context.Context, s *store.Store, agent store.Ac // deadline — the durable barrier for the crash/agent-authored legs, where the // owed row (not a dispatch) is the recovery scan's observable effect. Polls the // real store, never a sleep-as-synchronization. -func waitOwed(t *testing.T, ctx context.Context, s *store.Store, agent store.AccountID, want int) { //nolint:unparam // read-clarity signature: want states the expected owed-row count at each call site; currently constant, not dead code. +func waitOwed(t *testing.T, ctx context.Context, s *store.Store, agent store.AccountID, want int) { t.Helper() deadline := time.After(testTimeout) tick := time.NewTicker(time.Millisecond) diff --git a/go/internal/delivery/tree_mention_pgtest_test.go b/go/internal/delivery/tree_mention_pgtest_test.go new file mode 100644 index 000000000..3a8fc8151 --- /dev/null +++ b/go/internal/delivery/tree_mention_pgtest_test.go @@ -0,0 +1,54 @@ +//go:build pgtest && unix + +package delivery + +import ( + "context" + "testing" + + "github.com/RigelBuild/compass/go/internal/store" +) + +// A TREE channel has no member rows, so mention routing only works if +// ChannelAgentMembers returns the anchor's subtree. The owed row is the durable +// effect of a mention routed to an offline agent. +func TestTreeChannelMentionsRouteToDerivedAgents(t *testing.T) { + ctx := context.Background() + s := openDeliveryStore(t) + owner := mustOwner(t, ctx, s) + root := mustAgentAcct(t, ctx, s, owner.ID, "root") + mid, err := s.CreateAgent(ctx, owner.ID, store.NewAgent{Handle: "mid", DisplayName: "mid", ParentAgentID: root.ID}) + if err != nil { + t.Fatalf("CreateAgent(mid): %v", err) + } + leaf, err := s.CreateAgent(ctx, owner.ID, store.NewAgent{Handle: "leaf", DisplayName: "leaf", ParentAgentID: mid.ID}) + if err != nil { + t.Fatalf("CreateAgent(leaf): %v", err) + } + outsider := mustAgentAcct(t, ctx, s, owner.ID, "outsider") + ch, err := s.CreateChannel(ctx, owner.ID, store.NewChannel{ + Name: "tree-room", Kind: store.ChannelKindChannel, + ParentAgentID: root.ID, MembershipMode: store.ChannelMembershipModeTree, + }) + if err != nil { + t.Fatalf("CreateChannel(TREE): %v", err) + } + + handleMention := postThroughStore(t, ctx, s, ch.ID, root.ID, "@leaf please look") + agentsMention := postThroughStore(t, ctx, s, ch.ID, root.ID, "@agents standup") + + c, _, _ := newPgConsumer(t, s) + startConsumer(t, c) + waitMarked(t, ctx, s, string(handleMention.ID)) + waitMarked(t, ctx, s, string(agentsMention.ID)) + + // leaf: its @handle plus @agents; mid: @agents only; the author and an + // agent outside the subtree get nothing. + waitOwed(t, ctx, s, leaf.ID, 2) + waitOwed(t, ctx, s, mid.ID, 1) + for _, a := range []store.Account{root, outsider} { + if n := owedTotal(t, ctx, s, a.ID); n != 0 { + t.Fatalf("owed mentions for %s = %d; want 0", a.Handle, n) + } + } +} diff --git a/go/internal/store/channel_tree_delivery_pgtest_test.go b/go/internal/store/channel_tree_delivery_pgtest_test.go index 13065608d..6898ed8be 100644 --- a/go/internal/store/channel_tree_delivery_pgtest_test.go +++ b/go/internal/store/channel_tree_delivery_pgtest_test.go @@ -4,6 +4,7 @@ package store import ( "slices" + "strings" "testing" ) @@ -95,7 +96,7 @@ func TestChannelTreeReparentOutMakesOverrideInert(t *testing.T) { if err != nil { t.Fatalf("ChannelAgentMembers: %v", err) } - if slices.Contains(members, f.leaf.ID) { + if slices.ContainsFunc(members, func(member ChannelAgentMember) bool { return member.ID == f.leaf.ID }) { t.Fatalf("ChannelAgentMembers = %v; want reparented leaf absent", members) } var overrides int @@ -121,8 +122,13 @@ func TestChannelTreeAgentMembersAreTheSubtree(t *testing.T) { } want := []AccountID{f.mid.ID, f.leaf.ID} slices.Sort(want) - if !slices.Equal(members, want) { - t.Fatalf("ChannelAgentMembers(author=root) = %v; want subtree %v without author, owner, or sibling", members, want) + got := make([]AccountID, 0, len(members)) + for _, member := range members { + got = append(got, member.ID) + } + slices.Sort(got) + if !slices.Equal(got, want) { + t.Fatalf("ChannelAgentMembers(author=root) = %v; want subtree %v without author, owner, or sibling", got, want) } } @@ -147,4 +153,9 @@ func TestChannelTreeMembershipWritesRefused(t *testing.T) { PostPolicy: ChannelPostPolicyOwnerOnly, OwnerAccountID: f.owner.ID, }) sentinelIs(t, err, ErrInvalidArgument, "TREE OWNER_ONLY") + // The owner-is-member check also refuses here, since TREE has no member + // rows; the message pins that the TREE guard is the one that fired. + if !strings.Contains(err.Error(), "TREE") { + t.Fatalf("TREE OWNER_ONLY refused by %v; want the TREE guard", err) + } } diff --git a/go/internal/store/channels.go b/go/internal/store/channels.go index 1154ded70..b3ab6c636 100644 --- a/go/internal/store/channels.go +++ b/go/internal/store/channels.go @@ -688,6 +688,7 @@ func (s *Store) ChannelByNameForViewer(ctx context.Context, viewer AccountID, na // Returns the channel with its updated member set, plus the accounts a removal // actually deleted (a remove of a non-member deletes nothing and owes no event) // so the stream can deliver each departed member its one final ChannelChanged. +// On a TREE channel only subscription changes are accepted (updateTreeSubscriptions). // D9 write-authz is enforced here in the store: the actor must be a member of // the channel to mutate it, so an unknown channel and a non-member both return // ErrNotFound (the not-found/forbidden merge). @@ -800,9 +801,13 @@ func updateTreeSubscriptions(ctx context.Context, tx pgx.Tx, channelID ChannelID } } for _, u := range updates { - if err := requireChannelMember(ctx, tx, u.AccountID, channelID); err != nil { + participant, err := isChannelMember(ctx, tx, u.AccountID, channelID) + if err != nil { return err } + if !participant { + return fmt.Errorf("%w: account %q is not a participant of channel %q", ErrNotFound, u.AccountID, channelID) + } if err := db.New(tx).UpsertChannelSubscription(ctx, db.UpsertChannelSubscriptionParams{ ChannelID: string(channelID), AccountID: string(u.AccountID), diff --git a/go/internal/store/db/delivery_cursors.sql.go b/go/internal/store/db/delivery_cursors.sql.go index 944b91081..a962a7acf 100644 --- a/go/internal/store/db/delivery_cursors.sql.go +++ b/go/internal/store/db/delivery_cursors.sql.go @@ -75,6 +75,7 @@ WITH RECURSIVE chain AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.account_id = $1 UNION ALL SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed @@ -369,6 +370,7 @@ WITH RECURSIVE chain AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.account_id = $1 UNION ALL SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed diff --git a/go/internal/store/db/delivery_reads.sql.go b/go/internal/store/db/delivery_reads.sql.go index f02915c83..d3f7db652 100644 --- a/go/internal/store/db/delivery_reads.sql.go +++ b/go/internal/store/db/delivery_reads.sql.go @@ -21,6 +21,7 @@ WITH RECURSIVE subtree AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.channel_id = $1 UNION ALL SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed @@ -157,6 +158,7 @@ WITH RECURSIVE subtree AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.channel_id = $1 UNION ALL SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed @@ -193,6 +195,7 @@ type SubscribedAgentsParams struct { // SweepChannels here; UndeliveredMessages, InSweepSet in delivery_cursors.sql) // drive from a participants CTE: stored member rows UNION the TREE-derived set. // Channel-keyed sites walk the anchor's subtree; account-keyed ones the chain up. +// Copies of each walk MUST stay identical; the stored arm skips TREE channels. // reach: the author may reach agent aa func (q *Queries) SubscribedAgents(ctx context.Context, arg SubscribedAgentsParams) ([]string, error) { rows, err := q.db.Query(ctx, subscribedAgents, arg.ChannelID, arg.AccountID) @@ -226,6 +229,7 @@ WITH RECURSIVE chain AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.account_id = $1 UNION ALL SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index e9eb3ca1c..6b1a98123 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -667,6 +667,7 @@ type Querier interface { // SweepChannels here; UndeliveredMessages, InSweepSet in delivery_cursors.sql) // drive from a participants CTE: stored member rows UNION the TREE-derived set. // Channel-keyed sites walk the anchor's subtree; account-keyed ones the chain up. + // Copies of each walk MUST stay identical; the stored arm skips TREE channels. // reach: the author may reach agent aa SubscribedAgents(ctx context.Context, arg SubscribedAgentsParams) ([]string, error) // Exact-artifact subscribers, plus (on an opened event) the container-scope diff --git a/go/internal/store/delivery_reads.go b/go/internal/store/delivery_reads.go index 434f60ffa..5725146ec 100644 --- a/go/internal/store/delivery_reads.go +++ b/go/internal/store/delivery_reads.go @@ -14,6 +14,8 @@ import ( // SubscribedAgents resolves subscribed agent members the author may reach, // author excluded. Home and mandatory channels still bypass the stored flag. +// A TREE channel's participants are derived; their flag is the +// channel_subscriptions override, default FALSE. func (s *Store) SubscribedAgents(ctx context.Context, channel ChannelID, author AccountID) ([]AccountID, error) { rows, err := s.q.SubscribedAgents(ctx, db.SubscribedAgentsParams{ ChannelID: string(channel), @@ -26,7 +28,8 @@ func (s *Store) SubscribedAgents(ctx context.Context, channel ChannelID, author } // ChannelAgentMembers resolves every reachable agent member, author excluded, -// regardless of subscribe state; out-of-reach members are absent. +// regardless of subscribe state; out-of-reach members are absent. On a TREE +// channel the members are the anchor's agent subtree. func (s *Store) ChannelAgentMembers(ctx context.Context, channel ChannelID, author AccountID) ([]ChannelAgentMember, error) { rows, err := s.q.ChannelAgentMembers(ctx, db.ChannelAgentMembersParams{ ChannelID: string(channel), @@ -126,7 +129,8 @@ func (s *Store) TopicChannelNames(ctx context.Context, topicID string) (topicNam // disjunct mirrors UndeliveredMessages/SubscribedAgents EXACTLY // (design.md:118-120, :127-128, :343, :708) so the pin sweep's channel set and // the cursor sweep's cannot drift. $1 is always an agent, so the JOIN to -// agent_accounts matches exactly one row and yields its home_channel_id. +// agent_accounts matches exactly one row and yields its home_channel_id. TREE +// channels anchored on the agent's ancestor chain join through their override. func (s *Store) SweepChannels(ctx context.Context, agent AccountID) ([]ChannelID, error) { rows, err := s.q.SweepChannels(ctx, string(agent)) if err != nil { diff --git a/go/internal/store/queries/delivery_cursors.sql b/go/internal/store/queries/delivery_cursors.sql index ec2df0bb5..4ab532307 100644 --- a/go/internal/store/queries/delivery_cursors.sql +++ b/go/internal/store/queries/delivery_cursors.sql @@ -106,6 +106,7 @@ WITH RECURSIVE chain AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.account_id = $1 UNION ALL SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed @@ -157,6 +158,7 @@ WITH RECURSIVE chain AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.account_id = $1 UNION ALL SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed diff --git a/go/internal/store/queries/delivery_reads.sql b/go/internal/store/queries/delivery_reads.sql index 0ddc181f9..092371a10 100644 --- a/go/internal/store/queries/delivery_reads.sql +++ b/go/internal/store/queries/delivery_reads.sql @@ -5,6 +5,7 @@ -- SweepChannels here; UndeliveredMessages, InSweepSet in delivery_cursors.sql) -- drive from a participants CTE: stored member rows UNION the TREE-derived set. -- Channel-keyed sites walk the anchor's subtree; account-keyed ones the chain up. +-- Copies of each walk MUST stay identical; the stored arm skips TREE channels. -- name: SubscribedAgents :many WITH RECURSIVE subtree AS ( @@ -18,6 +19,7 @@ WITH RECURSIVE subtree AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.channel_id = $1 UNION ALL SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed @@ -55,6 +57,7 @@ WITH RECURSIVE subtree AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.channel_id = $1 UNION ALL SELECT s.channel_id, s.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed @@ -111,6 +114,7 @@ WITH RECURSIVE chain AS ( ), participants AS ( SELECT cm.channel_id, cm.account_id, cm.subscribed FROM channel_members cm + JOIN channels c ON c.id = cm.channel_id AND c.membership_mode = 0 WHERE cm.account_id = $1 UNION ALL SELECT c.id AS channel_id, ch.account_id, COALESCE(cs.subscribed, FALSE) AS subscribed