Compass channel topic policy
Tracking: RIG-5951 (owner lane compass-server).
Ruling: Matt, 2026-10-19, Option A. routing is automation-only; the
supervisor reads it, triages, and dispatches. DMs default to AGENTS_ONLY;
humans reply inside existing threads. DL-631 and DL-632 record this for
agent–agent DMs and for topic starts in routing. DMs with a user party
are Open Question 2. Matt also said routing is automation-only for
posting; Open Question 1 covers the conflict that creates.
Related: RIG-5950 (compass#2492) makes the UI send create_topic when it
starts a topic. RIG-5958 (compass#2499) makes InsertTopicIgnore report
whether it created a row.
Open Questions
Section titled “Open Questions”Questions 1 and 2 are load-bearing. The four are independent. T1–T4 are written against the recommendations; T5 runs only if Question 1 is A.
- What does “automation-only for posting” mean in
routing? It cannot be literal. The supervisor is an agent, and it must post in the session topic:Dispatcher.OnCommsEvent(go/internal/linearagent/dispatcher.go) “emits oneresponseon the Manager’s first post in an armed session topic”, and on the routing fallback that Manager is the supervisor. With no supervisor post, every cold delegation stays “Thinking” in Linear. Matt is a routing member (EnsureLinearRoutingChanneladds the admin), so topic policy alone still lets him post replies.- A. A new post-policy value
AGENTS_AND_AUTOMATION: agents and system accounts post, users do not. Server-enforced. The supervisor and the bridge still post. The post gate reuses the author-kind read T2 already adds. Cost: one enum value, a wider CHECK, one gate branch, and caller kind in the UIcanPost(T5). - B. Topic policy only.
routingstays OPEN; the UI hides the composer from users there. Human replies stay possible on the server. This is a UI-only gate, which Matt rejected for topic starts (Option C). - C. Literal automation-only posting. The supervisor’s triage reply goes
somewhere other than the routing topic (Linear directly, or the owning
Manager’s channel), and
OnCommsEventchanges to match. This changes the DL-255 flow and is the largest change. - Recommendation: A. It is the closest server-enforced reading: only automation starts work, only agents and automation post, and the supervisor reply that Linear needs still lands.
- A. A new post-policy value
- Does AGENTS_ONLY cover a DM where a user is a party?
OpenDM(go/internal/comms/comms.go) accepts a user caller, andTestOpenOwnerAgentDMSurvivesOwnerHandleRenamepins “owner-agent DMs when the user is an actual party”. Under a flat AGENTS_ONLY default, a user who opens a DM to task an agent cannot post the first message. The backfill would lock every existing user↔agent DM the same way.- A. Set the birth value from the parties: a DM between two agents is AGENTS_ONLY; a DM with a user party is ANY_MEMBER. It is still a stored field set once at birth, not a kind rule at the gate.
- B. AGENTS_ONLY on every DM. A user-opened DM stays empty until the agent posts; the UI hint must say so.
- Recommendation: A. “Humans reply inside existing threads” fits agent queues. A DM the user opened is the user’s own conversation.
- May a member change the topic policy of a DM or of
routing?- A. No.
SetChannelPolicyrefuses with INVALID_ARGUMENT, as it does for “OWNER_ONLY is not supported on a TREE channel”; setting the current value is a no-op. This is a kind rule at the mutation gate, the kind of rule Option B rejected. - B. Yes, through the normal
SetChannelPolicygate. Agents have no policy tool, so only a human member can do it. The routing seed putsroutingback when it runs; a DM keeps the change. - Recommendation: B. No kind rule, no extra code.
- A. No.
- What topic policy does a converted DM get? A member add converts a DM
to a named CHANNEL (DL-296).
ConvertDMChannelalready resetsmandatory_subscription.- A. Reset to ANY_MEMBER. Consequence: any member, including the pulled-in
human owner, can convert a DM by adding a third party, and get a channel
where humans start topics. A fresh
open_dmthen mints a new DM with the birth value. - B. Keep the DM’s value.
- Recommendation: A. A converted DM is a normal channel.
- A. Reset to ANY_MEMBER. Consequence: any member, including the pulled-in
human owner, can convert a DM by adding a third party, and get a channel
where humans start topics. A fresh
Problem / Intent
Section titled “Problem / Intent”Any channel member can start a topic today. PostMessage creates the topic
when create_topic is true, and the UI shows “New topic” on every channel.
Matt wants two channels to work like queues: in a DM only agents start
threads, and in routing only automation starts threads. Humans reply in
existing topics. post_policy (OPEN or OWNER_ONLY) controls who may post. It
does not control who may start a topic.
Approach
Section titled “Approach”D1 — Policy field and values
Section titled “D1 — Policy field and values”Add a channel-level field topic_policy next to post_policy. This is the
shape DL-136 chose for post authority: a policy field, never a ChannelKind
rule or a name rule. Matt rejected those (Option B), and a UI-only gate
(Option C).
| Value | Who may start a topic |
|---|---|
| ANY_MEMBER | Every member (today’s behavior) |
| AGENTS_ONLY | Agent accounts and system accounts |
| AUTOMATION_ONLY | System accounts only |
“Automation” means a system account. The server sender (@compass) and the
Linear bridge (@linear) are both system accounts: EnsureSystemAccount and
EnsureLinearBridgeAccount (go/internal/store/accounts.go) both call
ensureSystemSubtypeAccount. A system account cannot get a token:
IssueToken (go/server/service.go) refuses any acc.System != nil. So no
human or agent can act as automation. The limit: AUTOMATION_ONLY admits only
in-process server connectors. A future token-authenticated source (the
routing constant’s comment names “later alert sources”) cannot start a
routing topic unless it is built as a system account.
Scope: the policy controls topic creation only.
- A post that names an existing topic is a reply and is never gated, even
with
create_topic: true. After RIG-5950 the UI sendscreate_topic: truefrom “New topic”, and an existing name resolves to that topic. - Reviving an archived topic is a reply.
UpdateTopicrename and merge are out of scope; their only gate is participation. A human member can rename a bridge topic inrouting. The dispatcher’s miss path then mints a fresh topic as the bridge, which the policy allows.
D2 — Defaults and backfill
Section titled “D2 — Defaults and backfill”- A DM is born with its party-derived value (Open Question 2): AGENTS_ONLY
between two agents, ANY_MEMBER with a user party.
UpsertDMChannelTx(go/internal/store/dm.go) computes it fromDMChannelSpec.Members. - The routing channel is born AUTOMATION_ONLY.
InsertLinearRoutingChannel(queries/linear_routing.sql) writes it.ReassertLinearRoutingShapesets it too and checks it in its driftWHERE. - Every other channel defaults to ANY_MEMBER. This includes home channels:
InsertHomeChannelmintskind = CHANNEL, so the Setup thread (DL-192) and bridge topics in a Manager home channel do not change. - An unset
topic_policyonCreateChannelRequestmeans the server default. - Existing rows: migration
0005backfills agent↔agent DMs to AGENTS_ONLY and the routing channel to AUTOMATION_ONLY. The migration is the guarantee. The reassert is not a boot guarantee:seedRootSupervisor(go/server/serve_seed.go) runs on the runner-ready hook, only when the Linear bridge is configured, and returns beforeEnsureLinearRoutingChannelon the non-root-supervisor and non-empty-tree branches.
D3 — Enforcement at the one topic-mint point
Section titled “D3 — Enforcement at the one topic-mint point”All topic creation goes through resolveTopicForAppend
(go/internal/store/messages.go), the only caller of InsertTopicIgnore.
It serves AppendMessage (the PostMessage path: human RPC,
PostAsAccount, PostAsAccountByName) and GetOrCreateTopic
(go/internal/store/topics.go, the Linear dispatcher). One check covers
every caller.
The check runs only when the insert created a row (the created flag from
RIG-5958). It reads the channel’s topic_policy and the author’s account
kind. A refusal returns ErrPermissionDenied, and the transaction rolls
back, so the topic never commits. Concurrent creates converge by the unique
index as today; only the author whose insert made the row is checked.
AppendMessage keeps its order: membership (requireChannelMember; a
non-member gets NOT_FOUND), then post policy, then topic resolution. A
member refused by topic policy gets PERMISSION_DENIED through edgeError
(go/internal/comms/context.go). This extends DL-383 to topic policy: a
member can already read the channel, so the distinct code leaks nothing.
Error text tells an agent what to do next:
channel "<name>" is agents-only: only an agent or automation can start a topic; reply in an existing topicchannel "<name>" is automation-only: only automation can start a topic; reply in an existing topic
D4 — Who may change it
Section titled “D4 — Who may change it”SetChannelPolicy stays the only post-creation mutation (DL-136). It gains
an optional topic_policy; unset leaves the value unchanged, so a caller that
sets only post_policy cannot reset it. The existing gate applies: the actor
must be a member, and once a channel has an owner only that owner may change
policy. There is no extra refusal for DMs or routing (Open Question 3).
CreateChannel takes an initial value.
D5 — Agent exposure
Section titled “D5 — Agent exposure”No new agent tool and no new CommsCallRequest arm. Agents cannot set the
policy; the relay has no policy arm. An agent learns the policy from the
refusal text. Two prompt lines teach the rule:
config/prompts/supervisor/SYSTEM.md, “How you communicate”, says “an unknown name creates the topic”. Add: inroutingthe Linear bridge starts every topic; read it, triage, and reply inside the existing topic.config/skills/comms-playbook/SKILL.mdsays “a new topic needscreate_topic: true”. Add: a channel’s topic policy can refuse that; on PERMISSION_DENIED, reply in an existing topic.
Agents may start topics in agent DMs, so the spawn auto-open DM and
comms_dm keep working (DL-296).
D6 — UI gate: a channel field, not a per-caller capability
Section titled “D6 — UI gate: a channel field, not a per-caller capability”The UI decides from Channel.topic_policy and the caller’s account kind
(store.caller(), kind “user”, “agent” or “system”). A per-viewer bit is
possible, since subscribe.go already reshapes ChannelChanged per actor
(trimRemovedToActor). It is not worth it: it needs an account lookup per
subscriber per event, and the server stays the authority either way. The cost
of this choice: canStartTopic copies the server rule in TS, so T4 tests the
same 3 × 3 table as T2.
NewTopic (apps/ui/src/components/ChannelView.tsx) is hidden when the
caller may not start a topic. One hint line renders in its place, for example
“Agents-only channel — reply in an existing topic.” The owner-only path keeps
its disabled-with-hint form.
Prior records
Section titled “Prior records”- Manager comms substrate §A2:
DL-136 (channel-level policy fields,
SetChannelPolicy) and DL-383 (PermissionDenied for a member refused by OWNER_ONLY). - Agent peer-DM:
DL-296 (DM birth shape, convert on add) and DL-293 (
create_topicgates creation everywhere; this record adds who may create). - Zulip threading model: DL-098 (every message is in a named topic).
- Channels in the agent tree: TREE channels take topic policy unchanged.
- Linear agent responder: DL-255 (the routing channel).
Global Constraints
Section titled “Global Constraints”- Wire changes are additive. Follow the comms.proto convention (buf.yaml
exempts it from
ENUM_ZERO_VALUE_SUFFIX): a meaningful zero value, no_UNSPECIFIED. Fields:Channel.topic_policy = 13,CreateChannelRequest.topic_policy = 8(optional),SetChannelPolicyRequest.topic_policy = 5(optional). - Wire and store numbers match: ANY_MEMBER = 0, AGENTS_ONLY = 1, AUTOMATION_ONLY = 2. Column default 0 keeps old rows and old inserts right.
- Never edit migrations 0001–0004; the runner refuses an edited applied
migration. The schema change is
0005_channel_topic_policy.sql. channelshas FORCE row-level security. A backfillUPDATEruns ascompass_system(BYPASSRLS), or it can match zero rows silently.- A member refused by topic policy gets PERMISSION_DENIED; a non-member keeps NOT_FOUND. The text names the channel and says to reply in an existing topic. A reply to an existing topic is never refused.
- Regenerate sqlc and proto output, never hand-edit (
compass-go:sqlc-gen,compass-go:sqlc-drift,compass-proto:gen,compass-proto:drift).
Stack: T1 → T2 → T3 → T4 (→ T5), one PR each. T2 needs RIG-5958’s created
flag; if RIG-5958 has not landed, T2 stacks on it.
T1 — Proto: enum and fields (compass-server)
Section titled “T1 — Proto: enum and fields (compass-server)”Interfaces (produces), in proto/compass/v1/comms.proto:
// Who may start a topic. Replies to an existing topic are never gated.enum ChannelTopicPolicy { CHANNEL_TOPIC_POLICY_ANY_MEMBER = 0; // Agent and system accounts; users reply only. CHANNEL_TOPIC_POLICY_AGENTS_ONLY = 1; // System accounts only (the server sender and in-process connectors). CHANNEL_TOPIC_POLICY_AUTOMATION_ONLY = 2;}
message Channel { ChannelTopicPolicy topic_policy = 13; }// Unset = the server default for the kind and parties.message CreateChannelRequest { optional ChannelTopicPolicy topic_policy = 8; }// Unset = unchanged.message SetChannelPolicyRequest { optional ChannelTopicPolicy topic_policy = 5; }Tests: compass-proto:gen, compass-proto:drift and buf lint stay green.
Generated Go and TS compile (compass-agent:typecheck,
compass-ui:typecheck).
T2 — Store: column, backfill, enforcement (compass-server)
Section titled “T2 — Store: column, backfill, enforcement (compass-server)”Interfaces (produces), in go/internal/store:
type ChannelTopicPolicy int32 // mirrors ChannelPostPolicy (types.go)
const ( ChannelTopicPolicyAnyMember ChannelTopicPolicy = 0 ChannelTopicPolicyAgentsOnly ChannelTopicPolicy = 1 ChannelTopicPolicyAutomationOnly ChannelTopicPolicy = 2)
// Channel gains: TopicPolicy ChannelTopicPolicy// ChannelPolicy gains: TopicPolicy *ChannelTopicPolicy // nil = default (create) / unchanged (set)
// birthTopicPolicy: CHANNEL -> ANY_MEMBER; DM -> AGENTS_ONLY unless a party// is a user account, then ANY_MEMBER (Open Question 2 = A; under B, always// AGENTS_ONLY).func birthTopicPolicy(ctx context.Context, q *db.Queries, kind ChannelKind, parties []AccountID) (ChannelTopicPolicy, error)
// requireTopicMint returns ErrPermissionDenied when author may not start a// topic in channelID. resolveTopicForAppend calls it only when its insert// created the row.func requireTopicMint(ctx context.Context, q *db.Queries, channelID string, author AccountID) errorMigration migrations/0005_channel_topic_policy.sql:
ALTER TABLE channels ADD COLUMN topic_policy SMALLINT NOT NULL DEFAULT 0 CHECK (topic_policy IN (0, 1, 2));
SET LOCAL ROLE compass_system;-- Agent<->agent DMs only (Open Question 2 = A; under B, drop the count).UPDATE channels c SET topic_policy = 1 WHERE c.kind = 1 AND (SELECT count(*) FROM channel_members cm JOIN agent_accounts aa ON aa.account_id = cm.account_id WHERE cm.channel_id = c.id) >= 2;-- Same key as GetLinearRoutingGroup (owner visibility, top level).UPDATE channels c SET topic_policy = 2 FROM channel_groups g WHERE c.group_id = g.id AND g.name = '__linear__' AND g.visibility = 0 AND g.parent_group_id IS NULL AND c.name = 'routing';RESET ROLE;RESET ROLE matters: applyMigration (store.go) writes
schema_migrations in the same transaction after the SQL. CreateChannelGroup
refuses __linear__, so only EnsureLinearRoutingChannel makes that group.
Queries:
- New
GetTopicMintPolicy :one:topic_policy,name, andEXISTSflags for the author inagent_accountsand insystem_accounts. - New
AnyUserAccount :one:EXISTSoveruser_accountsfor an id array. InsertChannelandInsertDMChanneltaketopic_policy;InsertLinearRoutingChannelwrites 2.ReassertLinearRoutingShapesetstopic_policy = 2and addsOR topic_policy <> 2to itsWHERE.UpdateChannelPolicysetstopic_policy = COALESCE(sqlc.narg(topic_policy), topic_policy).ConvertDMChannelsetstopic_policy = 0(Open Question 4).- Every
Channelprojection (GetChannel,ListChannels,ChannelsByNameForViewer) returnstopic_policy;channelFromRowmaps it.
Go: CreateChannel writes *p.TopicPolicy, or birthTopicPolicy with the
actor plus MemberAccountIDs. UpsertDMChannelTx writes birthTopicPolicy
with spec.Members. SetChannelPolicy writes the value when non-nil.
Tests (pgtest):
- Matrix: 3 policies × author {user, agent, system} on a new topic name.
Only the D1 table rows pass. A refused create leaves no
topicsrow. - Healthy subject: on each policy, every member kind replies to an existing
topic with
Create: trueand revives an archived topic. All pass. - A non-member gets
ErrNotFound, notErrPermissionDenied. GetOrCreateTopicas the bridge intoroutingpasses; the supervisor starting a topic there is refused.- Birth: agent↔agent DM reads 1; user↔agent DM reads 0; plain channel 0.
- Migration: apply through 0004, insert both DM shapes, the routing channel
and a plain channel, apply 0005, assert 1, 0, 2, 0. Run as a non-superuser
role so RLS is real.
TestCheckContiguousandTestEmbeddedMigrationsLoadstay green. - Drift: set routing to 0, run
EnsureLinearRoutingChannel, read back 2. SetChannelPolicywith nil keeps the value and with a value sets it. A converted DM reads 0.
T3 — Comms edge and prompts (compass-server)
Section titled “T3 — Comms edge and prompts (compass-server)”Interfaces (consumes T1, T2; produces), in go/internal/comms/mapping.go:
func channelTopicPolicyToWire(p store.ChannelTopicPolicy) compassv1.ChannelTopicPolicy// nil in -> nil out; an unknown value -> InvalidArgument.func channelTopicPolicyFromWire(p *compassv1.ChannelTopicPolicy) (*store.ChannelTopicPolicy, error)channelToWiresetsTopicPolicy.CreateChannelandSetChannelPolicypass the mapped optional through.PostMessageneeds no change;edgeErroralready mapsErrPermissionDeniedto PERMISSION_DENIED.- The D5 prompt lines.
Tests (pgtest): a user starting a topic in an agent↔agent DM gets
PERMISSION_DENIED with the agents-only text, and the same user replying to an
agent’s topic passes. A user opens a DM with an agent and starts a topic
(Open Question 2 = A). SetChannelPolicy round trip, and ChannelChanged
carries the new value. Fixtures that start an agent-DM topic as a user move
to an agent author.
T4 — UI gate (compass-ui)
Section titled “T4 — UI gate (compass-ui)”Interfaces (consumes T1 TS output and RIG-5950), in apps/ui/src:
export type ChannelTopicPolicy = "any_member" | "agents_only" | "automation_only";// Channel gains: topicPolicy: ChannelTopicPolicy
// comms.tsexport function canStartTopic(channel: Channel, caller: Account): boolean;// any_member -> true; agents_only -> caller.kind !== "user";// automation_only -> caller.kind === "system"live/adapt.ts: a totalTOPIC_POLICYmap next toPOST_POLICY.ChannelView.tsxNewTopic: whencanStartTopicis false, render only the hint line (D6). The owner-only path does not change.- Fixtures: every
Channelliteral getstopicPolicy. The stub models a home channel askind: "dm"; give it “any_member” to match the server.
Tests: canStartTopic table (3 × 3, same rows as T2).
ChannelView.policy.test.tsx: a user on an agents-only DM sees no
.new-topic-name and sees the hint; a user on an any-member channel sees the
affordance; the owner-only cases stay green. Adapter map test.
T5 — Routing post policy (only if Open Question 1 = A)
Section titled “T5 — Routing post policy (only if Open Question 1 = A)”Interfaces:
enum ChannelPostPolicy { CHANNEL_POST_POLICY_AGENTS_AND_AUTOMATION = 2; }- Migration
0006: replacechannels_post_policy_checkwithCHECK (post_policy IN (0, 1, 2)). AppendMessage: on value 2, a user author getsErrPermissionDenied(“channel %q takes posts from agents and automation only”).SetChannelPolicy: value 2 needs an empty owner, like OPEN.EnsureLinearRoutingChannelpasses 2 toInsertLinearRoutingChannelandReassertLinearRoutingShape. The bridge and the supervisor still post.- UI:
canPost(channel, caller: Account); value 2 is false for a user.
Tests: the user, agent and system matrix on a post into an existing topic;
the supervisor’s reply in a routing topic fires OnCommsEvent
(TestDispatcherManagerReplyEmitsResponse stays green).
- T1. Proto
ChannelTopicPolicyenum and three fields; regen. (DL-631) - T2. Store column, migration 0005 backfill, birth values, routing
reassert,
requireTopicMintinresolveTopicForAppend,SetChannelPolicywrite. (DL-631, DL-632) - T3. Comms mapping,
CreateChannelandSetChannelPolicyedge, supervisor prompt and comms-playbook lines. (DL-631) - T4. UI
topicPolicy, adapter map,canStartTopic,NewTopicgate. (DL-632) - T5. Routing post policy
AGENTS_AND_AUTOMATION, only if Open Question 1 = A.