diff --git a/go/cmd/compass/message.go b/go/cmd/compass/message.go index a8a617a7..d956479e 100644 --- a/go/cmd/compass/message.go +++ b/go/cmd/compass/message.go @@ -39,8 +39,10 @@ func newMessageCmd() *cobra.Command { // newMessagePostCmd builds `message post --channel --topic [--mention // ]`: post one message into a channel's topic, the body read from stdin. -// --topic is a get-or-create-by-name (an unknown name creates the topic per the -// PostMessage handler at internal/comms/comms.go:353). --mention prepends +// --topic is a get-or-create-by-name: this operator surface is a trusted minter, +// so it sets CreateTopic on the request and an unknown name mints the topic (the +// get-or-create gate is store.resolveTopicForAppend, keyed on +// PostMessageRequest.create_topic). --mention prepends // `@ ` to the body; the server parses @-mentions from the raw text, so // there is no separate mention field on the wire (PostMessageRequest carries // only container/topic/blocks). The body is read from stdin, never a flag or @@ -126,8 +128,9 @@ func runMessagePost(ctx context.Context, client compassv1connect.CommsServiceCli // auto-retry), so the (author, client_request_id) idempotency lane is left // unused deliberately rather than minting a per-invocation key. resp, err := client.PostMessage(ctx, connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: args.channel}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: args.topic}, + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: args.channel}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: args.topic}, + CreateTopic: true, Blocks: []*compassv1.MessageBlock{ {Block: &compassv1.MessageBlock_Text{Text: body}}, }, diff --git a/go/e2e/comms_ops.go b/go/e2e/comms_ops.go index 39526170..97e47d12 100644 --- a/go/e2e/comms_ops.go +++ b/go/e2e/comms_ops.go @@ -14,8 +14,10 @@ import ( // PostMessage posts one text block to a channel's topic over // CommsService.PostMessage and returns the server-assigned message id. It // addresses the channel by id (the PostMessageRequest.container oneof) and the -// topic by name (the topic oneof — get-or-create by name), matching the -// agent-initiated post shape the in-process suite exercises +// topic by name (the topic oneof). It is a trusted internal minter, so it sets +// CreateTopic — the first post to a freshly-minted home channel (no pre-seeded +// topic) mints the topic — matching the agent-initiated post shape the +// in-process suite exercises // (integration_pgtest_test.go:162-163). Returns an error rather than panicking // so the caller (a test) decides fatality; the per-call deadline is threaded // from ctx. @@ -23,9 +25,10 @@ func (f *Fixture) PostMessage(ctx context.Context, channelID, topicName, text st rctx, cancel := context.WithTimeout(ctx, rpcTimeout) defer cancel() resp, err := f.Comms().PostMessage(rctx, connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: topicName}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: text}}}, + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: topicName}, + CreateTopic: true, + Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: text}}}, })) if err != nil { return "", fmt.Errorf("PostMessage RPC: %w", err) diff --git a/go/e2e/legcomms_test.go b/go/e2e/legcomms_test.go index aae6e927..9e4b6035 100644 --- a/go/e2e/legcomms_test.go +++ b/go/e2e/legcomms_test.go @@ -41,14 +41,21 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) { // comms bus and cannot collide with the human trigger post. const postTopic = "e2e-comms-leg" const postBody = "comms leg: posting from the agent loop" + // The poster's handle. Its home channel is minted under the same name + // (store/accounts.go:337 names the home channel for the agent's handle), so + // this const doubles as the channel NAME the scripted post must target. + const posterHandle = "comms-leg-poster" // The comms tool's arguments, serialized JSON (the OpenAI tool-call // contract). Built from the consts above so the asserted values cannot drift // from what the script issues. Field names are the postParameters wire schema - // (comms.ts): text, topic. channel_id is omitted so the post lands on the - // agent's home channel. + // (comms.ts): text, topic, channel, create_topic. As of the peer-DM cutover + // (record R2/R5) post has NO home default — `channel` is REQUIRED and carries + // the target channel NAME (here the poster's own home channel, whose name is + // its handle), and a name-miss topic needs create_topic:true (postTopic names + // no existing topic on the freshly-minted home channel, so it must mint). postArgsJSON := fmt.Sprintf( - `{"text":%q,"topic":%q}`, - postBody, postTopic, + `{"text":%q,"topic":%q,"channel":%q,"create_topic":true}`, + postBody, postTopic, posterHandle, ) // The assistant text the closing turn settles on after the tool result // returns — a clean text settle, mirroring the sibling's settleReply. Unlike @@ -70,7 +77,7 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) { // The poster agent: created, provisioned, and started exactly as the // sibling's spawner. Its turn issues the comms post. - posterID, err := f.CreateAgent(ctx, "comms-leg-poster", "Comms Leg Poster") + posterID, err := f.CreateAgent(ctx, posterHandle, "Comms Leg Poster") if err != nil { t.Fatalf("CreateAgent (poster): %v", err) } @@ -110,9 +117,9 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) { defer tail.Close() // Resolve the poster to get its home channel id (the channel the trigger post - // lands on and the channel the agent's own post — channel_id omitted — fans - // onto). - poster, err := adminAgentByHandle(ctx, st, "comms-leg-poster") + // lands on and the channel the agent's own post — targeting its home channel + // by name — fans onto). + poster, err := adminAgentByHandle(ctx, st, posterHandle) if err != nil { t.Fatalf("AgentByHandle(poster): %v", err) } @@ -160,9 +167,12 @@ func TestCommsPostMessageThroughAgentLoop(t *testing.T) { if got := posted.GetAuthorAccountId(); got != posterID { t.Fatalf("posted message author = %q, want the poster agent's account id %q (the agent posted it, not the human trigger)", got, posterID) } - // The 'omit channel_id => home channel' branch is not separately asserted: - // the returned Message carries no channel container (F9 removed it), and - // GetTopicId() is a server-minted id, not the scripted topic NAME, so there - // is nothing on the wire Message to compare against poster.Agent.HomeChannelID - // or postTopic. Body + author is the correct assertion ceiling here. + // The scripted post names its target channel explicitly (the poster's own + // home channel by name — post has no home default post-cutover) and mints the + // topic via create_topic. Neither the resolved channel nor the topic NAME is + // separately asserted off the wire Message: it carries no channel container + // (F9 removed it), and GetTopicId() is a server-minted id, not the scripted + // topic NAME, so there is nothing on the wire to compare against + // poster.Agent.HomeChannelID or postTopic. Body + author is the correct + // assertion ceiling here. } diff --git a/go/gen/compass/v1/comms.pb.go b/go/gen/compass/v1/comms.pb.go index b3135bae..3a69f8e7 100644 --- a/go/gen/compass/v1/comms.pb.go +++ b/go/gen/compass/v1/comms.pb.go @@ -4030,8 +4030,15 @@ type PostMessageRequest struct { // store of record, D12), returning the already-stored message rather than // posting a duplicate. Optional; empty disables dedup for this call. ClientRequestId string `protobuf:"bytes,5,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Gates get-or-create of `topic_name`: when true, a name that names no + // existing topic in the channel MINTS it; when false (default), a + // name-miss is NOT_FOUND rather than a silent create. Guards accidental + // topic proliferation now that agents must always name their topic + // (peer-DM record DL-293; amends the get-or-create semantics of DL-098). + // Ignored when `topic_id` is set or `topic_name` is unset. + CreateTopic bool `protobuf:"varint,6,opt,name=create_topic,json=createTopic,proto3" json:"create_topic,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PostMessageRequest) Reset() { @@ -4119,6 +4126,13 @@ func (x *PostMessageRequest) GetClientRequestId() string { return "" } +func (x *PostMessageRequest) GetCreateTopic() bool { + if x != nil { + return x.CreateTopic + } + return false +} + type isPostMessageRequest_Container interface { isPostMessageRequest_Container() } @@ -4982,7 +4996,7 @@ const file_compass_v1_comms_proto_rawDesc = "" + "\btopic_id\x18\x05 \x01(\tR\atopicIdB\v\n" + "\tcontainer\"G\n" + "\x14ListMessagesResponse\x12/\n" + - "\bmessages\x18\x01 \x03(\v2\x13.compass.v1.MessageR\bmessages\"\xe7\x01\n" + + "\bmessages\x18\x01 \x03(\v2\x13.compass.v1.MessageR\bmessages\"\x8a\x02\n" + "\x12PostMessageRequest\x12\x1f\n" + "\n" + "channel_id\x18\x01 \x01(\tH\x00R\tchannelId\x120\n" + @@ -4990,7 +5004,8 @@ const file_compass_v1_comms_proto_rawDesc = "" + "\btopic_id\x18\x03 \x01(\tH\x01R\atopicId\x12\x1f\n" + "\n" + "topic_name\x18\x04 \x01(\tH\x01R\ttopicName\x12*\n" + - "\x11client_request_id\x18\x05 \x01(\tR\x0fclientRequestIdB\v\n" + + "\x11client_request_id\x18\x05 \x01(\tR\x0fclientRequestId\x12!\n" + + "\fcreate_topic\x18\x06 \x01(\bR\vcreateTopicB\v\n" + "\tcontainerB\a\n" + "\x05topic\"D\n" + "\x13PostMessageResponse\x12-\n" + diff --git a/go/internal/comms/agent_caller.go b/go/internal/comms/agent_caller.go index 729af22e..1464c527 100644 --- a/go/internal/comms/agent_caller.go +++ b/go/internal/comms/agent_caller.go @@ -176,6 +176,79 @@ func (c *Comms) ListAsAccount( return resp.Msg, nil } +// PostAsAccountByName is the AGENT TOOL entry for comms_post_message / +// comms_post_ask: it resolves the request's channel NAME (carried in the +// channel_id container arm — the TS tool sends a name there per peer-DM R1) to a +// real channel id within account's visible set, then delegates to the id-typed +// PostAsAccount. This is the ONLY caller that treats the container arm as a name; +// PostAsAccount stays id-typed for its internal id-holder co-callers +// (postSetupThread, CommitAgentPost, the offline-mention e2e), which the frozen +// record's "resolve in PostAsAccount" wording predates. +// +// Per R2 there is NO home-channel default at the tool level: an empty channel +// name is NOT filled from home (the agent must NAME its channel, even its own +// home), so an empty name resolves to ErrNotFound like any other miss. An +// unknown or invisible name collapses to CodeNotFound (the D9 merge); an +// ambiguous name is CodeInvalidArgument naming the collision — both surfaced via +// edgeError from ChannelByNameForViewer. +func (c *Comms) PostAsAccountByName( + ctx context.Context, + account store.AccountID, + req *compassv1.PostMessageRequest, +) (*compassv1.PostMessageResponse, error) { + if account == "" { + return nil, errNoActor + } + ch, err := c.store.ChannelByNameForViewer(ctx, account, req.GetChannelId()) + if err != nil { + return nil, edgeError(err) + } + resolved := &compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, + Blocks: req.GetBlocks(), + Topic: req.GetTopic(), + ClientRequestId: req.GetClientRequestId(), + CreateTopic: req.GetCreateTopic(), + } + // The delegated PostAsAccount runs defaultChannel again, but it is a no-op + // here: the channel id is already resolved-non-empty, so defaultChannel's + // empty→home fill never fires. R2 (no home default for post/ask) is enforced + // upstream by ChannelByNameForViewer rejecting an empty name above, NOT by + // this residual pass — a future refactor of PostAsAccount's home default must + // not reintroduce a post/ask home fallback here. + return c.PostAsAccount(ctx, account, resolved) +} + +// ListAsAccountByName is the AGENT TOOL entry for comms_list_messages. It mirrors +// PostAsAccountByName's name resolution but KEEPS omit-=home (a read has no +// misroute hazard, peer-DM R2): an empty channel name defaults to the account's +// home channel, a non-empty name resolves through ChannelByNameForViewer. The +// resolved id-typed request is delegated to the unchanged ListAsAccount. +func (c *Comms) ListAsAccountByName( + ctx context.Context, + account store.AccountID, + req *compassv1.ListMessagesRequest, +) (*compassv1.ListMessagesResponse, error) { + if account == "" { + return nil, errNoActor + } + channelID := req.GetChannelId() + if channelID != "" { + ch, err := c.store.ChannelByNameForViewer(ctx, account, channelID) + if err != nil { + return nil, edgeError(err) + } + channelID = string(ch.ID) + } + resolved := &compassv1.ListMessagesRequest{ + Container: &compassv1.ListMessagesRequest_ChannelId{ChannelId: channelID}, + Limit: req.GetLimit(), + BeforeMessageId: req.GetBeforeMessageId(), + SnapshotSeq: req.GetSnapshotSeq(), + } + return c.ListAsAccount(ctx, account, resolved) +} + // UpdatePinnedBoardAsAccount executes one agent-initiated UpdatePinnedBoard as // account, mirroring PostAsAccount: WithActor + the shared UpdatePinnedBoard // handler path, so the board authz (post_policy), the pure-pointer store ops, @@ -230,8 +303,9 @@ func (c *Comms) CommitAgentPost( // Container unset: routes to the agent's home channel (defaultChannel). // Topic named: the store has no home-topic default, so the frame's // conversation is addressed by topic name. - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: agentConversationTopic}, - Blocks: posted.GetMessage().GetBlocks(), + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: agentConversationTopic}, + CreateTopic: true, + Blocks: posted.GetMessage().GetBlocks(), }) } @@ -373,6 +447,7 @@ func (c *Comms) defaultChannel( Blocks: req.GetBlocks(), Topic: req.GetTopic(), ClientRequestId: req.GetClientRequestId(), + CreateTopic: req.GetCreateTopic(), }, nil } diff --git a/go/internal/comms/agent_caller_byname_pgtest_test.go b/go/internal/comms/agent_caller_byname_pgtest_test.go new file mode 100644 index 00000000..0fa433ac --- /dev/null +++ b/go/internal/comms/agent_caller_byname_pgtest_test.go @@ -0,0 +1,235 @@ +//go:build pgtest && unix + +package comms + +// The agent-TOOL comms entries (peer-DM R1/R2): PostAsAccountByName / +// ListAsAccountByName resolve the request's channel NAME to an id within the +// caller's visible set (ChannelByNameForViewer) and delegate to the id-typed +// PostAsAccount / ListAsAccount. Every test here defends one arm of the +// name-addressing contract the tool edge now enforces: +// +// - a visible name resolves and the post/list lands in that channel; +// - an unknown OR invisible name is CodeNotFound (the D9 merge — the agent +// never learns a channel it cannot see exists); +// - an ambiguous name is CodeInvalidArgument (there is no ErrAmbiguous); +// - post/ask have NO home default: an empty channel name is CodeNotFound, not +// a silent home fallback (R2); +// - list KEEPS omit-=home: an empty channel name lists the caller's home. +// +// The id-typed PostAsAccount / ListAsAccount and defaultChannel are unchanged and +// covered by agent_caller_pgtest_test.go; these tests exercise only the ByName +// resolution layer above them. + +import ( + "context" + "testing" + + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/store" +) + +// TestPostAsAccountByNameResolvesVisibleChannel: a post naming a channel the +// agent is a member of resolves to that channel's id and lands there, authored by +// the agent. create_topic threads through so the named topic is minted. +func TestPostAsAccountByNameResolvesVisibleChannel(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + ch, err := st.CreateChannel(ctx, owner.ID, store.NewChannel{ + Name: "war-room", + Kind: store.ChannelKindChannel, + MemberAccountIDs: []store.AccountID{agent.ID}, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + resp, err := svc.PostAsAccountByName(ctx, agent.ID, &compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "war-room"}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("named post"), + }) + if err != nil { + t.Fatalf("PostAsAccountByName: %v", err) + } + if got := resp.GetMessage().GetAuthorAccountId(); got != string(agent.ID) { + t.Fatalf("author = %q, want the agent %q", got, agent.ID) + } + + // Read it back through the resolved channel (by id) to prove it landed there. + listed, err := svc.ListAsAccount(ctx, agent.ID, &compassv1.ListMessagesRequest{ + Container: &compassv1.ListMessagesRequest_ChannelId{ChannelId: string(ch.ID)}, + }) + if err != nil { + t.Fatalf("ListAsAccount(read-back): %v", err) + } + var found bool + for _, m := range listed.GetMessages() { + if m.GetId() == resp.GetMessage().GetId() { + found = true + } + } + if !found { + t.Fatalf("named post %q not found in the resolved channel", resp.GetMessage().GetId()) + } +} + +// TestPostAsAccountByNameUnknownChannelIsNotFound: a name no visible channel +// carries is CodeNotFound — the resolver miss surfaced at the tool edge. +func TestPostAsAccountByNameUnknownChannelIsNotFound(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + + _, err := svc.PostAsAccountByName(ctx, agent.ID, &compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "no-such-channel"}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("into the void"), + }) + connectCodeIs(t, err, connect.CodeNotFound, "PostAsAccountByName(unknown channel)") +} + +// TestPostAsAccountByNameInvisibleChannelIsNotFound: a channel that exists but +// the agent cannot see resolves to the SAME CodeNotFound an unknown name gets — +// the D9 not-found/forbidden merge carried through the tool edge. +func TestPostAsAccountByNameInvisibleChannelIsNotFound(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + // A private channel the agent is NOT a member of. + if _, err := st.CreateChannel(ctx, owner.ID, store.NewChannel{Name: "private", Kind: store.ChannelKindChannel}); err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + _, err := svc.PostAsAccountByName(ctx, agent.ID, &compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "private"}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("sneaking in by name"), + }) + connectCodeIs(t, err, connect.CodeNotFound, "PostAsAccountByName(invisible channel)") +} + +// TestPostAsAccountByNameAmbiguousChannelIsInvalidArgument: two channels the +// agent's owner can see sharing a name surface as CodeInvalidArgument — the +// caller must disambiguate; the server never silently picks one. Ungrouped +// channels are name-unconstrained, so two same-named ones both visible to the +// agent (member of both) is the achievable collision. +func TestPostAsAccountByNameAmbiguousChannelIsInvalidArgument(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + for range 2 { + if _, err := st.CreateChannel(ctx, owner.ID, store.NewChannel{ + Name: "dupe", Kind: store.ChannelKindChannel, + MemberAccountIDs: []store.AccountID{agent.ID}, + }); err != nil { + t.Fatalf("CreateChannel(dupe): %v", err) + } + } + + _, err := svc.PostAsAccountByName(ctx, agent.ID, &compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "dupe"}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("which dupe?"), + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "PostAsAccountByName(ambiguous channel)") +} + +// TestPostAsAccountByNameEmptyChannelHasNoHomeDefault: R2 drops the home default +// at the tool level for post/ask — an empty channel name is NOT filled from home, +// so it resolves like any other miss to CodeNotFound. (The agent must NAME its +// channel, even its own home; TS schema-requires a non-blank channel, so this is +// the defense-in-depth for a wire that somehow arrives empty.) +func TestPostAsAccountByNameEmptyChannelHasNoHomeDefault(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + + _, err := svc.PostAsAccountByName(ctx, agent.ID, &compassv1.PostMessageRequest{ + // No channel name — under R2 this is NOT a home post; it is a miss. + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("no channel named"), + }) + connectCodeIs(t, err, connect.CodeNotFound, "PostAsAccountByName(empty channel, no home default)") + + // The agent's home channel stayed empty — no silent home fallback wrote there. + listed, err := svc.ListAsAccount(ctx, agent.ID, &compassv1.ListMessagesRequest{ + Container: &compassv1.ListMessagesRequest_ChannelId{ChannelId: string(agent.Agent.HomeChannelID)}, + }) + if err != nil { + t.Fatalf("ListAsAccount(home): %v", err) + } + if n := len(listed.GetMessages()); n != 0 { + t.Fatalf("home channel holds %d messages, want 0 (no home fallback for post/ask)", n) + } +} + +// TestListAsAccountByNameEmptyChannelKeepsHomeDefault: R2 KEEPS omit-=home for +// list (a read has no misroute hazard) — an empty channel name lists the agent's +// home channel, while a named channel resolves through the viewer-scoped resolver. +func TestListAsAccountByNameEmptyChannelKeepsHomeDefault(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + + // Seed one message in the agent's home channel (id-typed post — internal). + seed, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(agent.Agent.HomeChannelID)}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("home seed"), + }) + if err != nil { + t.Fatalf("PostAsAccount(home seed): %v", err) + } + + // An empty channel name on the LIST tool entry defaults to home and finds it. + listed, err := svc.ListAsAccountByName(ctx, agent.ID, &compassv1.ListMessagesRequest{}) + if err != nil { + t.Fatalf("ListAsAccountByName(empty=home): %v", err) + } + var found bool + for _, m := range listed.GetMessages() { + if m.GetId() == seed.GetMessage().GetId() { + found = true + } + } + if !found { + t.Fatalf("home seed %q not found via empty-name list (omit-=home not honored)", seed.GetMessage().GetId()) + } +} + +// TestListAsAccountByNameUnknownChannelIsNotFound: a NON-empty list channel name +// that no visible channel carries is CodeNotFound — omit-=home applies only to the +// empty case; a named-but-unknown channel still misses. +func TestListAsAccountByNameUnknownChannelIsNotFound(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + + owner := mustUser(t, st, "owner") + agent := mustAgent(t, st, owner.ID, "agent") + + _, err := svc.ListAsAccountByName(ctx, agent.ID, &compassv1.ListMessagesRequest{ + Container: &compassv1.ListMessagesRequest_ChannelId{ChannelId: "no-such-channel"}, + }) + connectCodeIs(t, err, connect.CodeNotFound, "ListAsAccountByName(unknown channel)") +} diff --git a/go/internal/comms/agent_caller_pgtest_test.go b/go/internal/comms/agent_caller_pgtest_test.go index eec8b877..b5d37970 100644 --- a/go/internal/comms/agent_caller_pgtest_test.go +++ b/go/internal/comms/agent_caller_pgtest_test.go @@ -60,7 +60,7 @@ func TestPostAsAccountAttributesToAgentAccount(t *testing.T) { t.Fatalf("CreateChannel: %v", err) } - resp, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("from the agent")}) + resp, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("from the agent")}) if err != nil { t.Fatalf("PostAsAccount: %v", err) } @@ -106,7 +106,7 @@ func TestPostAsAccountNonMemberChannelIsNotFound(t *testing.T) { t.Fatalf("CreateChannel: %v", err) } - _, err = svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("sneaking in")}) + _, err = svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("sneaking in")}) connectCodeIs(t, err, connect.CodeNotFound, "PostAsAccount(non-member)") } @@ -122,8 +122,9 @@ func TestPostAsAccountEmptyChannelDefaultsToHome(t *testing.T) { resp, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{ // No channel_id set — defaults to the agent's home channel. - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: textBlocks("home post"), + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, + CreateTopic: true, + Blocks: textBlocks("home post"), }) if err != nil { t.Fatalf("PostAsAccount(empty channel): %v", err) @@ -182,7 +183,7 @@ func TestPostAsAccountEmptyAccountFailsClosedNoAdminWrite(t *testing.T) { t.Fatalf("CreateChannel: %v", err) } - _, err = svc.PostAsAccount(ctx, "", &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("should never be written")}) + _, err = svc.PostAsAccount(ctx, "", &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("should never be written")}) connectCodeIs(t, err, connect.CodeInvalidArgument, "PostAsAccount(empty account)") // No message was written as admin: the admin's own channel is empty. @@ -208,13 +209,13 @@ func TestPostAsAccountIdempotentByClientRequestID(t *testing.T) { owner := mustUser(t, st, "owner") agent := mustAgent(t, st, owner.ID, "agent") - req := &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(agent.Agent.HomeChannelID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("once"), ClientRequestId: "agent-req-1"} + req := &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(agent.Agent.HomeChannelID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("once"), ClientRequestId: "agent-req-1"} first, err := svc.PostAsAccount(ctx, agent.ID, req) if err != nil { t.Fatalf("PostAsAccount(first): %v", err) } // A retry with the same id (even a different body) returns the stored row. - retry, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(agent.Agent.HomeChannelID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("different body, same id"), ClientRequestId: "agent-req-1"}) + retry, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(agent.Agent.HomeChannelID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("different body, same id"), ClientRequestId: "agent-req-1"}) if err != nil { t.Fatalf("PostAsAccount(retry): %v", err) } diff --git a/go/internal/comms/agent_conversation_pgtest_test.go b/go/internal/comms/agent_conversation_pgtest_test.go index a36bd9a4..39d3f8cc 100644 --- a/go/internal/comms/agent_conversation_pgtest_test.go +++ b/go/internal/comms/agent_conversation_pgtest_test.go @@ -168,7 +168,7 @@ func TestCommitAgentUpdateEditsInPlaceAndFansOut(t *testing.T) { seed, _, err := h.store.AppendMessage(ctx, store.Message{ AuthorAccountID: agent.ID, Blocks: []store.MessageBlock{{Text: &seedText}}, - }, string(agent.Agent.HomeChannelID), store.TopicRef{Name: "general"}, "") + }, string(agent.Agent.HomeChannelID), store.TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(seed): %v", err) } @@ -230,7 +230,7 @@ func TestCommitAgentUpdateCrossAccountIsNotFound(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - postedA, err := svc.PostAsAccount(ctx, agentA.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("agent A's words")}) + postedA, err := svc.PostAsAccount(ctx, agentA.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("agent A's words")}) if err != nil { t.Fatalf("PostAsAccount(A): %v", err) } @@ -269,7 +269,7 @@ func TestCommitAgentUpdateRevokedMemberIsNotFound(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - posted, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: textBlocks("posted while a member")}) + posted, err := svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks("posted while a member")}) if err != nil { t.Fatalf("PostAsAccount: %v", err) } @@ -400,11 +400,7 @@ func TestCommitAgentPostRequestIsDedupedByClientRequestID(t *testing.T) { // The request shape CommitAgentPost builds (home-channel default via an // unset Container), plus the key T2 will thread onto it. post := func(text string) (*compassv1.PostMessageResponse, error) { - return svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{ - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: textBlocks(text), - ClientRequestId: "relayed-frame-1", - }) + return svc.PostAsAccount(ctx, agent.ID, &compassv1.PostMessageRequest{Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: textBlocks(text), ClientRequestId: "relayed-frame-1"}) } first, err := post("the turn") if err != nil { diff --git a/go/internal/comms/channel_policy_pgtest_test.go b/go/internal/comms/channel_policy_pgtest_test.go index f96810ee..54c4cb6e 100644 --- a/go/internal/comms/channel_policy_pgtest_test.go +++ b/go/internal/comms/channel_policy_pgtest_test.go @@ -80,11 +80,7 @@ func TestPostMessageOwnerOnlyNonOwnerIsNotFound(t *testing.T) { t.Fatalf("SetChannelPolicy: %v", err) } - _, err = svc.PostMessage(WithActor(ctx, other.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: chID}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "blocked"}}}, - })) + _, err = svc.PostMessage(WithActor(ctx, other.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: chID}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "blocked"}}}})) connectCodeIs(t, err, connect.CodeNotFound, "non-owner post on OWNER_ONLY channel") } diff --git a/go/internal/comms/comms.go b/go/internal/comms/comms.go index 4619d152..8b31a467 100644 --- a/go/internal/comms/comms.go +++ b/go/internal/comms/comms.go @@ -410,8 +410,9 @@ func (c *Comms) PostMessage( AuthorAccountID: c.actorFromContext(ctx), Blocks: blocks, }, req.Msg.GetChannelId(), store.TopicRef{ - ID: req.Msg.GetTopicId(), - Name: req.Msg.GetTopicName(), + ID: req.Msg.GetTopicId(), + Name: req.Msg.GetTopicName(), + Create: req.Msg.GetCreateTopic(), }, req.Msg.GetClientRequestId()) if err != nil { return nil, edgeError(err) diff --git a/go/internal/comms/comms_test.go b/go/internal/comms/comms_test.go index d52d7c62..b84353a2 100644 --- a/go/internal/comms/comms_test.go +++ b/go/internal/comms/comms_test.go @@ -219,7 +219,7 @@ func TestSearchMessagesAuthorizationScoped(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: alice.ID, Blocks: []store.MessageBlock{{Text: ptr("peregrine falcon")}}}, string(chA.ID), store.TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: alice.ID, Blocks: []store.MessageBlock{{Text: ptr("peregrine falcon")}}}, string(chA.ID), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -262,7 +262,7 @@ func TestListMessagesVisibilityScopedAtEdge(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: alice.ID, Blocks: []store.MessageBlock{{Text: ptr("private plans")}}}, string(chA.ID), store.TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: alice.ID, Blocks: []store.MessageBlock{{Text: ptr("private plans")}}}, string(chA.ID), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -298,7 +298,7 @@ func TestRespondToAskHappyPathEmitsMessageUpdated(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := h.store.AppendMessage(ctx, store.Message{AuthorAccountID: author.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := h.store.AppendMessage(ctx, store.Message{AuthorAccountID: author.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -362,12 +362,12 @@ func TestRespondToAskIsChannelScopedAcrossTopics(t *testing.T) { // answerer establishes presence ONLY in topic B — never in the ask's topic A. topicBText := "in topic B" - bMsg, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: answerer.ID, Blocks: []store.MessageBlock{{Text: &topicBText}}}, string(ch.ID), store.TopicRef{Name: "topic-b"}, "") + bMsg, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: answerer.ID, Blocks: []store.MessageBlock{{Text: &topicBText}}}, string(ch.ID), store.TopicRef{Name: "topic-b", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(topic-b): %v", err) } // asker posts the ask into topic A. - aMsg, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: asker.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "topic-a"}, "") + aMsg, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: asker.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "topic-a", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(topic-a): %v", err) } @@ -461,28 +461,24 @@ func TestPostMessageDropsCallerSuppliedAnswerState(t *testing.T) { // Every field the server owns, set by the caller to a value it must not // keep, alongside every content field, which it must. recommended := int32(1) - posted, err := svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Ask{Ask: &compassv1.Ask{ - AskId: "forged-ask-id", - Answered: true, - Questions: []*compassv1.AskQuestion{{ - QuestionId: "q1", - Question: "Which environment?", - Header: "Deploy target", - Options: []*compassv1.AskOption{ - {Id: "opt-a", Label: "staging", Description: "the safe one", Preview: "staging.example"}, - {Id: "opt-b", Label: "prod", Description: "the scary one", Preview: "prod.example"}, - }, - AllowMultiple: true, - Recommended: &recommended, - ChosenOptionIds: []string{"opt-a"}, - CustomText: "forged answer", - TimedOut: true, - }}, - }}}}, - })) + posted, err := svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Ask{Ask: &compassv1.Ask{ + AskId: "forged-ask-id", + Answered: true, + Questions: []*compassv1.AskQuestion{{ + QuestionId: "q1", + Question: "Which environment?", + Header: "Deploy target", + Options: []*compassv1.AskOption{ + {Id: "opt-a", Label: "staging", Description: "the safe one", Preview: "staging.example"}, + {Id: "opt-b", Label: "prod", Description: "the scary one", Preview: "prod.example"}, + }, + AllowMultiple: true, + Recommended: &recommended, + ChosenOptionIds: []string{"opt-a"}, + CustomText: "forged answer", + TimedOut: true, + }}, + }}}}})) if err != nil { t.Fatalf("PostMessage: %v", err) } @@ -579,7 +575,7 @@ func TestAskAnsweredFlagReachesTheWire(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: author.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: author.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -637,11 +633,7 @@ func TestPostMessageRoutesTopicOverWire(t *testing.T) { } // A post naming a topic echoes the resolved topic id on the wire. - first, err := svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "planning"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "first"}}}, - })) + first, err := svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "planning"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "first"}}}})) if err != nil { t.Fatalf("PostMessage(first): %v", err) } @@ -653,11 +645,7 @@ func TestPostMessageRoutesTopicOverWire(t *testing.T) { // A second post to the SAME topic_name resolves to the same topic id // (get-or-create), so the two messages share a topic. - second, err := svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "planning"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "second"}}}, - })) + second, err := svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "planning"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "second"}}}})) if err != nil { t.Fatalf("PostMessage(second): %v", err) } @@ -717,11 +705,7 @@ func TestPostMessageStripsCallerAskID(t *testing.T) { // postMinted posts one ask and returns the ask_id on the response Message. postMinted := func(ctx string) string { - resp, err := svc.PostMessage(WithActor(context.Background(), author.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{forgedBlock()}, - })) + resp, err := svc.PostMessage(WithActor(context.Background(), author.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{forgedBlock()}})) if err != nil { t.Fatalf("PostMessage(%s): %v", ctx, err) } @@ -786,7 +770,7 @@ func TestRespondToAskVisibilityCollapseNotFound(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: alice.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-secret")}}, string(chA.ID), store.TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := st.AppendMessage(ctx, store.Message{AuthorAccountID: alice.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-secret")}}, string(chA.ID), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } diff --git a/go/internal/comms/pinned_board_pgtest_test.go b/go/internal/comms/pinned_board_pgtest_test.go index 78d2f81e..faef3770 100644 --- a/go/internal/comms/pinned_board_pgtest_test.go +++ b/go/internal/comms/pinned_board_pgtest_test.go @@ -28,7 +28,7 @@ func pinnableMessage(t *testing.T, st *store.Store, ch store.ChannelID, author s t.Helper() msg, _, err := st.AppendMessage(context.Background(), store.Message{AuthorAccountID: author, Blocks: []store.MessageBlock{{Text: &body}}}, - string(ch), store.TopicRef{Name: "general"}, "") + string(ch), store.TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%q): %v", body, err) } diff --git a/go/internal/comms/respond_ask_answer_test.go b/go/internal/comms/respond_ask_answer_test.go index ce5e901a..f5d980bb 100644 --- a/go/internal/comms/respond_ask_answer_test.go +++ b/go/internal/comms/respond_ask_answer_test.go @@ -74,7 +74,7 @@ func TestRespondToAskFansOutMessagePosted(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := h.store.AppendMessage(ctx, store.Message{AuthorAccountID: agent.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := h.store.AppendMessage(ctx, store.Message{AuthorAccountID: agent.ID, Blocks: []store.MessageBlock{pendingAskStore("ask-1")}}, string(ch.ID), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -138,18 +138,14 @@ func TestPostMessageRejectsAskAnswerBlock(t *testing.T) { t.Fatalf("CreateChannel: %v", err) } - _, err = h.svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_AskAnswer{AskAnswer: &compassv1.AskAnswerBlock{ - Ask: &compassv1.Ask{ - AskId: "ask-1", - Answered: true, - Questions: []*compassv1.AskQuestion{{QuestionId: "q1", Question: "?"}}, - }, - AskerAccountId: "agent", - }}}}, - })) + _, err = h.svc.PostMessage(WithActor(ctx, author.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_AskAnswer{AskAnswer: &compassv1.AskAnswerBlock{ + Ask: &compassv1.Ask{ + AskId: "ask-1", + Answered: true, + Questions: []*compassv1.AskQuestion{{QuestionId: "q1", Question: "?"}}, + }, + AskerAccountId: "agent", + }}}}})) connectCodeIs(t, err, connect.CodeInvalidArgument, "PostMessage with an ask_answer block") } diff --git a/go/internal/comms/subscribe_test.go b/go/internal/comms/subscribe_test.go index 6c2d7cf2..519e8bb2 100644 --- a/go/internal/comms/subscribe_test.go +++ b/go/internal/comms/subscribe_test.go @@ -206,7 +206,7 @@ func TestSubscribeCommsSnapshotBoundaryFirstFrame(t *testing.T) { } const k = 2 for i := range k { - if _, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "seeded"}}}})); err != nil { + if _, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "seeded"}}}})); err != nil { t.Fatalf("PostMessage(%d): %v", i, err) } } @@ -294,7 +294,7 @@ func TestSubscribeCommsPostDeliversMessagePosted(t *testing.T) { // subscribe not block the post. events := firstEventAfterBoundary(t, h, poster.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) - posted, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "hello stream"}}}})) + posted, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "hello stream"}}}})) if err != nil { t.Fatalf("PostMessage: %v", err) } @@ -368,7 +368,7 @@ func TestSubscribeCommsAgentPresenceSharedChannelScoping(t *testing.T) { } strangerEvents := firstEventAfterBoundary(t, h, stranger.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) h.bus.Publish(presence) - posted, err := h.svc.PostMessage(WithActor(ctx, stranger.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(strangerCh.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "visible to me"}}}})) + posted, err := h.svc.PostMessage(WithActor(ctx, stranger.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(strangerCh.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "visible to me"}}}})) if err != nil { t.Fatalf("PostMessage(stranger): %v", err) } @@ -405,7 +405,7 @@ func TestPostMessageIdempotentRetrySuppressesDuplicatePublish(t *testing.T) { // Live path: subscribe concurrently (half-duplex), then the first post // delivers exactly one MessagePosted to the member. events := firstEventAfterBoundary(t, h, poster.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) - first, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "only once"}}}, ClientRequestId: "req-dup"})) + first, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "only once"}}}, ClientRequestId: "req-dup"})) if err != nil { t.Fatalf("PostMessage(first): %v", err) } @@ -417,7 +417,7 @@ func TestPostMessageIdempotentRetrySuppressesDuplicatePublish(t *testing.T) { // The retry with the SAME id returns the already-stored message (dedup), not // a fresh one — the store deduped and the handler must not re-publish. - retry, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "different body, same id"}}}, ClientRequestId: "req-dup"})) + retry, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "different body, same id"}}}, ClientRequestId: "req-dup"})) if err != nil { t.Fatalf("PostMessage(retry): %v", err) } @@ -454,7 +454,7 @@ func TestPostMessageWriteThrough(t *testing.T) { events := firstEventAfterBoundary(t, h, poster.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) - posted, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "durable and fanned"}}}})) + posted, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "durable and fanned"}}}})) if err != nil { t.Fatalf("PostMessage: %v", err) } @@ -489,7 +489,7 @@ func TestSubscribeCommsStaleEpochResyncsAndRedelivers(t *testing.T) { } // Post one message so there is a real store row to re-snapshot. - posted, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "before restart"}}}})) + posted, err := h.svc.PostMessage(WithActor(ctx, poster.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(ch.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "before restart"}}}})) if err != nil { t.Fatalf("PostMessage: %v", err) } diff --git a/go/internal/comms/supervisor_orchestration_pgtest_test.go b/go/internal/comms/supervisor_orchestration_pgtest_test.go index 3308bc91..8bc8d8a2 100644 --- a/go/internal/comms/supervisor_orchestration_pgtest_test.go +++ b/go/internal/comms/supervisor_orchestration_pgtest_test.go @@ -74,11 +74,11 @@ func TestSupervisorAssignsToTwoWorkersAuditable(t *testing.T) { // The supervisor posts each assignment as a channel message, authored as the // supervisor account. - postedA, err := svc.PostMessage(WithActor(ctx, supervisor.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: chID}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: assignA}}}})) + postedA, err := svc.PostMessage(WithActor(ctx, supervisor.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: chID}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: assignA}}}})) if err != nil { t.Fatalf("PostMessage(assign A): %v", err) } - postedB, err := svc.PostMessage(WithActor(ctx, supervisor.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: chID}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: assignB}}}})) + postedB, err := svc.PostMessage(WithActor(ctx, supervisor.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: chID}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: assignB}}}})) if err != nil { t.Fatalf("PostMessage(assign B): %v", err) } diff --git a/go/internal/comms/visibility_filter_test.go b/go/internal/comms/visibility_filter_test.go index 5d4c45f3..b165c02a 100644 --- a/go/internal/comms/visibility_filter_test.go +++ b/go/internal/comms/visibility_filter_test.go @@ -187,11 +187,7 @@ func TestSubscribeCommsPrivateMessageLeakBlocked(t *testing.T) { } // A posts a plain message (MessagePosted) into a named topic ... - posted, err := h.svc.PostMessage(WithActor(ctx, memberA.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(priv.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "secret"}}}, - })) + posted, err := h.svc.PostMessage(WithActor(ctx, memberA.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(priv.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "secret"}}}})) if err != nil { t.Fatalf("PostMessage: %v", err) } @@ -258,11 +254,7 @@ func TestSubscribeCommsPrivateTopicUpsertedLeakBlocked(t *testing.T) { // A posts a message into a named topic, creating the topic (no TopicUpserted // yet — get-or-create does not emit one). - posted, err := h.svc.PostMessage(WithActor(ctx, memberA.ID), connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(priv.ID)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "secret"}}}, - })) + posted, err := h.svc.PostMessage(WithActor(ctx, memberA.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(priv.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "secret"}}}})) if err != nil { t.Fatalf("PostMessage: %v", err) } @@ -535,14 +527,14 @@ func TestSubscribeCommsPrivateMessageLeakBlockedLiveTail(t *testing.T) { outsiderEvents := firstEventAfterBoundary(t, h, outsiderB.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) // The private post: member entitled, non-member not. - privPosted, err := h.svc.PostMessage(WithActor(ctx, memberA.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(priv.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "secret"}}}})) + privPosted, err := h.svc.PostMessage(WithActor(ctx, memberA.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(priv.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "secret"}}}})) if err != nil { t.Fatalf("PostMessage(private): %v", err) } privID := privPosted.Msg.GetMessage().GetId() // The canary post into a channel the non-member CAN see: its high-water mark. - canaryPosted, err := h.svc.PostMessage(WithActor(ctx, outsiderB.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(canaryCh.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "visible canary"}}}})) + canaryPosted, err := h.svc.PostMessage(WithActor(ctx, outsiderB.ID), connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(canaryCh.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "visible canary"}}}})) if err != nil { t.Fatalf("PostMessage(canary): %v", err) } diff --git a/go/internal/delivery/ask_answer_recovery_pgtest_test.go b/go/internal/delivery/ask_answer_recovery_pgtest_test.go index 6d7bd3c9..888a8518 100644 --- a/go/internal/delivery/ask_answer_recovery_pgtest_test.go +++ b/go/internal/delivery/ask_answer_recovery_pgtest_test.go @@ -36,7 +36,7 @@ func postAsk(t *testing.T, ctx context.Context, s *store.Store, ch store.Channel Options: []store.AskOption{{ID: "opt-a", Label: "staging"}, {ID: "opt-b", Label: "prod"}}, }}, }}}, - }, string(ch), store.TopicRef{Name: "general"}, ""); err != nil { + }, string(ch), store.TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(ask %q): %v", askID, err) } } diff --git a/go/internal/delivery/consumer.go b/go/internal/delivery/consumer.go index 69448038..7be4a64c 100644 --- a/go/internal/delivery/consumer.go +++ b/go/internal/delivery/consumer.go @@ -69,6 +69,14 @@ type DeliveryReads interface { //nolint:interfacebloat // one method per store r // the fan-out resolves the channel it delivers to through this join (the // frozen record's topic->channel resolution). MessageChannel(ctx context.Context, messageID string) (store.ChannelID, error) + // TopicChannelNames resolves a message's topic id to its topic name and the + // name of the channel it lives in (topics.channel_id -> channels.name) — the + // source-name denormalization stamped onto the deliver/steer control so the + // recipient renders the source channel+topic without a roster lookup (RIG-2956 + // T0). An unknown topic id is store.ErrNotFound, which the caller logs and + // treats as empty names — never a delivery block, exactly as GetAccount's + // from_handle miss degrades. + TopicChannelNames(ctx context.Context, topicID string) (topicName, channelName string, err error) UndeliveredMessages(ctx context.Context, agent store.AccountID) (map[store.ChannelID][]store.Message, error) // ChannelAgentMembers resolves every agent MEMBER of a channel (subscribe // state irrelevant), author excluded — the mention→steer routing set (D5, @@ -377,11 +385,14 @@ func (c *Consumer) handleEvent(ctx context.Context, resp *compassv1.SubscribeCom // handle denormalized onto the control so the agent emits the SessionInjection // observation's from_handle without a roster lookup on the injection path // (RIG-2486 T1); empty when the author handle could not be resolved (logged at -// the resolve site — a handle miss never blocks a delivery). -func deliverOp(msg *compassv1.Message, fromHandle string) *compassv1internal.AgentControl { +// the resolve site — a handle miss never blocks a delivery). channelName and +// topicName are the source channel+topic names denormalized the same way so the +// agent renders "Channel › topic :" without a roster lookup +// (RIG-2956 T0); each is empty on a resolve miss, which never blocks a delivery. +func deliverOp(msg *compassv1.Message, fromHandle, channelName, topicName string) *compassv1internal.AgentControl { return &compassv1internal.AgentControl{ Control: &compassv1internal.AgentControl_Deliver{ - Deliver: &compassv1internal.DeliverControl{Message: msg, FromHandle: fromHandle}, + Deliver: &compassv1internal.DeliverControl{Message: msg, FromHandle: fromHandle, ChannelName: channelName, TopicName: topicName}, }, } } @@ -392,15 +403,37 @@ func deliverOp(msg *compassv1.Message, fromHandle string) *compassv1internal.Age // (turn-end coalesced) — the only deliver-vs-steer difference is recipient-side // (design.md:558-562). SteerControl carries the same single first-party Message // as DeliverControl (DL-073), plus the same denormalized author from_handle -// (RIG-2486 T1). -func steerOp(msg *compassv1.Message, fromHandle string) *compassv1internal.AgentControl { +// (RIG-2486 T1) and source channel+topic names (RIG-2956 T0) — each empty on a +// resolve miss, which never blocks a delivery. +func steerOp(msg *compassv1.Message, fromHandle, channelName, topicName string) *compassv1internal.AgentControl { return &compassv1internal.AgentControl{ Control: &compassv1internal.AgentControl_Steer{ - Steer: &compassv1internal.SteerControl{Message: msg, FromHandle: fromHandle}, + Steer: &compassv1internal.SteerControl{Message: msg, FromHandle: fromHandle, ChannelName: channelName, TopicName: topicName}, }, } } +// sourceNames resolves a wire message's topic id to its source channel name and +// topic name — the values denormalized onto the deliver/steer control so the +// agent renders the source without a roster lookup (RIG-2956 T0). A missing +// topic id or a store miss is logged and yields empty names: the names are a +// render signal, never a delivery precondition, so a name miss must not block +// the dispatch — the exact log-and-continue posture authorHandle applies to the +// from_handle. +func (c *Consumer) sourceNames(ctx context.Context, msg *compassv1.Message) (channelName, topicName string) { + topicID := msg.GetTopicId() + if topicID == "" { + return "", "" + } + topicName, channelName, err := c.st.TopicChannelNames(ctx, topicID) + if err != nil { + c.log.ErrorContext(ctx, "delivery: resolve source channel/topic names for injection", + "error", err, "message_id", msg.GetId(), "topic_id", topicID) + return "", "" + } + return channelName, topicName +} + // authorHandle resolves a wire message's author account id to its handle — the // value denormalized onto the deliver/steer control as the SessionInjection // from_handle (RIG-2486 T1). A missing author id or a store miss is logged and diff --git a/go/internal/delivery/dispatch.go b/go/internal/delivery/dispatch.go index ed0edef2..fcaff309 100644 --- a/go/internal/delivery/dispatch.go +++ b/go/internal/delivery/dispatch.go @@ -323,7 +323,8 @@ func (c *Consumer) resolveMentioned(ctx context.Context, channel store.ChannelID // re-dispatch. A synchronous refusal (no live stream) is not fatal: the cursor // was never advanced on send, so the D2 sweep redelivers. func (c *Consumer) dispatchTo(ctx context.Context, sessionID string, msg *compassv1.Message, fromHandle string) { - c.gatedDispatch(ctx, sessionID, deliverOp(msg, fromHandle), msg.GetId()) + channelName, topicName := c.sourceNames(ctx, msg) + c.gatedDispatch(ctx, sessionID, deliverOp(msg, fromHandle, channelName, topicName), msg.GetId()) } // dispatchSteerTo relays one steer for msg to a mentioned recipient's session, @@ -331,7 +332,8 @@ func (c *Consumer) dispatchTo(ctx context.Context, sessionID string, msg *compas // same session never interleave. A synchronous refusal falls to the sweep // exactly as a deliver does (design.md:546-548). func (c *Consumer) dispatchSteerTo(ctx context.Context, sessionID string, msg *compassv1.Message, fromHandle string) { - c.gatedDispatch(ctx, sessionID, steerOp(msg, fromHandle), msg.GetId()) + channelName, topicName := c.sourceNames(ctx, msg) + c.gatedDispatch(ctx, sessionID, steerOp(msg, fromHandle, channelName, topicName), msg.GetId()) } // wake best-effort resumes an offline recipient via the AgentWaker seam (T3), so diff --git a/go/internal/delivery/helpers_test.go b/go/internal/delivery/helpers_test.go index 6e39bc8b..c6240776 100644 --- a/go/internal/delivery/helpers_test.go +++ b/go/internal/delivery/helpers_test.go @@ -80,23 +80,28 @@ const ( // the delivered message's id, and the op kind (deliver vs steer), each pulled // from the control op. type dispatchRecord struct { - sessionID string - messageID string - kind opKind - fromHandle string -} - -// classifyOp reports the op kind, carried message id, and denormalized author -// from_handle of a dispatched control, so the recorder can tell a steer from a -// deliver (both carry a Message) and assert the RIG-2486 T1 from_handle. -func classifyOp(op *compassv1internal.AgentControl) (opKind, string, string) { + sessionID string + messageID string + kind opKind + fromHandle string + channelName string + topicName string +} + +// classifyOp reports the op kind, carried message id, denormalized author +// from_handle, and denormalized source channel+topic names of a dispatched +// control, so the recorder can tell a steer from a deliver (both carry a +// Message) and assert the RIG-2486 T1 from_handle and RIG-2956 T0 source names. +func classifyOp(op *compassv1internal.AgentControl) (kind opKind, messageID, fromHandle, channelName, topicName string) { switch { case op.GetSteer() != nil: - return opSteer, op.GetSteer().GetMessage().GetId(), op.GetSteer().GetFromHandle() + s := op.GetSteer() + return opSteer, s.GetMessage().GetId(), s.GetFromHandle(), s.GetChannelName(), s.GetTopicName() case op.GetDeliver() != nil: - return opDeliver, op.GetDeliver().GetMessage().GetId(), op.GetDeliver().GetFromHandle() + d := op.GetDeliver() + return opDeliver, d.GetMessage().GetId(), d.GetFromHandle(), d.GetChannelName(), d.GetTopicName() default: - return opOther, "", "" + return opOther, "", "", "", "" } } @@ -146,8 +151,8 @@ func (d *fakeDispatcher) DispatchControl(_ context.Context, sessionID string, op d.mu.Unlock() return err } - kind, messageID, fromHandle := classifyOp(op) - d.calls = append(d.calls, dispatchRecord{sessionID: sessionID, messageID: messageID, kind: kind, fromHandle: fromHandle}) + kind, messageID, fromHandle, channelName, topicName := classifyOp(op) + d.calls = append(d.calls, dispatchRecord{sessionID: sessionID, messageID: messageID, kind: kind, fromHandle: fromHandle, channelName: channelName, topicName: topicName}) d.mu.Unlock() signalObserved(d.recorded) return nil @@ -294,7 +299,12 @@ type fakeReads struct { handles map[string]store.Account // lowercased handle -> resolved account (unknown -> ErrNotFound) accounts map[store.AccountID]store.Account // account id -> account (GetAccount; unknown -> ErrNotFound) messages map[string]store.Message - owed map[store.AccountID]map[store.ChannelID][]store.Message + // topicNames resolves a topic id to its (channelName, topicName) — the source + // denorm the deliver/steer op carries (TopicChannelNames). Absent -> the fake + // returns store.ErrNotFound, so a test can exercise the log-and-empty name-miss + // path (a name miss never blocks a delivery). + topicNames map[string]struct{ channelName, topicName string } + owed map[store.AccountID]map[store.ChannelID][]store.Message // sweepChannels is the D1 disjunct channel set per agent the pin sweep // enumerates (SweepChannels). A test seeds the channels a fresh session must // visit for pins, independent of owed (which omits channels with no owed @@ -351,6 +361,7 @@ func newFakeReads() *fakeReads { handles: map[string]store.Account{}, accounts: map[store.AccountID]store.Account{}, messages: map[string]store.Message{}, + topicNames: map[string]struct{ channelName, topicName string }{}, owed: map[store.AccountID]map[store.ChannelID][]store.Message{}, sweepChannels: map[store.AccountID][]store.ChannelID{}, pins: map[store.ChannelID][]store.PinnedEntry{}, @@ -603,6 +614,20 @@ func (f *fakeReads) MessageChannel(_ context.Context, messageID string) (store.C return "chan-1", nil } +// TopicChannelNames resolves a topic id to its seeded (topicName, channelName) +// — the source denorm the deliver/steer op carries (RIG-2956 T0). An unseeded +// topic id is store.ErrNotFound, mirroring the store's fail-closed lookup, so a +// test can exercise the log-and-empty name-miss path. +func (f *fakeReads) TopicChannelNames(_ context.Context, topicID string) (topicName, channelName string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + names, ok := f.topicNames[topicID] + if !ok { + return "", "", store.ErrNotFound + } + return names.topicName, names.channelName, nil +} + func (f *fakeReads) UndeliveredMessages(_ context.Context, agent store.AccountID) (map[store.ChannelID][]store.Message, error) { if f.beforeUndelivered != nil { f.beforeUndelivered(agent) @@ -612,6 +637,14 @@ func (f *fakeReads) UndeliveredMessages(_ context.Context, agent store.AccountID return f.owed[agent], nil } +// seedTopicNames registers a topic id's source channel+topic names the +// deliver/steer op resolves through TopicChannelNames. +func (f *fakeReads) seedTopicNames(topicID, channelName, topicName string) { //nolint:unparam // read-clarity signature: topicID names WHICH topic's source names to seed at each call site, though every current caller happens to seed the textMessage default "topic-1" — not dead code. + f.mu.Lock() + defer f.mu.Unlock() + f.topicNames[topicID] = struct{ channelName, topicName string }{channelName: channelName, topicName: topicName} +} + // seedMessage registers a stored message a re-read (settle / sweep) resolves. func (f *fakeReads) seedMessage(m store.Message) { f.mu.Lock() @@ -624,7 +657,7 @@ func (f *fakeReads) seedMessage(m store.Message) { // start-edge test can pre-seed an owed row independent of the routing arm. The // message itself must be seeded separately (seedMessage) for the sweep's re-read // to resolve it; omit it to model a permanently-unreadable owed row. -func (f *fakeReads) seedOwedMention(agent store.AccountID, channel store.ChannelID, m store.Message) { +func (f *fakeReads) seedOwedMention(agent store.AccountID, channel store.ChannelID, m store.Message) { //nolint:unparam // read-clarity signature: agent names WHICH recipient's owed row to seed at each call site, though every current caller happens to seed the same recipient — not dead code. f.mu.Lock() defer f.mu.Unlock() if f.owedMentions[agent] == nil { diff --git a/go/internal/delivery/mention_test.go b/go/internal/delivery/mention_test.go index 8688e759..32f68416 100644 --- a/go/internal/delivery/mention_test.go +++ b/go/internal/delivery/mention_test.go @@ -314,6 +314,72 @@ func TestFromHandleMissDeliversWithEmptyHandle(t *testing.T) { } } +// RIG-2956 T0: the source channel name AND topic name are denormalized onto BOTH +// the steer op (to a mentioned member) and the deliver op (to a plain +// subscriber), resolved once server-side via TopicChannelNames from the +// message's topic_id. The agent reads them off the control to render +// "Channel › topic :" without a per-injection roster lookup, the +// same denorm posture as from_handle. RED before the wrap sites populated +// ChannelName/TopicName: both ops carried empty names. +func TestDeliverAndSteerCarrySourceChannelAndTopicNames(t *testing.T) { + c, disp, res, reads := newTestConsumer(t) + const ch store.ChannelID = "chan-1" + const human store.AccountID = "human-1" + const agentA, agentB store.AccountID = "agent-a", "agent-b" + + reads.subscribers[ch] = []store.AccountID{agentA, agentB} + reads.members[ch] = []store.AccountID{agentA, agentB} + reads.handles["aa"] = agentAccount(agentA, "aa") + reads.accounts[human] = store.Account{ID: human, Handle: "matt"} + // The message's topic ("topic-1", the wireText default) resolves to its + // source channel+topic names. + reads.seedTopicNames("topic-1", "engineering", "general") + res.bind(agentA, "sess-a") + res.bind(agentB, "sess-b") + startConsumer(t, c) + + // @aa steers agent-a; agent-b (subscribed, unmentioned) gets a plain deliver. + c.bus.Publish(postedResponse(wireText("m1", human, "hey @aa"))) + disp.waitForDispatches(t, 2) + + got := disp.snapshot() + a := recordsFor(got, "sess-a") + if len(a) != 1 || a[0].kind != opSteer || a[0].channelName != "engineering" || a[0].topicName != "general" { + t.Fatalf("sess-a records = %+v, want one steer with channel=engineering topic=general", a) + } + b := recordsFor(got, "sess-b") + if len(b) != 1 || b[0].kind != opDeliver || b[0].channelName != "engineering" || b[0].topicName != "general" { + t.Fatalf("sess-b records = %+v, want one deliver with channel=engineering topic=general", b) + } +} + +// RIG-2956 T0: a source-name resolution MISS (the topic is not found) is logged +// and yields EMPTY channel+topic names — it never blocks the delivery, exactly +// as a from_handle miss degrades. The deliver still dispatches; only its source +// names are empty. +func TestSourceNameMissDeliversWithEmptyNames(t *testing.T) { + c, disp, res, reads := newTestConsumer(t) + const ch store.ChannelID = "chan-1" + const human store.AccountID = "human-1" + const agentA store.AccountID = "agent-a" + + reads.subscribers[ch] = []store.AccountID{agentA} + // No reads.seedTopicNames for "topic-1": TopicChannelNames is ErrNotFound. + res.bind(agentA, "sess-a") + startConsumer(t, c) + + c.bus.Publish(postedResponse(wireText("m1", human, "hi"))) + disp.waitForDispatches(t, 1) + + got := disp.snapshot() + if len(got) != 1 || got[0].kind != opDeliver || got[0].messageID != "m1" { + t.Fatalf("dispatch = %+v, want one deliver of m1", got) + } + if got[0].channelName != "" || got[0].topicName != "" { + t.Fatalf("source names = (%q, %q) on a store miss, want empty", got[0].channelName, got[0].topicName) + } +} + // Case 8 (design.md:853-855): a mention absent from the initial MessagePosted // block set but streamed in via a later store-grow still steers at the author's // settle edge. The message is HELD while the author streams (no mention yet); the diff --git a/go/internal/delivery/offline_mention_test.go b/go/internal/delivery/offline_mention_test.go index edf270a8..7fd9e999 100644 --- a/go/internal/delivery/offline_mention_test.go +++ b/go/internal/delivery/offline_mention_test.go @@ -183,6 +183,38 @@ func TestStartEdgeSweepsOwedMentionAsSteer(t *testing.T) { } } +// RIG-2956 T0 (owed-mention sweep coverage): the start-edge owed-mention sweep +// denormalizes the source channel+topic names onto each re-steered op +// (sweepOwedMentions, settle.go: cn, tn := c.sourceNames(ctx, wire); +// steerOp(wire, handle, cn, tn)). Reuses TestStartEdgeSweepsOwedMentionAsSteer's +// harness; RED if the settle site drops the names. +func TestStartEdgeSweptOwedMentionCarriesSourceChannelAndTopicNames(t *testing.T) { + c, disp, res, reads := newTestConsumer(t) + const ch store.ChannelID = "chan-1" + const author store.AccountID = "human-1" + const recipient store.AccountID = "agent-recip" + + reads.owed[recipient] = map[store.ChannelID][]store.Message{} + reads.seedMessage(textMessage("owed-1", author, "@recip you were paged")) + reads.seedOwedMention(recipient, ch, textMessage("owed-1", author, "@recip you were paged")) + reads.seedTopicNames("topic-1", "engineering", "general") + res.bind(recipient, "sess-recip") + startConsumer(t, c) + + c.OnSessionStarted("sess-recip", recipient) + if !disp.waitForMessage(t, "owed-1") { + t.Fatal("owed-1 never dispatched: sweepOwedMentions must dispatch an owed mention") + } + + got := disp.snapshot() + if len(got) != 1 || got[0].kind != opSteer || got[0].messageID != "owed-1" { + t.Fatalf("dispatch = %+v, want one steer of owed-1", got) + } + if got[0].channelName != "engineering" || got[0].topicName != "general" { + t.Fatalf("swept owed-mention steer source names = (%q, %q), want (engineering, general)", got[0].channelName, got[0].topicName) + } +} + // Case 6: an ack clears the owed row (T1), so a re-sweep after the ack dispatches // nothing. Modeled by clearing the owed row (the ack's effect) between two start // edges: the first sweeps the owed mention, the second sweeps nothing. diff --git a/go/internal/delivery/pin_sweep_test.go b/go/internal/delivery/pin_sweep_test.go index d58d2f07..3a58f516 100644 --- a/go/internal/delivery/pin_sweep_test.go +++ b/go/internal/delivery/pin_sweep_test.go @@ -169,3 +169,36 @@ func TestSweepPinsCarriesAuthorFromHandle(t *testing.T) { t.Fatalf("from_handle = %q on pin deliver, want matt", got[0].fromHandle) } } + +// RIG-2956 T0 (pin-sweep coverage): the pin sweep denormalizes the source +// channel+topic names onto each re-delivered pin op (sweepPins, settle.go: +// cn, tn := c.sourceNames(ctx, wire); deliverOp(wire, handle, cn, tn)). Reuses +// TestSweepPinsCarriesAuthorFromHandle's harness; RED if the settle site drops +// the names. +func TestSweepPinsCarriesSourceChannelAndTopicNames(t *testing.T) { + c, disp, res, reads := newTestConsumer(t) + const ch store.ChannelID = "chan-1" + const author store.AccountID = "human-1" + const recipient store.AccountID = "agent-recip" + + reads.owed[recipient] = map[store.ChannelID][]store.Message{} + reads.sweepChannels[recipient] = []store.ChannelID{ch} + reads.pins[ch] = []store.PinnedEntry{{MessageID: "pinned-1", Position: 0}} + reads.seedMessage(textMessage("pinned-1", author, "the pinned board")) + reads.seedTopicNames("topic-1", "engineering", "general") + res.bind(recipient, "sess-recip") + startConsumer(t, c) + + c.OnSessionStarted("sess-recip", recipient) + if !disp.waitForMessage(t, "pinned-1") { + t.Fatal("pinned-1 never dispatched: a fresh session must receive current pins") + } + + got := disp.snapshot() + if len(got) != 1 || got[0].kind != opDeliver || got[0].messageID != "pinned-1" { + t.Fatalf("dispatch = %+v, want one deliver of pinned-1", got) + } + if got[0].channelName != "engineering" || got[0].topicName != "general" { + t.Fatalf("pin deliver source names = (%q, %q), want (engineering, general)", got[0].channelName, got[0].topicName) + } +} diff --git a/go/internal/delivery/pre_settle_closure_pgtest_test.go b/go/internal/delivery/pre_settle_closure_pgtest_test.go index 30922d23..6668dea1 100644 --- a/go/internal/delivery/pre_settle_closure_pgtest_test.go +++ b/go/internal/delivery/pre_settle_closure_pgtest_test.go @@ -123,7 +123,7 @@ func postThroughStore(t *testing.T, ctx context.Context, s *store.Store, ch stor m, _, err := s.AppendMessage(ctx, store.Message{ AuthorAccountID: author, Blocks: []store.MessageBlock{{Text: &body}}, - }, string(ch), store.TopicRef{Name: "general"}, "") + }, string(ch), store.TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%q): %v", body, err) } diff --git a/go/internal/delivery/settle.go b/go/internal/delivery/settle.go index 5a6df5f1..45424f55 100644 --- a/go/internal/delivery/settle.go +++ b/go/internal/delivery/settle.go @@ -183,7 +183,8 @@ func (c *Consumer) sweepPins(ctx context.Context, agent store.AccountID, session "channel", string(channel), "message_id", string(entry.MessageID)) continue } - ops = append(ops, pinOp{op: deliverOp(wire, c.authorHandle(ctx, wire)), messageID: entry.MessageID}) + cn, tn := c.sourceNames(ctx, wire) + ops = append(ops, pinOp{op: deliverOp(wire, c.authorHandle(ctx, wire), cn, tn), messageID: entry.MessageID}) } } gate := c.gateFor(sessionID) @@ -230,7 +231,8 @@ func (c *Consumer) sweepOwedMentions(ctx context.Context, agent store.AccountID, "agent", string(agent), "channel", string(channel), "message_id", string(m.ID)) continue } - ops = append(ops, steerOpEntry{op: steerOp(wire, c.authorHandle(ctx, wire)), messageID: m.ID}) + cn, tn := c.sourceNames(ctx, wire) + ops = append(ops, steerOpEntry{op: steerOp(wire, c.authorHandle(ctx, wire), cn, tn), messageID: m.ID}) } } if len(ops) > 0 { @@ -318,7 +320,8 @@ func (c *Consumer) sweepSession(ctx context.Context, account store.AccountID, se for _, msgs := range owed { for i := range msgs { wire := comms.MessageToWire(msgs[i]) - op := deliverOp(wire, c.authorHandle(ctx, wire)) + cn, tn := c.sourceNames(ctx, wire) + op := deliverOp(wire, c.authorHandle(ctx, wire), cn, tn) if err := c.dispatch.DispatchControl(ctx, sessionID, op); err != nil { c.log.WarnContext(ctx, "delivery: sweep dispatch failed, leaving to next sweep", "error", err, "session_id", sessionID, "message_id", string(msgs[i].ID)) diff --git a/go/internal/delivery/sweep_test.go b/go/internal/delivery/sweep_test.go index e98d803b..a3d3f292 100644 --- a/go/internal/delivery/sweep_test.go +++ b/go/internal/delivery/sweep_test.go @@ -243,3 +243,39 @@ func TestSweepSessionCarriesAuthorFromHandle(t *testing.T) { t.Fatalf("from_handle = %q on swept deliver, want matt", got[0].fromHandle) } } + +// RIG-2956 T0 (sweep coverage): the reconnect/start cursor sweep denormalizes the +// source channel+topic names onto each redelivered deliver op (sweepSession, +// settle.go: cn, tn := c.sourceNames(ctx, wire); deliverOp(wire, handle, cn, tn)). +// Redelivery via the sweep is exactly where the source names are load-bearing — +// an idle/reconnecting peer renders "Channel › topic :" off the +// swept deliver, not the live fan-out. Seeds the message's topic names and +// asserts the swept deliver carries them. Mirrors +// TestDeliverAndSteerCarrySourceChannelAndTopicNames (mention_test.go) on the +// sweep path; RED if the settle site drops the names (deliverOp(wire, handle, "", "")). +func TestSweepSessionCarriesSourceChannelAndTopicNames(t *testing.T) { + c, disp, res, reads := newTestConsumer(t) + const ch store.ChannelID = "chan-1" + const author store.AccountID = "human-1" + const recipient store.AccountID = "agent-recip" + + reads.owed[recipient] = map[store.ChannelID][]store.Message{ + ch: {textMessage("owed-1", author, "first")}, + } + // The message's topic ("topic-1", the textMessage default) resolves to its + // source channel+topic names. + reads.seedTopicNames("topic-1", "engineering", "general") + res.bind(recipient, "sess-recip") + startConsumer(t, c) + + c.OnSessionStarted("sess-recip", recipient) + disp.waitForDispatches(t, 1) + + got := disp.snapshot() + if len(got) != 1 || got[0].kind != opDeliver || got[0].messageID != "owed-1" { + t.Fatalf("dispatch = %+v, want one deliver of owed-1", got) + } + if got[0].channelName != "engineering" || got[0].topicName != "general" { + t.Fatalf("swept deliver source names = (%q, %q), want (engineering, general)", got[0].channelName, got[0].topicName) + } +} diff --git a/go/internal/gen/compass/v1/agent.pb.go b/go/internal/gen/compass/v1/agent.pb.go index 41277374..7b728cd3 100644 --- a/go/internal/gen/compass/v1/agent.pb.go +++ b/go/internal/gen/compass/v1/agent.pb.go @@ -696,7 +696,16 @@ type SteerControl struct { // flags>`; EMPTY when the Server had no active span (trace machinery never // blocks or fails a delivery). Server-side stamping per // docs/designs/platform/compass-agent-message-trace-continuity/design.md. - Traceparent string `protobuf:"bytes,3,opt,name=traceparent,proto3" json:"traceparent,omitempty"` + Traceparent string `protobuf:"bytes,3,opt,name=traceparent,proto3" json:"traceparent,omitempty"` + // The names of the steer message's topic and channel, denormalized onto the + // steer op so the agent renders the mention's source — and can reply naming + // both — without a lookup per steer (mirrors from_handle above and + // DeliverControl's topic_name/channel_name; peer-DM record DL-292). The ids + // are carried transitively by the Message; only the names are denormalized + // here. A name-resolve miss degrades exactly as from_handle does — it never + // blocks a steer — so each is EMPTY on a resolve miss. + TopicName string `protobuf:"bytes,4,opt,name=topic_name,json=topicName,proto3" json:"topic_name,omitempty"` + ChannelName string `protobuf:"bytes,5,opt,name=channel_name,json=channelName,proto3" json:"channel_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -752,6 +761,20 @@ func (x *SteerControl) GetTraceparent() string { return "" } +func (x *SteerControl) GetTopicName() string { + if x != nil { + return x.TopicName + } + return "" +} + +func (x *SteerControl) GetChannelName() string { + if x != nil { + return x.ChannelName + } + return "" +} + // Empty shells — payload fields parked (RIG-1310). Present so the AgentControl // oneof is complete on the wire; populated by a stacked PR. // @@ -858,7 +881,15 @@ type DeliverControl struct { // flags>`; EMPTY when the Server had no active span (trace machinery never // blocks or fails a delivery). Server-side stamping per // docs/designs/platform/compass-agent-message-trace-continuity/design.md. - Traceparent string `protobuf:"bytes,4,opt,name=traceparent,proto3" json:"traceparent,omitempty"` + Traceparent string `protobuf:"bytes,4,opt,name=traceparent,proto3" json:"traceparent,omitempty"` + // The name of the message's channel, denormalized onto the deliver op so the + // agent renders the delivery's source channel — and can reply naming it — + // without a channel lookup per delivery (mirrors the topic_name denorm + // rationale above; peer-DM record DL-292). The channel *id* is already + // carried transitively by `message.channel_id`; only the name is + // denormalized here. A name-resolve miss degrades exactly as from_handle + // does — it never blocks a delivery — so this is EMPTY on a resolve miss. + ChannelName string `protobuf:"bytes,5,opt,name=channel_name,json=channelName,proto3" json:"channel_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -921,6 +952,13 @@ func (x *DeliverControl) GetTraceparent() string { return "" } +func (x *DeliverControl) GetChannelName() string { + if x != nil { + return x.ChannelName + } + return "" +} + // DeliveryAck — the agent's per-message delivery receipt (RIG-1569), an // AgentFrame oneof variant riding the Publish spine. Correlates to the // delivered message by id; on receipt the Server advances the delivery cursor @@ -1164,21 +1202,25 @@ const file_compass_v1_agent_proto_rawDesc = "" + "\acontrol\"%\n" + "\rPromptControl\x12\x14\n" + "\x05input\x18\x01 \x01(\tR\x05input\"\x10\n" + - "\x0eReplayComplete\"\x80\x01\n" + + "\x0eReplayComplete\"\xc2\x01\n" + "\fSteerControl\x12-\n" + "\amessage\x18\x01 \x01(\v2\x13.compass.v1.MessageR\amessage\x12\x1f\n" + "\vfrom_handle\x18\x02 \x01(\tR\n" + "fromHandle\x12 \n" + - "\vtraceparent\x18\x03 \x01(\tR\vtraceparent\"\x12\n" + + "\vtraceparent\x18\x03 \x01(\tR\vtraceparent\x12\x1d\n" + + "\n" + + "topic_name\x18\x04 \x01(\tR\ttopicName\x12!\n" + + "\fchannel_name\x18\x05 \x01(\tR\vchannelName\"\x12\n" + "\x10TranscriptReplay\"\x0f\n" + - "\rConfigControl\"\xa1\x01\n" + + "\rConfigControl\"\xc4\x01\n" + "\x0eDeliverControl\x12-\n" + "\amessage\x18\x01 \x01(\v2\x13.compass.v1.MessageR\amessage\x12\x1d\n" + "\n" + "topic_name\x18\x02 \x01(\tR\ttopicName\x12\x1f\n" + "\vfrom_handle\x18\x03 \x01(\tR\n" + "fromHandle\x12 \n" + - "\vtraceparent\x18\x04 \x01(\tR\vtraceparent\",\n" + + "\vtraceparent\x18\x04 \x01(\tR\vtraceparent\x12!\n" + + "\fchannel_name\x18\x05 \x01(\tR\vchannelName\",\n" + "\vDeliveryAck\x12\x1d\n" + "\n" + "message_id\x18\x01 \x01(\tR\tmessageId\"[\n" + diff --git a/go/internal/runnerhub/helpers_test.go b/go/internal/runnerhub/helpers_test.go index 5b696b52..d6e43e53 100644 --- a/go/internal/runnerhub/helpers_test.go +++ b/go/internal/runnerhub/helpers_test.go @@ -222,6 +222,14 @@ func (f *fakeCommsCaller) PostAsAccount(_ context.Context, account store.Account return f.postResp, nil } +// PostAsAccountByName is the agent-tool entry. The fake records and responds +// identically to PostAsAccount (the name→id resolve is the real Comms', proven +// in the comms pgtests), so a relay test drives the same attribute→forward path +// and asserts on the recorded post + canned response/error. +func (f *fakeCommsCaller) PostAsAccountByName(ctx context.Context, account store.AccountID, req *compassv1.PostMessageRequest) (*compassv1.PostMessageResponse, error) { + return f.PostAsAccount(ctx, account, req) +} + func (f *fakeCommsCaller) ListAsAccount(_ context.Context, account store.AccountID, req *compassv1.ListMessagesRequest) (*compassv1.ListMessagesResponse, error) { f.mu.Lock() defer f.mu.Unlock() @@ -232,6 +240,12 @@ func (f *fakeCommsCaller) ListAsAccount(_ context.Context, account store.Account return f.listResp, nil } +// ListAsAccountByName is the agent-tool list entry; records and responds +// identically to ListAsAccount (see PostAsAccountByName). +func (f *fakeCommsCaller) ListAsAccountByName(ctx context.Context, account store.AccountID, req *compassv1.ListMessagesRequest) (*compassv1.ListMessagesResponse, error) { + return f.ListAsAccount(ctx, account, req) +} + func (f *fakeCommsCaller) RosterAsAccount(_ context.Context, account store.AccountID, req *compassv1.GetRosterRequest) (*compassv1.GetRosterResponse, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/go/internal/runnerhub/hub.go b/go/internal/runnerhub/hub.go index 5ba0da6c..30921590 100644 --- a/go/internal/runnerhub/hub.go +++ b/go/internal/runnerhub/hub.go @@ -235,8 +235,17 @@ type SessionTailSink interface { // hub's own binding, never asserted by the Runner (transport design Decision #3 // / OQ-2, comms-tools design T2). type CommsCaller interface { + // PostAsAccount posts under account with an id-typed channel container — the + // internal id-holder path (relay transcript, seeds). PostAsAccountByName is + // the agent-tool path: it resolves the request's channel NAME to an id first + // (peer-DM R1, no home default per R2), then delegates to PostAsAccount. PostAsAccount(ctx context.Context, account store.AccountID, req *compassv1.PostMessageRequest) (*compassv1.PostMessageResponse, error) + PostAsAccountByName(ctx context.Context, account store.AccountID, req *compassv1.PostMessageRequest) (*compassv1.PostMessageResponse, error) + // ListAsAccount lists under account with an id-typed channel container. + // ListAsAccountByName is the agent-tool path: it resolves a non-empty channel + // NAME to an id and KEEPS omit-=home (a read has no misroute hazard, R2). ListAsAccount(ctx context.Context, account store.AccountID, req *compassv1.ListMessagesRequest) (*compassv1.ListMessagesResponse, error) + ListAsAccountByName(ctx context.Context, account store.AccountID, req *compassv1.ListMessagesRequest) (*compassv1.ListMessagesResponse, error) // RosterAsAccount executes an agent-initiated GetRoster under account (the // caller AND, when the request names no vantage, the session-resolved // vantage) — RIG-1721 T2. diff --git a/go/internal/runnerhub/integration_pgtest_test.go b/go/internal/runnerhub/integration_pgtest_test.go index bf3ecbe3..b4ffe129 100644 --- a/go/internal/runnerhub/integration_pgtest_test.go +++ b/go/internal/runnerhub/integration_pgtest_test.go @@ -158,7 +158,7 @@ func TestIntegrationSocketPostCommitsToStoreAndFansOnBus(t *testing.T) { resp, err := client.Comms(callCtx, connect.NewRequest(&compassv1internal.CommsCallRequest{ CallId: "call-1", Call: &compassv1internal.CommsCallRequest_Post{ - Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(homeChannel)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: relayText}}}}, + Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "atlas"}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: relayText}}}}, }, })) if err != nil { diff --git a/go/internal/runnerhub/relay_comms.go b/go/internal/runnerhub/relay_comms.go index e2ca5971..c18fbf6d 100644 --- a/go/internal/runnerhub/relay_comms.go +++ b/go/internal/runnerhub/relay_comms.go @@ -413,7 +413,7 @@ func (h *Hub) executeCall( ) (*compassv1internal.CommsCallResult, error) { switch c := call.GetCall().(type) { case *compassv1internal.CommsCallRequest_Post: - resp, err := h.comms.PostAsAccount(ctx, account, c.Post) + resp, err := h.comms.PostAsAccountByName(ctx, account, c.Post) if err != nil { return nil, err } @@ -421,7 +421,7 @@ func (h *Hub) executeCall( Result: &compassv1internal.CommsCallResult_Post{Post: resp}, }, nil case *compassv1internal.CommsCallRequest_List: - resp, err := h.comms.ListAsAccount(ctx, account, c.List) + resp, err := h.comms.ListAsAccountByName(ctx, account, c.List) if err != nil { return nil, err } diff --git a/go/internal/store/authz_test.go b/go/internal/store/authz_test.go index 352e7171..6eec760e 100644 --- a/go/internal/store/authz_test.go +++ b/go/internal/store/authz_test.go @@ -30,14 +30,14 @@ func TestAppendMessageNonMemberRefusedNoRow(t *testing.T) { ch := mustChannel(t, s, member.ID) // member is the sole founding member // A non-member author is refused, and nothing is written. - _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: outsider.ID, Blocks: []MessageBlock{textBlock("intrusion")}}, string(ch.ID), TopicRef{Name: "general"}, "") + _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: outsider.ID, Blocks: []MessageBlock{textBlock("intrusion")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") sentinelIs(t, err, ErrNotFound, "non-member append") if n := messageCount(t, ctx, s, ch.ID); n != 0 { t.Fatalf("non-member append persisted %d rows, want 0 (refusal must not write)", n) } // The member author still succeeds — the gate admits the visible set. - posted, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: member.ID, Blocks: []MessageBlock{textBlock("legitimate")}}, string(ch.ID), TopicRef{Name: "general"}, "") + posted, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: member.ID, Blocks: []MessageBlock{textBlock("legitimate")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("member append: %v", err) } @@ -200,7 +200,7 @@ func TestIsTopicChannelMember(t *testing.T) { // Seed a message so a topic exists under the channel; its TopicID is the // topic the gate resolves through. - posted, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: member.ID, Blocks: []MessageBlock{textBlock("seed")}}, string(ch.ID), TopicRef{Name: "general"}, "") + posted, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: member.ID, Blocks: []MessageBlock{textBlock("seed")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(seed): %v", err) } diff --git a/go/internal/store/channel_by_name_pgtest_test.go b/go/internal/store/channel_by_name_pgtest_test.go new file mode 100644 index 00000000..e7075ed6 --- /dev/null +++ b/go/internal/store/channel_by_name_pgtest_test.go @@ -0,0 +1,111 @@ +//go:build pgtest + +package store + +// ChannelByNameForViewer: the viewer-scoped name->id resolve the agent tool edge +// runs ahead of the id-typed store calls (peer-DM record R1). Every test here +// defends one arm of its contract: a visible name resolves to exactly its +// channel; an unknown OR invisible name collapses to the same ErrNotFound (the +// D9 not-found/forbidden merge, so a probe cannot enumerate names it lacks +// visibility for); and two visible channels sharing a name are ErrInvalidArgument +// (there is no ErrAmbiguous sentinel — invalid_argument naming the collision is +// the R1 rule). + +import ( + "context" + "errors" + "strings" + "testing" +) + +// TestChannelByNameForViewerHit: a channel the viewer is a member of resolves by +// name to that exact channel. +func TestChannelByNameForViewerHit(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + owner := mustUser(t, s, "owner") + + ch, err := s.CreateChannel(ctx, owner.ID, NewChannel{Name: "war-room", Kind: ChannelKindChannel}) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + got, err := s.ChannelByNameForViewer(ctx, owner.ID, "war-room") + if err != nil { + t.Fatalf("ChannelByNameForViewer(hit): %v", err) + } + if got.ID != ch.ID { + t.Fatalf("resolved id = %q, want %q", got.ID, ch.ID) + } + if got.Name != "war-room" { + t.Fatalf("resolved name = %q, want %q", got.Name, "war-room") + } +} + +// TestChannelByNameForViewerMissIsNotFound: a name no channel carries is +// ErrNotFound — never a distinct "no such name" that a caller could tell apart +// from an invisible one. +func TestChannelByNameForViewerMissIsNotFound(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + owner := mustUser(t, s, "owner") + + _, err := s.ChannelByNameForViewer(ctx, owner.ID, "ghost") + sentinelIs(t, err, ErrNotFound, "unknown channel name") +} + +// TestChannelByNameForViewerInvisibleIsNotFound: a channel that genuinely EXISTS +// but the viewer may not see resolves to the SAME ErrNotFound an unknown name +// gets — the not-found/forbidden merge, so an outsider cannot confirm a private +// channel's name exists by resolving it. An ungrouped channel is owner-scoped +// (visible only through membership), so the outsider is a non-member and the +// resolve must miss. +func TestChannelByNameForViewerInvisibleIsNotFound(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + owner := mustUser(t, s, "owner") + outsider := mustUser(t, s, "outsider") + + if _, err := s.CreateChannel(ctx, owner.ID, NewChannel{Name: "secret-room", Kind: ChannelKindChannel}); err != nil { + t.Fatalf("CreateChannel: %v", err) + } + + // The owner resolves it (control: it really exists and is visible to a member). + if _, err := s.ChannelByNameForViewer(ctx, owner.ID, "secret-room"); err != nil { + t.Fatalf("owner resolve of own channel: %v", err) + } + // The outsider gets ErrNotFound — indistinguishable from an unknown name. + _, err := s.ChannelByNameForViewer(ctx, outsider.ID, "secret-room") + sentinelIs(t, err, ErrNotFound, "invisible channel resolves like unknown") +} + +// TestChannelByNameForViewerAmbiguousIsInvalidArgument: two channels the viewer +// can see sharing a name is ErrInvalidArgument naming the collision, not a +// silent pick of one. Ungrouped channels are name-unconstrained +// (channels_group_name_key is partial WHERE group_id IS NOT NULL), so two +// same-named ungrouped channels both visible to their owner is the achievable +// collision. +func TestChannelByNameForViewerAmbiguousIsInvalidArgument(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + owner := mustUser(t, s, "owner") + + if _, err := s.CreateChannel(ctx, owner.ID, NewChannel{Name: "dupe", Kind: ChannelKindChannel}); err != nil { + t.Fatalf("CreateChannel(#1): %v", err) + } + if _, err := s.CreateChannel(ctx, owner.ID, NewChannel{Name: "dupe", Kind: ChannelKindChannel}); err != nil { + t.Fatalf("CreateChannel(#2): %v", err) + } + + _, err := s.ChannelByNameForViewer(ctx, owner.ID, "dupe") + sentinelIs(t, err, ErrInvalidArgument, "ambiguous channel name") + // The error names the collision so the caller can disambiguate. + if err != nil && !strings.Contains(err.Error(), "dupe") { + t.Fatalf("ambiguous error %q does not name the colliding name", err) + } + // And it is NOT ErrNotFound — a real collision is a caller mistake to fix, + // not an indistinguishable miss. + if errors.Is(err, ErrNotFound) { + t.Fatalf("ambiguous resolve returned ErrNotFound, want ErrInvalidArgument only") + } +} diff --git a/go/internal/store/channel_pins_pgtest_test.go b/go/internal/store/channel_pins_pgtest_test.go index fad58642..328202e4 100644 --- a/go/internal/store/channel_pins_pgtest_test.go +++ b/go/internal/store/channel_pins_pgtest_test.go @@ -25,7 +25,7 @@ func mustPinnableMessage(t *testing.T, s *Store, ch ChannelID, author AccountID, t.Helper() msg, _, err := s.AppendMessage(context.Background(), Message{AuthorAccountID: author, Blocks: []MessageBlock{textBlock(body)}}, - string(ch), TopicRef{Name: "general"}, "") + string(ch), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%q): %v", body, err) } diff --git a/go/internal/store/channel_policy_pgtest_test.go b/go/internal/store/channel_policy_pgtest_test.go index 693ffee2..c3700fb2 100644 --- a/go/internal/store/channel_policy_pgtest_test.go +++ b/go/internal/store/channel_policy_pgtest_test.go @@ -47,7 +47,7 @@ func TestPostMessageOwnerOnlyRejectsNonOwnerInBand(t *testing.T) { // with the same not-found a non-member would get. _, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: other.ID, Blocks: []MessageBlock{textBlock("not allowed")}, - }, string(ch.ID), TopicRef{Name: "general"}, "") + }, string(ch.ID), TopicRef{Name: "general", Create: true}, "") sentinelIs(t, err, ErrNotFound, "non-owner post on OWNER_ONLY channel") } @@ -64,7 +64,7 @@ func TestPostMessageOwnerOnlyOwnerPostLands(t *testing.T) { m, inserted, err := s.AppendMessage(ctx, Message{ AuthorAccountID: owner.ID, Blocks: []MessageBlock{textBlock("owner speaks")}, - }, string(ch.ID), TopicRef{Name: "general"}, "") + }, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(owner): %v", err) } diff --git a/go/internal/store/channels.go b/go/internal/store/channels.go index 96b82633..7df50099 100644 --- a/go/internal/store/channels.go +++ b/go/internal/store/channels.go @@ -391,6 +391,50 @@ func (s *Store) ChannelVisibleTo(ctx context.Context, actor AccountID, channelID return visible, nil } +// ChannelByNameForViewer resolves a channel NAME to its Channel within the set +// visible to viewer — the viewer-scoped name→id resolve the agent tool edge runs +// ahead of the id-typed store calls (peer-DM record R1). Channel names are not +// globally unique — "Ungrouped channels (empty group) are not name-constrained" +// and a name may recur across groups — so resolution is deliberately scoped to +// the caller's visible set (channelVisiblePredicate, shared with ListChannels so +// the resolve cannot drift from the read) and enforces uniqueness WITHIN it: +// +// - no visible channel of that name (unknown, or real-but-invisible) → +// ErrNotFound, the two indistinguishable so a probe cannot enumerate names it +// lacks visibility for (the D9 not-found/forbidden merge); +// - exactly one → that channel; +// - two or more visible channels sharing the name → ErrInvalidArgument naming +// the collision, so the caller disambiguates rather than the server guessing +// (there is no ErrAmbiguous sentinel — invalid_argument is the R1 rule). +// +// Match is exact-case, mirroring the channels_group_name_key uniqueness the store +// enforces on writes. +func (s *Store) ChannelByNameForViewer(ctx context.Context, viewer AccountID, name string) (Channel, error) { + const q = effectiveVisibilityCTE + ` + SELECT c.id, c.name, COALESCE(c.group_id, ''), c.kind, c.post_policy, COALESCE(c.owner_account_id, ''), c.mandatory_subscription + FROM channels c + WHERE c.name = $2 AND ` + channelVisiblePredicate + ` + ORDER BY c.id` + rows, err := s.pool.Query(ctx, q, string(viewer), name) + if err != nil { + return Channel{}, fmt.Errorf("store: resolve channel by name: %w", err) + } + defer rows.Close() + + channels, err := scanChannels(ctx, s.pool, rows) + if err != nil { + return Channel{}, err + } + switch len(channels) { + case 0: + return Channel{}, fmt.Errorf("%w: channel %q", ErrNotFound, name) + case 1: + return channels[0], nil + default: + return Channel{}, fmt.Errorf("%w: channel name %q is ambiguous — it names %d visible channels; address it by id", ErrInvalidArgument, name, len(channels)) + } +} + // UpdateChannelMembers applies a set of add/remove/subscribe mutations to a // channel (RT-1: the single membership-mutation carrier). Adds and // subscribe-flips upsert a member row; removes delete it. An add of an agent diff --git a/go/internal/store/delivery_cursors_test.go b/go/internal/store/delivery_cursors_test.go index 0eb34b4e..c8def065 100644 --- a/go/internal/store/delivery_cursors_test.go +++ b/go/internal/store/delivery_cursors_test.go @@ -24,7 +24,7 @@ import ( // and store-space seq. The author must already be a channel member. func postAs(t *testing.T, s *Store, ch ChannelID, author AccountID, body string) (string, int64) { t.Helper() - m, _, err := s.AppendMessage(context.Background(), Message{AuthorAccountID: author, Blocks: []MessageBlock{textBlock(body)}}, string(ch), TopicRef{Name: "general"}, "") + m, _, err := s.AppendMessage(context.Background(), Message{AuthorAccountID: author, Blocks: []MessageBlock{textBlock(body)}}, string(ch), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%q): %v", body, err) } diff --git a/go/internal/store/delivery_reads.go b/go/internal/store/delivery_reads.go index fe401022..7764a14d 100644 --- a/go/internal/store/delivery_reads.go +++ b/go/internal/store/delivery_reads.go @@ -156,6 +156,25 @@ func (s *Store) MessageChannel(ctx context.Context, messageID string) (ChannelID return ChannelID(channel), nil } +// TopicChannelNames resolves a topic id to its topic name and the name of the +// channel it lives in (topics.channel_id -> channels.name), the source-name +// denormalization the delivery consumer stamps onto a deliver/steer control so +// the recipient agent renders "Channel › topic :" without a +// per-injection roster lookup (RIG-2956 T0) — the same denorm posture as the +// author from_handle (GetAccount). An unknown topic id is ErrNotFound, which the +// caller logs and treats as empty names: a name miss never blocks a delivery. +func (s *Store) TopicChannelNames(ctx context.Context, topicID string) (topicName, channelName string, err error) { + if err := s.pool.QueryRow(ctx, + `SELECT t.name, c.name FROM topics t JOIN channels c ON c.id = t.channel_id WHERE t.id = $1`, topicID, + ).Scan(&topicName, &channelName); err != nil { + if noRows(err) { + return "", "", fmt.Errorf("%w: topic %q", ErrNotFound, topicID) + } + return "", "", fmt.Errorf("store: resolve topic channel names: %w", err) + } + return topicName, channelName, nil +} + // SweepChannels returns the D1 disjunct channel set an agent sweeps: every // channel the agent is subscribed to, PLUS its home channel (in the set // regardless of its channel_members.subscribed flag), PLUS any channel with diff --git a/go/internal/store/messages.go b/go/internal/store/messages.go index 6c393ccf..59b79f58 100644 --- a/go/internal/store/messages.go +++ b/go/internal/store/messages.go @@ -181,11 +181,16 @@ func insertMessageTx(ctx context.Context, tx pgx.Tx, m Message, topicID string, // under channelID: a topic in another channel — or an unknown id — is one // indistinguishable ErrInvalidArgument, so the existence of a foreign topic // never leaks (the same oracle-closing collapse the old same-channel-parent -// check applied). A name-ref is get-or-created on (channel_id, lower(name)) via -// ON CONFLICT DO NOTHING + re-SELECT, so two racing posts converge on one topic -// row with neither surfacing a unique-violation; resolving to an archived topic -// clears its archived flag in the same tx (archive is a tidiness flag, not a -// lock — a post at a tidied-away name revives the conversation). +// check applied). +// +// A name-ref resolves on (channel_id, lower(name)), gated by TopicRef.Create +// (peer-DM record R5). When Create is set the name is get-or-created via ON +// CONFLICT DO NOTHING + re-SELECT, so two racing posts converge on one topic row +// with neither surfacing a unique-violation. When Create is UNSET a name that +// resolves to no existing topic is ErrNotFound (in-band) rather than a silent +// mint — the tool-edge guard against topic sprawl. Either way, resolving to an +// archived topic clears its archived flag in the same tx (archive is a tidiness +// flag, not a lock — a post at a tidied-away name revives the conversation). func resolveTopicForAppend(ctx context.Context, tx pgx.Tx, channelID string, topic TopicRef, author AccountID, atMS int64) (string, error) { if topic.ID != "" { var topicChannelID string @@ -203,20 +208,24 @@ func resolveTopicForAppend(ctx context.Context, tx pgx.Tx, channelID string, top return topic.ID, nil } - // Name get-or-create: settle a concurrent create by the unique index, never - // a naive SELECT-then-INSERT. A concurrent inserter's uncommitted row makes - // this INSERT block until it commits, after which DO NOTHING fires and the - // re-SELECT below reads the surviving row. - if _, err := tx.Exec(ctx, - `INSERT INTO topics (id, channel_id, name, created_by_account_id, created_at_unix_ms) + // Get-or-create is gated on Create (R5). When set, settle a concurrent + // create by the unique index, never a naive SELECT-then-INSERT: a concurrent + // inserter's uncommitted row makes this INSERT block until it commits, after + // which DO NOTHING fires and the re-SELECT below reads the surviving row. + // When unset, the mint is skipped entirely — a name that resolves to no row + // below is ErrNotFound. + if topic.Create { + if _, err := tx.Exec(ctx, + `INSERT INTO topics (id, channel_id, name, created_by_account_id, created_at_unix_ms) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (channel_id, lower(name)) DO NOTHING`, - newID(), channelID, topic.Name, string(author), atMS, - ); err != nil { - if pgErrIs(err, pgForeignKeyViolation) { - return "", fmt.Errorf("%w: unknown channel %q or author %q", ErrInvalidArgument, channelID, author) + newID(), channelID, topic.Name, string(author), atMS, + ); err != nil { + if pgErrIs(err, pgForeignKeyViolation) { + return "", fmt.Errorf("%w: unknown channel %q or author %q", ErrInvalidArgument, channelID, author) + } + return "", fmt.Errorf("store: get-or-create topic: %w", err) } - return "", fmt.Errorf("store: get-or-create topic: %w", err) } var ( topicID string @@ -226,7 +235,13 @@ func resolveTopicForAppend(ctx context.Context, tx pgx.Tx, channelID string, top `SELECT id, archived FROM topics WHERE channel_id = $1 AND lower(name) = lower($2)`, channelID, topic.Name, ).Scan(&topicID, &archived); err != nil { - return "", fmt.Errorf("store: resolve get-or-create topic: %w", err) + if noRows(err) { + // Create was unset (or a racing delete removed the row) and no topic + // carries this name: an in-band ErrNotFound, never a silent mint. The + // error names only the topic name, never whether the channel exists. + return "", fmt.Errorf("%w: topic %q", ErrNotFound, topic.Name) + } + return "", fmt.Errorf("store: resolve topic: %w", err) } if archived { if _, err := tx.Exec(ctx, diff --git a/go/internal/store/messages_answer_post_test.go b/go/internal/store/messages_answer_post_test.go index d4ea24c9..2e08685c 100644 --- a/go/internal/store/messages_answer_post_test.go +++ b/go/internal/store/messages_answer_post_test.go @@ -52,7 +52,7 @@ func TestAnswerAskPostsAnswerMessage(t *testing.T) { } // The agent posts the ask. - askMsgIn, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general"}, "") + askMsgIn, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(ask): %v", err) } @@ -113,7 +113,7 @@ func TestAnswerAskSecondAnswerNoSecondMessage(t *testing.T) { agent := mustUser(t, s, "agent") ch := mustNamedChannel(t, s, agent.ID, "room") - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(ask): %v", err) } @@ -139,7 +139,7 @@ func TestAnswerAskInvalidAnswerNoMessage(t *testing.T) { agent := mustUser(t, s, "agent") ch := mustNamedChannel(t, s, agent.ID, "room") - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(ask): %v", err) } diff --git a/go/internal/store/messages_authored_update_test.go b/go/internal/store/messages_authored_update_test.go index 4432c865..55c1d495 100644 --- a/go/internal/store/messages_authored_update_test.go +++ b/go/internal/store/messages_authored_update_test.go @@ -31,7 +31,7 @@ import ( // the row the authorizing-update cases then try to edit under various actors. func authoredMessage(t *testing.T, ctx context.Context, s *Store, author AccountID, ch ChannelID, text string) Message { t.Helper() - msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author, Blocks: []MessageBlock{textBlock(text)}}, string(ch), TopicRef{Name: "general"}, "") + msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author, Blocks: []MessageBlock{textBlock(text)}}, string(ch), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -237,7 +237,7 @@ func TestMessageAskIDsReturnsStoredIDsInOrder(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{askBlockID("ask-first"), textBlock("between"), askBlockID("ask-second")}}, string(ch.ID), TopicRef{Name: "general"}, "") + msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{askBlockID("ask-first"), textBlock("between"), askBlockID("ask-second")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -278,7 +278,7 @@ func TestAnswerAskStillWorksForANonAuthorMember(t *testing.T) { if err != nil { t.Fatalf("CreateChannel: %v", err) } - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: asker.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: asker.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } diff --git a/go/internal/store/messages_snapshot_test.go b/go/internal/store/messages_snapshot_test.go index d8b6424d..12799da0 100644 --- a/go/internal/store/messages_snapshot_test.go +++ b/go/internal/store/messages_snapshot_test.go @@ -32,7 +32,7 @@ func TestListMessagesSnapshotSeqBoundsPage(t *testing.T) { append1 := func(body string) MessageID { t.Helper() - m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general"}, "") + m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%s): %v", body, err) } @@ -125,7 +125,7 @@ func TestListMessagesSnapshotSeqExcludesConcurrentWrites(t *testing.T) { append1 := func(body string) MessageID { t.Helper() - m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general"}, "") + m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%s): %v", body, err) } @@ -186,7 +186,7 @@ func TestListMessagesSnapshotSeqExcludesConcurrentWrites(t *testing.T) { defer wg.Done() <-start for _, body := range []string{"c1", "c2", "c3", "c4"} { - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Errorf("concurrent AppendMessage(%s): %v", body, err) close(written) return @@ -262,7 +262,7 @@ func TestSearchMessagesSnapshotSeqBoundsResults(t *testing.T) { append1 := func(body string) MessageID { t.Helper() - m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general"}, "") + m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%s): %v", body, err) } diff --git a/go/internal/store/messages_test.go b/go/internal/store/messages_test.go index c69b9dfc..b610283f 100644 --- a/go/internal/store/messages_test.go +++ b/go/internal/store/messages_test.go @@ -54,7 +54,7 @@ func TestAppendMessageAssignsIDAndTimestamp(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hello")}}, string(ch.ID), TopicRef{Name: "general"}, "") + msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hello")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -71,7 +71,7 @@ func TestAppendMessageNoBlocksInvalid(t *testing.T) { s := newTestStore(t) author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID}, string(ch.ID), TopicRef{Name: "general"}, "") + _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") sentinelIs(t, err, ErrInvalidArgument, "message with no blocks") } @@ -85,7 +85,7 @@ func TestAppendMessageUnknownChannelNotFound(t *testing.T) { // collapse to ErrNotFound (the not-found/forbidden merge), never a hint the // channel exists. Pre-gate this reached the insert and the FK surfaced // ErrInvalidArgument. - _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hi")}}, string("ghost"), TopicRef{Name: "general"}, "") + _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hi")}}, string("ghost"), TopicRef{Name: "general", Create: true}, "") sentinelIs(t, err, ErrNotFound, "unknown channel") } @@ -96,11 +96,11 @@ func TestAppendMessageIdempotency(t *testing.T) { ch := mustChannel(t, s, author.ID) // Same non-empty request id + author → the same stored message, no duplicate. - first, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("only once")}}, string(ch.ID), TopicRef{Name: "general"}, "req-idem") + first, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("only once")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "req-idem") if err != nil { t.Fatalf("AppendMessage(first): %v", err) } - second, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("different body, same key")}}, string(ch.ID), TopicRef{Name: "general"}, "req-idem") + second, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("different body, same key")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "req-idem") if err != nil { t.Fatalf("AppendMessage(retry): %v", err) } @@ -116,10 +116,10 @@ func TestAppendMessageIdempotency(t *testing.T) { } // An empty request id never dedups: two empty-key appends are two messages. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("a")}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("a")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(empty key a): %v", err) } - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("b")}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("b")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(empty key b): %v", err) } if got := messageCount(t, ctx, s, ch.ID); got != 3 { @@ -138,7 +138,7 @@ func TestAppendMessageInsertedFlag(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - _, inserted, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("first")}}, string(ch.ID), TopicRef{Name: "general"}, "req-inserted") + _, inserted, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("first")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "req-inserted") if err != nil { t.Fatalf("AppendMessage(first): %v", err) } @@ -148,7 +148,7 @@ func TestAppendMessageInsertedFlag(t *testing.T) { // A retry with the SAME key writes no row: inserted=false, so the handler // suppresses a duplicate MessagePosted. - _, inserted, err = s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("retry, same key")}}, string(ch.ID), TopicRef{Name: "general"}, "req-inserted") + _, inserted, err = s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("retry, same key")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "req-inserted") if err != nil { t.Fatalf("AppendMessage(retry): %v", err) } @@ -157,7 +157,7 @@ func TestAppendMessageInsertedFlag(t *testing.T) { } // An empty key never dedups: it is always a genuine insert. - _, inserted, err = s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("empty key")}}, string(ch.ID), TopicRef{Name: "general"}, "") + _, inserted, err = s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("empty key")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(empty key): %v", err) } @@ -181,7 +181,7 @@ func TestAppendMessageTopicRouting(t *testing.T) { ch := mustChannel(t, s, author.ID) // A post naming a topic by name creates it and records the resolved topic id. - first, inserted, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("root")}}, string(ch.ID), TopicRef{Name: "retry policy"}, "") + first, inserted, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("root")}}, string(ch.ID), TopicRef{Name: "retry policy", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(first): %v", err) } @@ -194,7 +194,7 @@ func TestAppendMessageTopicRouting(t *testing.T) { // A second post naming the SAME topic name lands in the same topic — the // get-or-create resolves the existing row, never a duplicate. - second, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("follow-up")}}, string(ch.ID), TopicRef{Name: "retry policy"}, "") + second, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("follow-up")}}, string(ch.ID), TopicRef{Name: "retry policy", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(second): %v", err) } @@ -226,7 +226,7 @@ func TestAppendMessageTopicRouting(t *testing.T) { // topic by id. Topic-under-channel validation rejects it even though the FK // would pass (the topic row exists). other := mustNamedChannel(t, s, author.ID, "other-room") - otherPost, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("root in the other channel")}}, string(other.ID), TopicRef{Name: "deploy friday"}, "") + otherPost, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("root in the other channel")}}, string(other.ID), TopicRef{Name: "deploy friday", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(other-channel): %v", err) } @@ -256,7 +256,7 @@ func TestAppendMessageMintsAskID(t *testing.T) { // owns the ask_id (comms.proto:278-280); a caller-supplied empty id must be // filled so the ask is addressable by RespondToAsk. empty := askBlockID("") - appended, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("choose one"), empty}}, string(ch.ID), TopicRef{Name: "general"}, "req-mint") + appended, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("choose one"), empty}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "req-mint") if err != nil { t.Fatalf("AppendMessage(empty ask id): %v", err) } @@ -286,7 +286,7 @@ func TestAppendMessageMintsAskID(t *testing.T) { } // Case 3: a caller-supplied ask_id is preserved verbatim, never overwritten. - supplied, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{askBlockID("ask-fixed-123")}}, string(ch.ID), TopicRef{Name: "general"}, "req-fixed") + supplied, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{askBlockID("ask-fixed-123")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "req-fixed") if err != nil { t.Fatalf("AppendMessage(fixed ask id): %v", err) } @@ -312,7 +312,7 @@ func TestListMessagesNewestFirstAndPaging(t *testing.T) { bodies := []string{"m0", "m1", "m2", "m3"} ids := make([]MessageID, 0, len(bodies)) for _, body := range bodies { - m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general"}, "") + m, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%s): %v", body, err) } @@ -361,7 +361,7 @@ func TestListMessagesUnknownCursorInvalid(t *testing.T) { s := newTestStore(t) author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("m")}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("m")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } _, err := s.ListMessages(ctx, ListMessagesQuery{Actor: author.ID, ChannelID: ch.ID, Page: Page{BeforeMessageID: "not-a-real-id"}}) @@ -385,7 +385,7 @@ func TestListMessagesChannelLevelSpansTopics(t *testing.T) { t.Helper() m, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock(body)}, - }, string(ch.ID), TopicRef{Name: topic}, "") + }, string(ch.ID), TopicRef{Name: topic, Create: true}, "") if err != nil { t.Fatalf("AppendMessage(%s/%s): %v", topic, body, err) } @@ -456,7 +456,7 @@ func TestUpdateMessageBlocksReplaces(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("original")}}, string(ch.ID), TopicRef{Name: "general"}, "") + msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("original")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -481,7 +481,7 @@ func TestUpdateMessageBlocksRejectsEmptyAskID(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("original")}}, string(ch.ID), TopicRef{Name: "general"}, "") + msg, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("original")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -530,7 +530,7 @@ func TestMessageBlocksRoundTrip(t *testing.T) { ch := mustChannel(t, s, author.ID) want := sampleBlocks() - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: want}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: want}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } got, err := s.ListMessages(ctx, ListMessagesQuery{Actor: author.ID, ChannelID: ch.ID, Page: Page{}}) @@ -556,15 +556,15 @@ func TestSearchMessages(t *testing.T) { chB := mustNamedChannel(t, s, bob.ID, "room-b") // alice posts a matching and a non-matching message in chA. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: alice.ID, Blocks: []MessageBlock{textBlock("the peregrine falcon dives fast")}}, string(chA.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: alice.ID, Blocks: []MessageBlock{textBlock("the peregrine falcon dives fast")}}, string(chA.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("append match: %v", err) } - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: alice.ID, Blocks: []MessageBlock{textBlock("an unrelated grocery list")}}, string(chA.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: alice.ID, Blocks: []MessageBlock{textBlock("an unrelated grocery list")}}, string(chA.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("append nonmatch: %v", err) } // bob posts a message that also contains "falcon" but in chB, which alice // is not a member of. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: bob.ID, Blocks: []MessageBlock{textBlock("falcon heavy launch scheduled")}}, string(chB.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: bob.ID, Blocks: []MessageBlock{textBlock("falcon heavy launch scheduled")}}, string(chB.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("append bob: %v", err) } @@ -704,7 +704,7 @@ func TestAnswerAskHappyPath(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -730,7 +730,7 @@ func TestAnswerAskVisibilityCollapse(t *testing.T) { // alice posts an ask in a channel bob is not a member of. chA := mustNamedChannel(t, s, actorA.ID, "alice-room") - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: actorA.ID, Blocks: []MessageBlock{pendingAsk("ask-secret", false)}}, string(chA.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: actorA.ID, Blocks: []MessageBlock{pendingAsk("ask-secret", false)}}, string(chA.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -766,7 +766,7 @@ func TestAnswerAskNonexistentNotFound(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) // A real member with a real message, but no ask carries this id. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("no ask here")}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("no ask here")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } _, _, err := s.AnswerAsk(ctx, author.ID, "ask-ghost", []AskAnswer{{QuestionID: "q1", ChosenOptionIDs: []string{"opt-a"}}}) @@ -789,7 +789,7 @@ func TestAnswerAskValidation(t *testing.T) { // A single-select ask, a multi-select ask, and a two-question ask, one // message each. for _, b := range []MessageBlock{pendingAsk("ask-single", false), pendingAsk("ask-multi", true), pendingMultiQ("ask-mq")} { - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{b}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{b}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(%s): %v", b.Ask.AskID, err) } } @@ -852,7 +852,7 @@ func TestAnswerAskEmptySkipSatisfiesCoverage(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingMultiQ("ask-mq")}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingMultiQ("ask-mq")}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -894,7 +894,7 @@ func TestAnswerAskRejectsReAnswer(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-1", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -931,7 +931,7 @@ func TestAnswerAskRejectsReAnswerAfterSkip(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-skip", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-skip", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -961,7 +961,7 @@ func TestAnswerAskMultiSelect(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-multi", true)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-multi", true)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -985,7 +985,7 @@ func TestAnswerAskRejectsDuplicateOption(t *testing.T) { author := mustUser(t, s, "author") ch := mustChannel(t, s, author.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-dup", true)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-dup", true)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -1022,7 +1022,7 @@ func TestAnswerAskConcurrentDistinctAsksSerialize(t *testing.T) { ch := mustChannel(t, s, author.ID) // One message carrying two independent pending asks. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-x", false), pendingAsk("ask-y", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-x", false), pendingAsk("ask-y", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -1072,7 +1072,7 @@ func TestAnswerAskConcurrentSameAskOneConflict(t *testing.T) { ch := mustChannel(t, s, author.ID) // One message carrying one pending single-select ask (q1: opt-a / opt-b). - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-race", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-race", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -1158,7 +1158,7 @@ func TestAppendMessageRejectsMalformedAsk(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{{Ask: tc.ask}}}, string(ch.ID), TopicRef{Name: "general"}, "") + _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{{Ask: tc.ask}}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") sentinelIs(t, err, ErrInvalidArgument, tc.name) }) } @@ -1311,7 +1311,7 @@ func TestAnswerAskRecordsCustomText(t *testing.T) { }, }, }} - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{freeTextAsk}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{freeTextAsk}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -1353,7 +1353,7 @@ func TestAnswerAskRejectsOptionPlusCustomTextOnSingleSelect(t *testing.T) { ch := mustChannel(t, s, author.ID) // A single-select ask: q1 offers opt-a/opt-b, AllowMultiple=false. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-single", false)}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{pendingAsk("ask-single", false)}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -1405,7 +1405,7 @@ func TestAppendMessageRejectsRecommendedOutOfRange(t *testing.T) { t.Run(tc.name, func(t *testing.T) { _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{{Ask: &Ask{AskID: "ask-oob", Questions: []AskQuestion{{ QuestionID: "q1", Question: "Which environment?", Options: twoOpts, Recommended: tc.recommended, - }}}}}}, string(ch.ID), TopicRef{Name: "general"}, "") + }}}}}}, string(ch.ID), TopicRef{Name: "general", Create: true}, "") sentinelIs(t, err, ErrInvalidArgument, tc.name) if err == nil || !strings.Contains(err.Error(), "recommended index") || !strings.Contains(err.Error(), "out of range") { t.Fatalf("AppendMessage error = %v, want it to name the out-of-range recommended index", err) @@ -1422,7 +1422,7 @@ func TestAppendMessageRejectsRecommendedOutOfRange(t *testing.T) { // accepted and persists a row. if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{{Ask: &Ask{AskID: "ask-in-range", Questions: []AskQuestion{{ QuestionID: "q1", Question: "Which environment?", Options: twoOpts, Recommended: ptr(int32(1)), - }}}}}}, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + }}}}}}, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(in-range recommended): %v", err) } if n := messageCount(t, ctx, s, ch.ID); n != 1 { diff --git a/go/internal/store/presence_reads_test.go b/go/internal/store/presence_reads_test.go index 66cd9e35..c47ca88e 100644 --- a/go/internal/store/presence_reads_test.go +++ b/go/internal/store/presence_reads_test.go @@ -30,7 +30,7 @@ func TestAgentHasOpenAskCatchesOmittedAnsweredField(t *testing.T) { // Seed an unanswered (pending) ask authored by the agent. pendingAsk carries // no Answered field, so the stored JSONB omits "answered" entirely — the // exact shape the naive containment would miss. - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-open", false)}}, string(ch), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-open", false)}}, string(ch), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage(pending ask): %v", err) } @@ -68,7 +68,7 @@ func TestAgentHasOpenAskFalseOnceAnswered(t *testing.T) { agent := mustAgent(t, s, owner.ID, "agent") ch := mustNamedChannelWith(t, s, owner.ID, "room", agent.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-1", false)}}, string(ch), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{textBlock("choose one"), pendingAsk("ask-1", false)}}, string(ch), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -105,7 +105,7 @@ func TestAgentHasOpenAskFalseWithNoAsks(t *testing.T) { agent := mustAgent(t, s, owner.ID, "agent") ch := mustNamedChannelWith(t, s, owner.ID, "room", agent.ID) - if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{textBlock("just talking")}}, string(ch), TopicRef{Name: "general"}, ""); err != nil { + if _, _, err := s.AppendMessage(ctx, Message{AuthorAccountID: agent.ID, Blocks: []MessageBlock{textBlock("just talking")}}, string(ch), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } diff --git a/go/internal/store/store_test.go b/go/internal/store/store_test.go index d748324a..c1ebed50 100644 --- a/go/internal/store/store_test.go +++ b/go/internal/store/store_test.go @@ -83,7 +83,7 @@ func TestRestartDurabilityReadsBackFullGraph(t *testing.T) { t.Fatalf("CreateChannel: %v", err) } wantBlocks := sampleBlocks() - msg, _, err := s1.AppendMessage(ctx, Message{AuthorAccountID: user.ID, Blocks: wantBlocks}, string(channel.ID), TopicRef{Name: "general"}, "req-durable") + msg, _, err := s1.AppendMessage(ctx, Message{AuthorAccountID: user.ID, Blocks: wantBlocks}, string(channel.ID), TopicRef{Name: "general", Create: true}, "req-durable") if err != nil { t.Fatalf("AppendMessage: %v", err) } diff --git a/go/internal/store/topic_create_gate_pgtest_test.go b/go/internal/store/topic_create_gate_pgtest_test.go new file mode 100644 index 00000000..2fff25b2 --- /dev/null +++ b/go/internal/store/topic_create_gate_pgtest_test.go @@ -0,0 +1,110 @@ +//go:build pgtest + +package store + +// The create_topic gate (peer-DM record R5, amending the zulip-threading model's +// unconditional get-or-create): a post to an unknown topic NAME fails with +// ErrNotFound unless TopicRef.Create is set; with the flag it mints. An id-ref is +// unaffected (Create is ignored). Archived-topic revival is unchanged when the +// flag is set — archive is a tidiness flag, not a lock. These pin the store half +// of the tool-edge sprawl guard; the tool edge threads create_topic into +// TopicRef.Create. + +import ( + "context" + "testing" +) + +// TestAppendMessageUnknownTopicWithoutCreateIsNotFound: a name that names no +// existing topic, posted WITHOUT Create, is ErrNotFound and writes nothing — +// never a silent mint. The channel stays empty, proving the gated miss rolls the +// whole append back. +func TestAppendMessageUnknownTopicWithoutCreateIsNotFound(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + author := mustUser(t, s, "author") + ch := mustChannel(t, s, author.ID) + + _, _, err := s.AppendMessage(ctx, + Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("no topic yet")}}, + string(ch.ID), TopicRef{Name: "brand-new"}, "") + sentinelIs(t, err, ErrNotFound, "unknown topic name without create flag") + + if n := messageCount(t, ctx, s, ch.ID); n != 0 { + t.Fatalf("gated miss persisted %d rows, want 0 (refusal must not write)", n) + } +} + +// TestAppendMessageUnknownTopicWithCreateMints: the same post WITH Create mints +// the topic and lands the message under it — the trusted-minter path. +func TestAppendMessageUnknownTopicWithCreateMints(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + author := mustUser(t, s, "author") + ch := mustChannel(t, s, author.ID) + + posted, inserted, err := s.AppendMessage(ctx, + Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("mint me")}}, + string(ch.ID), TopicRef{Name: "brand-new", Create: true}, "") + if err != nil { + t.Fatalf("AppendMessage(create): %v", err) + } + if !inserted || posted.TopicID == "" { + t.Fatalf("create post: inserted=%v topicID=%q, want a real insert under a minted topic", inserted, posted.TopicID) + } + + // A second post to the SAME name, now WITHOUT the flag, resolves the existing + // topic — the gate blocks minting, not posting to an already-born topic. + second, _, err := s.AppendMessage(ctx, + Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("follow-up, no flag")}}, + string(ch.ID), TopicRef{Name: "brand-new"}, "") + if err != nil { + t.Fatalf("AppendMessage(follow-up, no flag, existing topic): %v", err) + } + if second.TopicID != posted.TopicID { + t.Fatalf("follow-up landed in topic %q, want the existing %q", second.TopicID, posted.TopicID) + } +} + +// TestAppendMessageArchivedTopicRevivalUnchangedWithCreate: resolving to an +// archived topic clears its archived flag in the same tx (revival), and this is +// unchanged under the new gate — with Create set, a post at a tidied-away name +// revives the existing topic rather than forking a duplicate. +func TestAppendMessageArchivedTopicRevivalUnchangedWithCreate(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + author := mustUser(t, s, "author") + ch := mustChannel(t, s, author.ID) + + // Birth the topic, then archive it. + first, _, err := s.AppendMessage(ctx, + Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hello")}}, + string(ch.ID), TopicRef{Name: "tidied", Create: true}, "") + if err != nil { + t.Fatalf("AppendMessage(birth): %v", err) + } + archived := true + if _, err := s.UpdateTopic(ctx, string(author.ID), first.TopicID, nil, &archived); err != nil { + t.Fatalf("UpdateTopic(archive): %v", err) + } + + // A post at the archived name WITH Create resolves the SAME topic and clears + // the flag — revival, not a duplicate. + revived, _, err := s.AppendMessage(ctx, + Message{AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("back on this")}}, + string(ch.ID), TopicRef{Name: "tidied", Create: true}, "") + if err != nil { + t.Fatalf("AppendMessage(revive): %v", err) + } + if revived.TopicID != first.TopicID { + t.Fatalf("revive landed in topic %q, want the archived %q (revival, not duplicate)", revived.TopicID, first.TopicID) + } + + topics, err := s.ListTopics(ctx, string(author.ID), string(ch.ID), false) + if err != nil { + t.Fatalf("ListTopics: %v", err) + } + if len(topics) != 1 || topics[0].Archived { + t.Fatalf("after revival topics = %+v, want the one 'tidied' topic un-archived", topics) + } +} diff --git a/go/internal/store/topics_test.go b/go/internal/store/topics_test.go index 86151dc2..842e78e4 100644 --- a/go/internal/store/topics_test.go +++ b/go/internal/store/topics_test.go @@ -39,7 +39,7 @@ func TestAppendMessageConcurrentGetOrCreateOneTopic(t *testing.T) { <-start _, _, errs[i] = s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("racing post")}, - }, string(ch.ID), TopicRef{Name: "retry policy"}, "") + }, string(ch.ID), TopicRef{Name: "retry policy", Create: true}, "") }(i) } close(start) @@ -78,7 +78,7 @@ func TestAppendMessageGetOrCreateRevivesArchivedTopic(t *testing.T) { // Birth the topic, then archive it. first, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hello")}, - }, string(ch.ID), TopicRef{Name: "Retry Policy"}, "") + }, string(ch.ID), TopicRef{Name: "Retry Policy", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(first): %v", err) } @@ -91,7 +91,7 @@ func TestAppendMessageGetOrCreateRevivesArchivedTopic(t *testing.T) { // topic (case-insensitive unique index) and clears the flag. revived, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("back on this")}, - }, string(ch.ID), TopicRef{Name: "retry policy"}, "") + }, string(ch.ID), TopicRef{Name: "retry policy", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(revive): %v", err) } @@ -122,7 +122,7 @@ func TestListTopicsChannelMembershipGated(t *testing.T) { if _, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: member.ID, Blocks: []MessageBlock{textBlock("hi")}, - }, string(ch.ID), TopicRef{Name: "general"}, ""); err != nil { + }, string(ch.ID), TopicRef{Name: "general", Create: true}, ""); err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -155,7 +155,7 @@ func TestUpdateTopicRenameInPlace(t *testing.T) { post, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("hello")}, - }, string(ch.ID), TopicRef{Name: "old name"}, "") + }, string(ch.ID), TopicRef{Name: "old name", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } @@ -196,13 +196,13 @@ func TestUpdateTopicRenameToExistingMerges(t *testing.T) { // Two topics, each with a message. src is renamed INTO dst. src, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("in source")}, - }, string(ch.ID), TopicRef{Name: "source topic"}, "") + }, string(ch.ID), TopicRef{Name: "source topic", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(source): %v", err) } dst, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: author.ID, Blocks: []MessageBlock{textBlock("in target")}, - }, string(ch.ID), TopicRef{Name: "target topic"}, "") + }, string(ch.ID), TopicRef{Name: "target topic", Create: true}, "") if err != nil { t.Fatalf("AppendMessage(target): %v", err) } @@ -257,7 +257,7 @@ func TestUpdateTopicUnknownOrNonMemberNotFound(t *testing.T) { post, _, err := s.AppendMessage(ctx, Message{ AuthorAccountID: member.ID, Blocks: []MessageBlock{textBlock("hi")}, - }, string(ch.ID), TopicRef{Name: "general"}, "") + }, string(ch.ID), TopicRef{Name: "general", Create: true}, "") if err != nil { t.Fatalf("AppendMessage: %v", err) } diff --git a/go/internal/store/types.go b/go/internal/store/types.go index 6256c6e3..bbb7087a 100644 --- a/go/internal/store/types.go +++ b/go/internal/store/types.go @@ -292,12 +292,22 @@ type Topic struct { } // TopicRef addresses the topic a message targets: exactly one of ID or Name is -// set. A Name is get-or-created inside the append tx (agents address topics by -// name, the unit they can produce without a lookup); an ID names an existing -// topic, validated to live under the post's channel. +// set. A Name is resolved inside the append tx (agents address topics by name, +// the unit they can produce without a lookup); an ID names an existing topic, +// validated to live under the post's channel. +// +// Create gates get-or-create of a Name-ref (peer-DM record R5, amending the +// unconditional get-or-create of the zulip-threading model): when true, a Name +// that names no existing topic in the channel is minted; when false (the +// default), a name-miss is ErrNotFound rather than a silent create — the +// tool-edge guard against accidental topic sprawl now that agents must always +// name their topic. Ignored for an ID-ref. Internal producers that legitimately +// mint (the Setup seed, the frame-relay transcript path, test helpers) set it +// true; the external agent tool edge threads PostMessageRequest.create_topic. type TopicRef struct { - ID string - Name string + ID string + Name string + Create bool } // Message is the persisted unit of the comms layer: the durable human↔agent diff --git a/go/server/lifecycle_e2e_pgtest_test.go b/go/server/lifecycle_e2e_pgtest_test.go index fd32dc87..2543b2f8 100644 --- a/go/server/lifecycle_e2e_pgtest_test.go +++ b/go/server/lifecycle_e2e_pgtest_test.go @@ -231,7 +231,7 @@ func e2ePeerPostsUnderOwnAccount(t *testing.T, w *e2eWire, peerID store.AccountI client := w.dialPeer(t, peerContainer) resp, err := client.Comms(ctx, connect.NewRequest(&compassv1internal.CommsCallRequest{ CallId: "peer-post-1", - Call: &compassv1internal.CommsCallRequest_Post{Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(peerHome)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: peerPostText}}}}}, + Call: &compassv1internal.CommsCallRequest_Post{Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "peer-1"}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: peerPostText}}}}}, })) if err != nil { t.Fatalf("Comms(post) over the peer socket = %v, want the round-trip result", err) @@ -325,7 +325,7 @@ func e2ePeerFailsClosedAfterDespawn(t *testing.T, w *e2eWire, peerID store.Accou client := w.dialPeer(t, peerContainer) _, err := client.Comms(ctx, connect.NewRequest(&compassv1internal.CommsCallRequest{ CallId: "peer-post-after-despawn", - Call: &compassv1internal.CommsCallRequest_Post{Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(peerHome)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "should never commit"}}}}}, + Call: &compassv1internal.CommsCallRequest_Post{Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "peer-1"}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "should never commit"}}}}}, })) if err == nil { t.Fatal("peer comms post after despawn SUCCEEDED, want a fail-closed error (container + socket torn down)") @@ -374,12 +374,6 @@ func TestForeignOwnerDespawnOverTheWireIsIndistinguishableNoOp(t *testing.T) { } peerBID := store.AccountID(peerBResp.GetAgentAccountId()) peerBContainer := peerBResp.GetContainerName() - peerBAcc, err := w.store.GetAccount(ctx, peerBID) - if err != nil { - t.Fatalf("GetAccount(peer B) = %v", err) - } - peerBHome := peerBAcc.Agent.HomeChannelID - // The supervisor (owner A's agent) despawns owner B's peer over its OWN // socket. resp, err := w.supervisorClient.Lifecycle(ctx, connect.NewRequest(&compassv1internal.LifecycleCallRequest{ @@ -444,7 +438,7 @@ func TestForeignOwnerDespawnOverTheWireIsIndistinguishableNoOp(t *testing.T) { clientB := w.dialPeer(t, peerBContainer) postResp, err := clientB.Comms(ctx, connect.NewRequest(&compassv1internal.CommsCallRequest{ CallId: "peer-b-post-1", - Call: &compassv1internal.CommsCallRequest_Post{Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(peerBHome)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "peer B still alive"}}}}}, + Call: &compassv1internal.CommsCallRequest_Post{Post: &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: "peer-b"}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "peer B still alive"}}}}}, })) if err != nil { t.Fatalf("Comms(post) over peer B socket after the foreign despawn = %v, want success (peer B untouched)", err) diff --git a/go/server/offline_mention_e2e_pgtest_test.go b/go/server/offline_mention_e2e_pgtest_test.go index 506dc86e..5c54b382 100644 --- a/go/server/offline_mention_e2e_pgtest_test.go +++ b/go/server/offline_mention_e2e_pgtest_test.go @@ -195,11 +195,7 @@ func containerFor(handle string) string { return "compass-agent-" + handle } // routes/delivers synchronously) into the shared channel and returns its id. func (w *mentionE2EWire) post(t *testing.T, body string) string { t.Helper() - resp, err := w.comms.PostAsAccount(w.ctx, w.adminID, &compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(w.channel)}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: body}}}, - }) + resp, err := w.comms.PostAsAccount(w.ctx, w.adminID, &compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(w.channel)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: body}}}}) if err != nil { t.Fatalf("PostAsAccount(%q): %v", body, err) } diff --git a/go/server/otel_emission_pgtest_test.go b/go/server/otel_emission_pgtest_test.go index e23e53da..dbd8da26 100644 --- a/go/server/otel_emission_pgtest_test.go +++ b/go/server/otel_emission_pgtest_test.go @@ -111,11 +111,7 @@ func seedAdminChannel(t *testing.T, ctx context.Context) (dsn string, channelID func postOverSocket(t *testing.T, ctx context.Context, socketPath, channelID string) (*connect.Response[compassv1.PostMessageResponse], string) { t.Helper() client := newUDSCommsClient(t, socketPath) - resp, err := client.PostMessage(ctx, connect.NewRequest(&compassv1.PostMessageRequest{ - Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID}, - Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, - Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "hello otel"}}}, - })) + resp, err := client.PostMessage(ctx, connect.NewRequest(&compassv1.PostMessageRequest{Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "general"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: "hello otel"}}}})) if err != nil { t.Fatalf("PostMessage over socket: %v", err) } diff --git a/go/server/serve_seed.go b/go/server/serve_seed.go index 06c87273..576221f3 100644 --- a/go/server/serve_seed.go +++ b/go/server/serve_seed.go @@ -171,6 +171,7 @@ func postSetupThread(ctx context.Context, cm *comms.Comms, st *store.Store, comp if _, err := cm.PostAsAccount(ctx, compassID, &compassv1.PostMessageRequest{ Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(homeChannelID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: setupTopicName}, + CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: setupThreadBody}}}, ClientRequestId: setupThreadClientRequestIDPrefix + "-" + string(supervisor.ID), }); err != nil { diff --git a/packages/compass-agent/src/agent.test.ts b/packages/compass-agent/src/agent.test.ts index 2a642fb4..893e6f45 100644 --- a/packages/compass-agent/src/agent.test.ts +++ b/packages/compass-agent/src/agent.test.ts @@ -820,6 +820,24 @@ describe("CompassAgent — RT-3 turn-end delivery (RIG-1310 §8 deliver arm)", ( await h.close(); }); + // Peer-DM DL-292 (T0 step 2): the source channel + topic NAMES ride the + // deliver op and are plumbed per message to `formatDeliversForPrompt`, so the + // idle turn-start prompt names the reply target the agent must now address by + // name. Plumbed exactly the way `fromHandle` is (4th arg), keyed on Message.id. + test("an idle deliver renders the plumbed source channel and topic names", async () => { + const h = startDeliverAgent(); + h.agent.deliver(deliverMsg("m1", "ship it", "t-1"), "@peer", "", { + channelName: "product", + topicName: "launch", + }); + expect(h.session.agent.prompts).toHaveLength(1); + expect(h.session.agent.prompts[0]).toContain( + "Channel product › topic launch:", + ); + expect(h.session.agent.prompts[0]).toContain("ship it"); + await h.close(); + }); + test("one ack per injected message, carrying its message_id", async () => { const h = startDeliverAgent(); h.drive({ type: "agent_start" } as AgentSessionEvent); @@ -1645,6 +1663,24 @@ describe("CompassAgent — channel-borne steer (RIG-1310 §8 steer arm)", () => await h.close(); }); + // Peer-DM DL-292 (T0 step 2): a steer carries the source channel + topic + // NAMES on the wire (`SteerControl.channel_name`/`topic_name`), plumbed as the + // 4th arg exactly the way `fromHandle` is the 2nd. The idle-steer turn-start + // prompt renders them so the agent can reply naming its target. + test("an idle steer renders the plumbed source channel and topic names", async () => { + const h = startDeliverAgent(); + h.agent.steer(deliverMsg("s1", "look here", "t-1"), "matt", "", { + channelName: "product", + topicName: "launch", + }); + expect(h.session.agent.prompts).toHaveLength(1); + expect(h.session.agent.prompts[0]).toContain( + "Channel product › topic launch:", + ); + expect(h.session.agent.prompts[0]).toContain("look here"); + await h.close(); + }); + // Spin-up-window guard on the idle-steer arm (RIG-2488 review follow-up): the // idle arm optimistically sets `#turnActive = true` BEFORE `prompt()`'s // `agent_start` propagates (agent.ts:428), exactly as `#flushDelivers` does. @@ -1908,19 +1944,32 @@ describe("CompassAgent — SessionInjection op-kind signal (RIG-2486 T1)", () => }); }); -describe("formatDeliversForPrompt — coalescing format (RIG-1310 §8)", () => { - test("renders each message's text, in order, within its topic section", () => { +describe("formatDeliversForPrompt — coalescing format (RIG-1310 §8 / peer-DM DL-292)", () => { + // A per-message source-name map, keyed on Message.id, mirroring the way + // `#deliverFromHandles` is plumbed. Empty entries exercise the resolve-miss + // fallback (topic → id, channel → "(unknown channel)"). + const src = ( + entries: Record, + ): Map => + new Map(Object.entries(entries)); + + test("renders each message's text, in order, within its (channel, topic) section", () => { const batch = [ deliverMsg("m1", "first", "t-1"), deliverMsg("m2", "second", "t-1"), ]; - const out = formatDeliversForPrompt(batch); + const sources = src({ + m1: { channelName: "eng", topicName: "deploys" }, + m2: { channelName: "eng", topicName: "deploys" }, + }); + const out = formatDeliversForPrompt(batch, sources); expect(out).toContain("first"); expect(out).toContain("second"); // Order is preserved: first appears before second. expect(out.indexOf("first")).toBeLessThan(out.indexOf("second")); - // One topic, so one section header. - expect(out.match(/Topic /g)).toHaveLength(1); + // One (channel, topic) group, so one section header naming both names. + expect(out.match(/^Channel /gm)).toHaveLength(1); + expect(out).toContain("Channel eng › topic deploys:"); }); test("concatenates multiple text blocks and ignores ask blocks", () => { @@ -1978,69 +2027,100 @@ describe("formatDeliversForPrompt — coalescing format (RIG-1310 §8)", () => { expect(out).toContain("prod"); }); - // Two delivers in different topics render as two distinct per-topic sections, - // first-seen topic order, message order preserved within a section (D4). - test("groups two topics into two distinct per-topic sections", () => { + // The source channel + topic NAMES ride the deliver op and are rendered in + // the section header + reply cue — the peer-DM cutover (DL-292): the agent + // must name its reply target, and the tool now requires an explicit channel + // name, so the delivery has to SHOW that name. + test("names the source channel and topic from the plumbed source names", () => { + const batch = [deliverMsg("m1", "hello", "t-9")]; + const sources = src({ + m1: { channelName: "product", topicName: "launch" }, + }); + const out = formatDeliversForPrompt(batch, sources); + expect(out).toContain("Channel product › topic launch:"); + expect(out).toContain("hello"); + // The reply cue names BOTH required post params. + const cueLine = out.split("\n\n").at(-1) ?? ""; + expect(cueLine).toContain("channel"); + expect(cueLine).toContain("topic"); + }); + + // A server-side resolve miss leaves either name empty (a name miss never + // blocks a delivery); the renderer falls back — topic to its id, channel to a + // fixed placeholder — rather than printing a bare empty label. + test("falls back on a resolve miss: topic to id, channel to a placeholder", () => { + const batch = [deliverMsg("m1", "orphan", "t-42")]; + // No source entry at all → both names missing. + const out = formatDeliversForPrompt(batch); + expect(out).toContain("Channel (unknown channel) › topic t-42:"); + expect(out).toContain("orphan"); + }); + + // Two delivers in different (channel, topic) groups render as two distinct + // sections, first-seen order, message order preserved within a section (D4). + test("groups two topics into two distinct sections", () => { const batch = [ deliverMsg("m1", "alpha msg", "t-alpha"), deliverMsg("m2", "beta msg", "t-beta"), ]; - const out = formatDeliversForPrompt(batch); + const sources = src({ + m1: { channelName: "eng", topicName: "alpha" }, + m2: { channelName: "eng", topicName: "beta" }, + }); + const out = formatDeliversForPrompt(batch, sources); - expect(out).toContain("Topic t-alpha:"); - expect(out).toContain("Topic t-beta:"); + expect(out).toContain("Channel eng › topic alpha:"); + expect(out).toContain("Channel eng › topic beta:"); // Two distinct sections. - expect(out.match(/^Topic /gm)).toHaveLength(2); - // First-seen order: t-alpha before t-beta. - expect(out.indexOf("Topic t-alpha:")).toBeLessThan( - out.indexOf("Topic t-beta:"), + expect(out.match(/^Channel /gm)).toHaveLength(2); + // First-seen order: alpha before beta. + expect(out.indexOf("topic alpha:")).toBeLessThan( + out.indexOf("topic beta:"), ); expect(out.indexOf("alpha msg")).toBeLessThan(out.indexOf("beta msg")); }); - // Two delivers in the SAME topic stay in one section, message order kept. + // Two delivers in the SAME (channel, topic) stay in one section, order kept. test("keeps same-topic delivers in one section, in order", () => { const batch = [ deliverMsg("m1", "one", "t-1"), deliverMsg("m2", "two", "t-1"), ]; - const out = formatDeliversForPrompt(batch); + const sources = src({ + m1: { channelName: "eng", topicName: "deploys" }, + m2: { channelName: "eng", topicName: "deploys" }, + }); + const out = formatDeliversForPrompt(batch, sources); - expect(out.match(/^Topic /gm)).toHaveLength(1); + expect(out.match(/^Channel /gm)).toHaveLength(1); expect(out.indexOf("one")).toBeLessThan(out.indexOf("two")); }); - // RIG-2664: the coalesced prompt carries ONE terse reply cue for the whole - // batch, pointing at comms_post so the model posts its answer back to the - // channel instead of only narrating it into its session. Terse by design: the - // load-bearing "why" lives once in the manager SYSTEM.md, not re-paid per - // delivered batch. The cue does NOT re-list topic ids — each id already prints - // in its `Topic :` section header, so the list was pure duplication (Matt - // review, #583). - test("appends a single terse reply cue, id-free, pointing at comms_post", () => { + // RIG-2664 / DL-292: the coalesced prompt carries ONE terse reply cue for the + // whole batch, pointing at comms_post_message and naming the two required + // address params. Terse by design: the load-bearing "why" lives once in the + // manager SYSTEM.md, not re-paid per delivered batch. + test("appends a single terse reply cue naming channel and topic", () => { const batch = [ deliverMsg("m1", "alpha msg", "t-alpha"), deliverMsg("m2", "beta msg", "t-beta"), ]; - const out = formatDeliversForPrompt(batch); + const sources = src({ + m1: { channelName: "eng", topicName: "alpha" }, + m2: { channelName: "eng", topicName: "beta" }, + }); + const out = formatDeliversForPrompt(batch, sources); - // The cue is the trailing section (topics render first, one cue last). + // The cue is the trailing section (sections render first, one cue last). const cueLine = out.split("\n\n").at(-1) ?? ""; - // Exact-shape lock: the durable guard. Any re-expansion of the cue (verbose - // "why", per-message repetition, a re-added id list) breaks this equality. - expect(cueLine).toBe("Reply via comms_post_message to the relevant topic."); - // Anti-regression: the cue names NO topic id — re-adding the id list (the - // unbounded 14-tok-per-id duplication this PR removed) reintroduces them. - expect(cueLine).not.toContain("t-alpha"); - expect(cueLine).not.toContain("t-beta"); - // The ids are NOT lost — each still prints once, in its section header. - expect(out).toContain("Topic t-alpha:"); - expect(out).toContain("Topic t-beta:"); + // Exact-shape lock: the durable guard. Any re-expansion of the cue breaks + // this equality. + expect(cueLine).toBe( + "Reply via comms_post_message, naming the channel and topic above.", + ); // Exactly one cue for the whole batch, not one per message/topic. expect(out.match(/comms_post_message/g)).toHaveLength(1); - // Terse tripwire: the verbose block-0 "why" is NOT re-paid in the cue. Uses - // a phrase that actually appears in the block-0 "why" (SYSTEM.md), so a - // re-expansion that pastes that reasoning back into the cue trips it. + // Terse tripwire: the verbose block-0 "why" is NOT re-paid in the cue. expect(out).not.toContain("session log"); }); diff --git a/packages/compass-agent/src/agent.ts b/packages/compass-agent/src/agent.ts index 838faa8b..21bdec0e 100644 --- a/packages/compass-agent/src/agent.ts +++ b/packages/compass-agent/src/agent.ts @@ -134,6 +134,16 @@ export class CompassAgent { // Populated at enqueue beside `#deliverQueue.push`, read + deleted per message // at flush. readonly #deliverTraceparents = new Map(); + // Peer-DM record DL-292 (T0 step 2): the denormalized SOURCE channel + topic + // NAMES per queued deliver, keyed on `Message.id`, EXACTLY mirroring + // `#deliverFromHandles`. A deliver coalesces into `#deliverQueue` and is + // rendered later at the turn-end flush, so its channel/topic names (carried + // on the wire deliver control, resolved server-side) must be stashed here to + // travel with the message to `formatDeliversForPrompt` at flush time. Either + // name is EMPTY on a server-side resolve miss — a name miss never blocks a + // delivery, and the renderer falls back to the id. Populated at enqueue + // beside `#deliverQueue.push`, read + deleted per message at flush. + readonly #deliverSourceNames = new Map(); // Trace-continuity (design record §T2): the ids of every channel message that // has fed the CURRENT turn span, in arrival order — the source for the // `compass.message.ids` stamp. T1's `stampActiveTurn` OVERWRITES the @@ -280,7 +290,12 @@ export class CompassAgent { // The replay barrier is enforced UPSTREAM at the control source (a // pre-ReplayComplete immediate op is refused-and-counted before it reaches // this handle, control-source.ts), so this method does not re-check it. - deliver(msg: Message, fromHandle = "", traceparent = ""): void { + deliver( + msg: Message, + fromHandle = "", + traceparent = "", + sourceNames: DeliverSourceNames = { channelName: "", topicName: "" }, + ): void { // A message with no id cannot be acked or deduped — fail-visible, never a // silent drop (and never injected, since there would be no receipt for it). if (msg.id === "") { @@ -331,6 +346,7 @@ export class CompassAgent { this.#deliverQueue.push(msg); this.#deliverFromHandles.set(msg.id, fromHandle); this.#deliverTraceparents.set(msg.id, traceparent); + this.#deliverSourceNames.set(msg.id, sourceNames); // Idle deliver starts a turn immediately (frozen :799/:810); a mid-turn // deliver waits for the `agent_end` flush. "Idle" consults BOTH the // event-derived `#turnActive` AND the authoritative `#session.isStreaming` @@ -412,7 +428,12 @@ export class CompassAgent { // "injected", emitted at injection time (frozen :540-546, :283). The replay // barrier is enforced UPSTREAM at the control source (control-source.ts), so // this method does not re-check it — same as `deliver`. - steer(msg: Message, fromHandle = "", traceparent = ""): void { + steer( + msg: Message, + fromHandle = "", + traceparent = "", + sourceNames: DeliverSourceNames = { channelName: "", topicName: "" }, + ): void { // A message with no id cannot be acked or deduped — fail-visible, never a // silent drop (and never injected, since there would be no receipt for it). // Mirrors deliver's empty-id guard. @@ -454,8 +475,13 @@ export class CompassAgent { this.#processedMessageIds.add(msg.id); // The mention text, formatted once via the single deliver formatter (a // single-element batch — no new formatter). Used as the mid-turn steering - // message's content AND as the idle turn-start prompt. - const content = formatDeliversForPrompt([msg]); + // message's content AND as the idle turn-start prompt. The source names + // ride the steer op (`SteerControl.channel_name`/`topic_name`), plumbed the + // same per-message way deliver plumbs them. + const content = formatDeliversForPrompt( + [msg], + new Map([[msg.id, sourceNames]]), + ); // Idle is computed exactly as deliver does: the event-derived `#turnActive` // AND the authoritative `#session.isStreaming` (closes the control-prompt // spin-up race the event flag alone cannot — see the deliver comment). @@ -666,7 +692,10 @@ export class CompassAgent { this.#forgeQueue = []; if (delivers.length === 0 && forges.length === 0) return; const sections: string[] = []; - if (delivers.length > 0) sections.push(formatDeliversForPrompt(delivers)); + if (delivers.length > 0) + sections.push( + formatDeliversForPrompt(delivers, this.#deliverSourceNames), + ); if (forges.length > 0) sections.push( formatForgeNotifications(forges.map((e) => e.notification)), @@ -717,6 +746,7 @@ export class CompassAgent { this.#processedMessageIds.delete(msg.id); this.#deliverFromHandles.delete(msg.id); this.#deliverTraceparents.delete(msg.id); + this.#deliverSourceNames.delete(msg.id); } if (delivers.length > 0) { this.#onUnmapped({ @@ -766,6 +796,7 @@ export class CompassAgent { const fromHandle = this.#deliverFromHandles.get(msg.id) ?? ""; this.#deliverFromHandles.delete(msg.id); this.#deliverTraceparents.delete(msg.id); + this.#deliverSourceNames.delete(msg.id); this.#emitInjection(SessionInjectionKind.DELIVER, msg.id, fromHandle); } // FORGE acks: one ForgeNotificationAck frame (advances the Server's @@ -890,6 +921,19 @@ export class CompassAgent { } } +/** + * The denormalized source NAMES for a delivered channel message (peer-DM record + * DL-292), carried on the wire `DeliverControl.channel_name`/`topic_name` and + * plumbed per message to `formatDeliversForPrompt` so the render names the + * source channel + topic and the reply cue names both required post params. + * Either is EMPTY on a server-side resolve miss (a name miss never blocks a + * delivery); the renderer falls back per label. + */ +export interface DeliverSourceNames { + readonly channelName: string; + readonly topicName: string; +} + // Coalesce a batch of delivered channel messages into ONE prompt input string // (RIG-1310 §8). Pure + exported so it is unit-testable. The batch is GROUPED // per topic (`msg.topicId`) at format time: a topic belongs to exactly one @@ -905,24 +949,40 @@ export class CompassAgent { // ask is a separate surface. A blank text (a message with no rendered blocks) // still contributes its slot so a section is a faithful 1:1 with its group. // -// RIG-2664: the coalesced prompt ends with ONE terse reply cue for the whole -// batch, pointing at `comms_post_message`, closing the gap a bare +// RIG-2664 / peer-DM DL-292: the coalesced prompt renders each delivery grouped +// under a `Channel › topic :` header and ends with ONE terse reply +// cue for the whole batch, pointing at `comms_post_message` and naming the two +// required address params (channel + topic). This closes the gap a bare // `Topic :\n` digest left open (the model would otherwise narrate its -// reply into its own turn, which the operator never reads). The cue does NOT -// re-list the batch's topic ids — each id already prints in its `Topic :` -// section header one line up, so the model addresses the topic from the section -// it is replying to; re-listing them only duplicated 32-char hex ids per batch -// (~14 tokens each, unbounded in batch size) with no added addressing. Terse by -// design: this cue rides EVERY delivered batch, so the load-bearing "why" — the -// operator reads the channel, not your session log — lives once in the manager -// block-0 SYSTEM.md and is not re-paid here per delivery. -export function formatDeliversForPrompt(batch: readonly Message[]): string { +// reply into its own turn, which the operator never reads — and could not +// address a channel it was never shown the NAME of, now that the post tool +// requires an explicit channel name). The SOURCE names ride the deliver op +// (`DeliverControl.channel_name`/`topic_name`, resolved server-side) and are +// plumbed here per message via `sources`, keyed on `Message.id`, exactly the way +// `#deliverFromHandles` is plumbed. A name is EMPTY on a server-side resolve +// miss (a name miss never blocks a delivery), so each label falls back: topic to +// its id (`Message.topic_id`), channel to a fixed "(unknown channel)" placeholder +// (the comms `Message` carries no channel id to fall back to). Terse by design: +// this cue rides EVERY delivered batch, so the load-bearing "why" — the operator +// reads the channel, not your session log — lives once in the manager block-0 +// SYSTEM.md and is not re-paid here per delivery. +export function formatDeliversForPrompt( + batch: readonly Message[], + sources: ReadonlyMap = new Map(), +): string { // An empty batch has nothing to reply to — return "" rather than a bare cue // (both prod callers guard against this, but the exported pure fn's contract // is pinned here regardless). if (batch.length === 0) return ""; - const groups = new Map(); + // Group by the rendered (channel, topic) header, first-seen order. The map + // value carries the label so the header is computed once per group. + const groups = new Map(); for (const msg of batch) { + const names = sources.get(msg.id); + const channelLabel = names?.channelName || "(unknown channel)"; + const topicLabel = names?.topicName || msg.topicId; + const key = `${channelLabel}\u0000${topicLabel}`; + const header = `Channel ${channelLabel} › topic ${topicLabel}:`; const text = msg.blocks .flatMap((block) => { if (block.block.case === "text") return [block.block.value]; @@ -933,15 +993,17 @@ export function formatDeliversForPrompt(batch: readonly Message[]): string { return []; }) .join("\n"); - const existing = groups.get(msg.topicId); - if (existing) existing.push(text); - else groups.set(msg.topicId, [text]); + const existing = groups.get(key); + if (existing) existing.texts.push(text); + else groups.set(key, { header, texts: [text] }); } const sections = Array.from( - groups, - ([topicId, texts]) => `Topic ${topicId}:\n${texts.join("\n\n")}`, + groups.values(), + ({ header, texts }) => `${header}\n${texts.join("\n\n")}`, + ); + sections.push( + "Reply via comms_post_message, naming the channel and topic above.", ); - sections.push("Reply via comms_post_message to the relevant topic."); return sections.join("\n\n"); } diff --git a/packages/compass-agent/src/cli.ts b/packages/compass-agent/src/cli.ts index 2e69803e..54e07fb9 100644 --- a/packages/compass-agent/src/cli.ts +++ b/packages/compass-agent/src/cli.ts @@ -1028,8 +1028,10 @@ export async function main( // `agent` is assigned — so the `agent?.` guard never actually sees undefined. let agent: CompassAgent | undefined; const control = createSocketControlSource(transport, { - steer: (msg, fromHandle) => agent?.steer(msg, fromHandle), - deliver: (msg, fromHandle) => agent?.deliver(msg, fromHandle), + steer: (msg, fromHandle, sourceNames) => + agent?.steer(msg, fromHandle, "", sourceNames), + deliver: (msg, fromHandle, sourceNames) => + agent?.deliver(msg, fromHandle, "", sourceNames), forgeNotification: (notification, ackRail) => agent?.forgeNotification(notification, ackRail), }); diff --git a/packages/compass-agent/src/comms.test.ts b/packages/compass-agent/src/comms.test.ts index 8a3f294e..dad244e0 100644 --- a/packages/compass-agent/src/comms.test.ts +++ b/packages/compass-agent/src/comms.test.ts @@ -315,32 +315,58 @@ describe("comms parameter schemas", () => { // deliberately allowed rather than silently caught. test("post rejects an empty or whitespace-only text", () => { expect(rejects(postParameters, {})).toBe(true); - expect(rejects(postParameters, { text: "", topic: "t" })).toBe(true); - expect(rejects(postParameters, { text: " ", topic: "t" })).toBe(true); - expect(rejects(postParameters, { text: "\n", topic: "t" })).toBe(true); - expect(rejects(postParameters, { text: "\t\t", topic: "t" })).toBe(true); + expect( + rejects(postParameters, { text: "", topic: "t", channel: "c" }), + ).toBe(true); + expect( + rejects(postParameters, { text: " ", topic: "t", channel: "c" }), + ).toBe(true); + expect( + rejects(postParameters, { text: "\n", topic: "t", channel: "c" }), + ).toBe(true); + expect( + rejects(postParameters, { text: "\t\t", topic: "t", channel: "c" }), + ).toBe(true); // Not whitespace to `trim()`, so it passes — asserted so the bound's // real edge is recorded rather than assumed. - expect(rejects(postParameters, { text: "\u200b", topic: "t" })).toBe(false); - expect(rejects(postParameters, { text: "hi", topic: "t" })).toBe(false); - expect(rejects(postParameters, { text: " hi ", topic: "t" })).toBe(false); + expect( + rejects(postParameters, { text: "\u200b", topic: "t", channel: "c" }), + ).toBe(false); + expect( + rejects(postParameters, { text: "hi", topic: "t", channel: "c" }), + ).toBe(false); + expect( + rejects(postParameters, { text: " hi ", topic: "t", channel: "c" }), + ).toBe(false); }); // Topic is REQUIRED: a post with no topic is rejected (threading is // topic-level, there is no `parent` fallback). Non-blank and ≤120 chars. test("post requires a non-blank topic of at most 120 chars", () => { - expect(rejects(postParameters, { text: "hi" })).toBe(true); - expect(rejects(postParameters, { text: "hi", topic: "" })).toBe(true); - expect(rejects(postParameters, { text: "hi", topic: " " })).toBe(true); + expect(rejects(postParameters, { text: "hi", channel: "c" })).toBe(true); expect( - rejects(postParameters, { text: "hi", topic: "x".repeat(121) }), + rejects(postParameters, { text: "hi", topic: "", channel: "c" }), ).toBe(true); expect( - rejects(postParameters, { text: "hi", topic: "x".repeat(120) }), + rejects(postParameters, { text: "hi", topic: " ", channel: "c" }), + ).toBe(true); + expect( + rejects(postParameters, { + text: "hi", + topic: "x".repeat(121), + channel: "c", + }), + ).toBe(true); + expect( + rejects(postParameters, { + text: "hi", + topic: "x".repeat(120), + channel: "c", + }), + ).toBe(false); + expect( + rejects(postParameters, { text: "hi", topic: "planning", channel: "c" }), ).toBe(false); - expect(rejects(postParameters, { text: "hi", topic: "planning" })).toBe( - false, - ); }); test("list bounds limit to 1-100 and leaves it optional", () => { @@ -351,21 +377,49 @@ describe("comms parameter schemas", () => { expect(rejects(listParameters, {})).toBe(false); }); - // `""` is not "omitted": both execute bodies gate on truthiness, so an empty - // channel id took the home-channel branch and a model whose channel lookup - // missed posted to its own channel instead of learning it was wrong. `text` - // was already guarded against exactly this; `channel_id` was not. - test("an empty channel_id is rejected rather than silently meaning home", () => { + // post/ask REQUIRE an explicit `channel` NAME (peer-DM record R1/R2/R5: the + // home default is dropped at the tool level). A blank name is rejected by the + // same `.narrow` idiom `text` uses. list is EXEMPT and keeps omit-=home, but a + // blank list channel is still a reject (an empty string is not "omitted"). + test("post requires a non-blank channel name; a blank channel is rejected", () => { + // Omitting channel entirely is now a reject for post — the home default is + // gone; the agent must NAME its target channel. + expect(rejects(postParameters, { text: "hi", topic: "t" })).toBe(true); expect( - rejects(postParameters, { text: "hi", topic: "t", channel_id: "" }), + rejects(postParameters, { text: "hi", topic: "t", channel: "" }), ).toBe(true); expect( - rejects(postParameters, { text: "hi", topic: "t", channel_id: " " }), + rejects(postParameters, { text: "hi", topic: "t", channel: " " }), ).toBe(true); - expect(rejects(listParameters, { channel_id: "" })).toBe(true); - // Omission remains the documented way to mean the home channel. - expect(rejects(postParameters, { text: "hi", topic: "t" })).toBe(false); + // A named channel passes. + expect( + rejects(postParameters, { text: "hi", topic: "t", channel: "general" }), + ).toBe(false); + // list keeps omit-=home; a blank list channel is still a reject. + expect(rejects(listParameters, { channel: "" })).toBe(true); + expect(rejects(listParameters, { channel: " " })).toBe(true); expect(rejects(listParameters, {})).toBe(false); + expect(rejects(listParameters, { channel: "general" })).toBe(false); + }); + + // create_topic is optional on post; when present it must be a boolean. + test("create_topic is an optional boolean on post", () => { + expect( + rejects(postParameters, { + text: "hi", + topic: "t", + channel: "c", + create_topic: true, + }), + ).toBe(false); + expect( + rejects(postParameters, { + text: "hi", + topic: "t", + channel: "c", + create_topic: "yes", + }), + ).toBe(true); }); // The bound the model is SHOWN, not the one enforced behind it. A `.narrow` @@ -392,16 +446,12 @@ describe("comms parameter schemas", () => { // A bare string: the non-blank rule is GONE from what the model sees. expect(postProps.text?.type).toBe("string"); expect(postProps.text?.minLength).toBeUndefined(); - expect(postParameters.get("topic").description).toContain( - "creates the topic", - ); + expect(postParameters.get("topic").description).toContain("create_topic"); expect(postProps.text?.pattern).toBeUndefined(); expect(postParameters.get("text").description).toContain("not be blank"); - expect(postParameters.get("channel_id").description).toContain( - "empty string is rejected", - ); - expect(listParameters.get("channel_id").description).toContain( - "empty string is rejected", + expect(postParameters.get("channel").description).toContain("required"); + expect(listParameters.get("channel").description).toContain( + "omit entirely for your home channel", ); // Contrast: an expressible bound survives as real schema, which is what @@ -423,6 +473,7 @@ describe("comms_post_message", () => { const result = await exec(post, "tc-42", { text: "hello there", topic: "planning", + channel: "eng", }); const req = transport.requests[0]; @@ -446,36 +497,62 @@ describe("comms_post_message", () => { // fallback). Asserted at the schema, since that is what the agent loop runs // before `execute`, and the FakeTransport must never be touched. test("a post without topic is rejected before any broker call", async () => { - expect(postParameters({ text: "hi" }) instanceof ArkErrors).toBe(true); + expect( + postParameters({ text: "hi", channel: "c" }) instanceof ArkErrors, + ).toBe(true); // The reject happens at the schema the agent loop runs before `execute`, // so the broker is never reached: a fake transport handed to no call // records nothing. const transport = new FakeTransport(postResult("m-1", "t-1")); - expect(postParameters({ text: "hi", topic: "" }) instanceof ArkErrors).toBe( - true, - ); expect( - postParameters({ text: "hi", topic: " " }) instanceof ArkErrors, + postParameters({ text: "hi", topic: "", channel: "c" }) instanceof + ArkErrors, ).toBe(true); expect( - postParameters({ text: "hi", topic: "x".repeat(121) }) instanceof + postParameters({ text: "hi", topic: " ", channel: "c" }) instanceof ArkErrors, ).toBe(true); expect( - postParameters({ text: "hi", topic: "x".repeat(120) }) instanceof - ArkErrors, + postParameters({ + text: "hi", + topic: "x".repeat(121), + channel: "c", + }) instanceof ArkErrors, + ).toBe(true); + expect( + postParameters({ + text: "hi", + topic: "x".repeat(120), + channel: "c", + }) instanceof ArkErrors, ).toBe(false); expect(transport.requests).toHaveLength(0); }); + // A post without `channel` must be a schema reject BEFORE any broker call — + // the home default is dropped (peer-DM R2), so an explicit channel NAME is + // mandatory. Asserted at the schema, since that is what the agent loop runs + // before `execute`, and the FakeTransport must never be touched. + test("a post without channel is rejected before any broker call", async () => { + const transport = new FakeTransport(postResult("m-1", "t-1")); + expect( + postParameters({ text: "hi", topic: "t" }) instanceof ArkErrors, + ).toBe(true); + expect( + postParameters({ text: "hi", topic: "t", channel: "" }) instanceof + ArkErrors, + ).toBe(true); + expect(transport.requests).toHaveLength(0); + }); + // The wire contract: a valid post fills the `topic` oneof with the // `topicName` case carrying the caller's topic (get-or-create by name). test("a valid post carries topicName on the wire", async () => { const transport = new FakeTransport(postResult("m-9", "t-9")); const post = tool(new CommsBroker(transport), "comms_post_message"); - await exec(post, "tc-9", { text: "hi", topic: "deploys" }); + await exec(post, "tc-9", { text: "hi", topic: "deploys", channel: "eng" }); const call = transport.requests[0]?.call; if (call?.case !== "post") throw new Error("expected a post call"); @@ -485,24 +562,16 @@ describe("comms_post_message", () => { }); }); - test("omitted channel_id leaves the container oneof unset (home-channel default)", async () => { - const transport = new FakeTransport(postResult("m-1", "t-1")); - const post = tool(new CommsBroker(transport), "comms_post_message"); - - await exec(post, "tc-1", { text: "hi", topic: "general" }); - - const call = transport.requests[0]?.call; - if (call?.case !== "post") throw new Error("expected a post call"); - expect(call.value.container.case).toBeUndefined(); - }); - - test("channel_id threads through when supplied", async () => { + // The channel NAME rides the container's `channelId` arm (the proto field + // name is unchanged; the Go comms edge resolves the name → id). There is no + // home default at the tool level — every post names its channel. + test("the channel name rides the container channelId arm on the wire", async () => { const transport = new FakeTransport(postResult("m-2", "t-2")); const post = tool(new CommsBroker(transport), "comms_post_message"); await exec(post, "tc-2", { text: "a reply", - channel_id: "chan-b", + channel: "eng", topic: "general", }); @@ -510,8 +579,33 @@ describe("comms_post_message", () => { if (call?.case !== "post") throw new Error("expected a post call"); expect(call.value.container).toEqual({ case: "channelId", - value: "chan-b", + value: "eng", + }); + }); + + // create_topic defaults to false on the wire and passes through when set — + // the gate that turns an unknown topic name from an error into a mint + // (peer-DM DL-293). + test("create_topic defaults false and passes through on the wire", async () => { + const transport = new FakeTransport(postResult("m-3", "t-3")); + const post = tool(new CommsBroker(transport), "comms_post_message"); + + await exec(post, "tc-ct0", { text: "hi", topic: "new", channel: "eng" }); + const call0 = transport.requests[0]?.call; + if (call0?.case !== "post") throw new Error("expected a post call"); + expect(call0.value.createTopic).toBe(false); + + const transport2 = new FakeTransport(postResult("m-4", "t-4")); + const post2 = tool(new CommsBroker(transport2), "comms_post_message"); + await exec(post2, "tc-ct1", { + text: "hi", + topic: "new", + channel: "eng", + create_topic: true, }); + const call1 = transport2.requests[0]?.call; + if (call1?.case !== "post") throw new Error("expected a post call"); + expect(call1.value.createTopic).toBe(true); }); test("an error result throws carrying the code and the detail", async () => { @@ -520,7 +614,11 @@ describe("comms_post_message", () => { ); const post = tool(new CommsBroker(transport), "comms_post_message"); - const err = await exec(post, "tc-3", { text: "hi", topic: "t" }).then( + const err = await exec(post, "tc-3", { + text: "hi", + topic: "t", + channel: "c", + }).then( () => undefined, (e: unknown) => e as Error, ); @@ -544,7 +642,9 @@ describe("comms_post_message", () => { ); const post = tool(new CommsBroker(transport), "comms_post_message"); - const text = textOf(await exec(post, "tc-5", { text: "hi", topic: "t" })); + const text = textOf( + await exec(post, "tc-5", { text: "hi", topic: "t", channel: "c" }), + ); expect(text.split("\n")).toHaveLength(1); expect(text).not.toContain("escalation granted"); @@ -560,7 +660,9 @@ describe("comms_post_message", () => { ); const post = tool(new CommsBroker(transport), "comms_post_message"); - const text = textOf(await exec(post, "tc-6", { text: "hi", topic: "t" })); + const text = textOf( + await exec(post, "tc-6", { text: "hi", topic: "t", channel: "c" }), + ); expect(text.split("\n")).toHaveLength(1); expect(text).toBe("Posted message m-1 to topic (malformed)."); @@ -586,7 +688,11 @@ describe("comms_post_message", () => { ); const post = tool(new CommsBroker(transport), "comms_post_message"); - const err = await exec(post, "tc-7", { text: "hi", topic: "t" }).then( + const err = await exec(post, "tc-7", { + text: "hi", + topic: "t", + channel: "c", + }).then( () => undefined, (e: unknown) => e as Error, ); @@ -605,7 +711,11 @@ describe("comms_post_message", () => { ); const post = tool(new CommsBroker(transport), "comms_post_message"); - const err = await exec(post, "tc-8", { text: "hi", topic: "t" }).then( + const err = await exec(post, "tc-8", { + text: "hi", + topic: "t", + channel: "c", + }).then( () => undefined, (e: unknown) => e as Error, ); @@ -619,7 +729,7 @@ describe("comms_post_message", () => { const post = tool(new CommsBroker(transport), "comms_post_message"); await expect( - exec(post, "tc-4", { text: "hi", topic: "t" }), + exec(post, "tc-4", { text: "hi", topic: "t", channel: "c" }), ).rejects.toThrow(/comms_post_message/); }); }); @@ -630,7 +740,7 @@ describe("comms_list_messages", () => { const list = tool(new CommsBroker(transport), "comms_list_messages"); await exec(list, "tc-5", { - channel_id: "chan-a", + channel: "chan-a", limit: 10, before_message_id: "m-9", }); @@ -647,10 +757,11 @@ describe("comms_list_messages", () => { expect(req.call.value.snapshotSeq).toBe(0n); }); - // An omitted channel_id is not "no channel" — it is the documented request - // for the agent's home channel, which the Server resolves. Sending an empty - // `channelId` instead would name a channel the agent was never given. - test("an omitted channel_id leaves the container oneof unset", async () => { + // An omitted channel is not "no channel" — it is the documented request for + // the agent's home channel, which the Server resolves (list is EXEMPT from the + // required-channel rule). Sending an empty `channelId` instead would name a + // channel the agent was never given. + test("an omitted channel leaves the container oneof unset", async () => { const transport = new FakeTransport(listResult()); const list = tool(new CommsBroker(transport), "comms_list_messages"); @@ -1286,7 +1397,7 @@ describe("comms_list_messages", () => { "comms_list_messages", ), "tc-30", - { channel_id: "c-1" }, + { channel: "c-1" }, ), ); const f = fenceOf(text); @@ -1344,7 +1455,7 @@ describe("comms_list_messages", () => { "comms_list_messages", ), "tc-32", - { channel_id: "c-1" }, + { channel: "c-1" }, ), ); const f = fenceOf(text); @@ -1393,7 +1504,7 @@ describe("comms_list_messages", () => { "comms_list_messages", ), "tc-33", - { channel_id: "c-1" }, + { channel: "c-1" }, ), ); const f = fenceOf(text); @@ -1464,7 +1575,7 @@ describe("comms_list_messages", () => { "comms_list_messages", ), "tc-34", - { channel_id: "c-1" }, + { channel: "c-1" }, ), ); const f = fenceOf(text); @@ -1527,7 +1638,7 @@ describe("comms_list_messages", () => { "comms_list_messages", ), "tc-31", - { channel_id: "c-1" }, + { channel: "c-1" }, ), ); const f = fenceOf(text); @@ -1579,7 +1690,7 @@ describe("comms_list_messages", () => { "comms_list_messages", ), "tc-34", - { channel_id: "c-1" }, + { channel: "c-1" }, ), ); const f = fenceOf(text); @@ -1974,9 +2085,12 @@ describe("comms_post_ask parameter schema", () => { postAskParameters(params) instanceof ArkErrors; test("requires at least one question", () => { - expect(rejects({ questions: [] })).toBe(true); + expect(rejects({ questions: [], channel: "c" })).toBe(true); expect( - rejects({ questions: [{ id: "q1", question: "ok?", options: [] }] }), + rejects({ + questions: [{ id: "q1", question: "ok?", options: [] }], + channel: "c", + }), ).toBe(false); }); @@ -1988,13 +2102,20 @@ describe("comms_post_ask parameter schema", () => { { id: "q1", question: "a?", options: [] }, { id: "q1", question: "b?", options: [] }, ], + channel: "c", }), ).toBe(true); expect( - rejects({ questions: [{ id: "", question: "a?", options: [] }] }), + rejects({ + questions: [{ id: "", question: "a?", options: [] }], + channel: "c", + }), ).toBe(true); expect( - rejects({ questions: [{ id: " ", question: "a?", options: [] }] }), + rejects({ + questions: [{ id: " ", question: "a?", options: [] }], + channel: "c", + }), ).toBe(true); expect( rejects({ @@ -2002,6 +2123,7 @@ describe("comms_post_ask parameter schema", () => { { id: "q1", question: "a?", options: [] }, { id: "q2", question: "b?", options: [] }, ], + channel: "c", }), ).toBe(false); }); @@ -2010,33 +2132,50 @@ describe("comms_post_ask parameter schema", () => { // rejected — the same idiom postParameters.topic uses. test("topic is optional but rejects blank or overlong values", () => { expect( - rejects({ questions: [{ id: "q1", question: "a?", options: [] }] }), + rejects({ + questions: [{ id: "q1", question: "a?", options: [] }], + channel: "c", + }), ).toBe(false); expect( rejects({ questions: [{ id: "q1", question: "a?", options: [] }], topic: "", + channel: "c", }), ).toBe(true); expect( rejects({ questions: [{ id: "q1", question: "a?", options: [] }], topic: " ", + channel: "c", }), ).toBe(true); expect( rejects({ questions: [{ id: "q1", question: "a?", options: [] }], topic: "x".repeat(121), + channel: "c", }), ).toBe(true); }); - test("an empty channel_id is rejected rather than silently meaning home", () => { + // channel is REQUIRED on ask (peer-DM R2: the home default is dropped). An + // omitted or blank channel is a reject. + test("ask requires a non-blank channel name", () => { + expect( + rejects({ questions: [{ id: "q1", question: "a?", options: [] }] }), + ).toBe(true); + expect( + rejects({ + questions: [{ id: "q1", question: "a?", options: [] }], + channel: "", + }), + ).toBe(true); expect( rejects({ questions: [{ id: "q1", question: "a?", options: [] }], - channel_id: "", + channel: " ", }), ).toBe(true); }); @@ -2080,6 +2219,7 @@ describe("comms_post_ask", () => { }, ], topic: "deploys", + channel: "c", }); expect(transport.requests).toHaveLength(1); @@ -2117,6 +2257,7 @@ describe("comms_post_ask", () => { options: [{ label: "a" }, { label: "b" }, { label: "c" }], }, ], + channel: "c", }); const call = transport.requests[0]?.call; @@ -2138,6 +2279,7 @@ describe("comms_post_ask", () => { await exec(ask, "tc-x", { questions: [{ id: "q1", question: "a?", options: [{ label: "a" }] }], + channel: "c", }); const call = transport.requests[0]?.call; @@ -2152,43 +2294,61 @@ describe("comms_post_ask", () => { expect(q?.timedOut).toBe(false); }); - // (c) Home-channel default: omitted channel_id leaves the container unset. - test("omitted channel_id leaves the container oneof unset", async () => { - const transport = new FakeTransport(askPostResult("a-3", "t-a")); + // The channel NAME rides the container's `channelId` arm (proto field name + // unchanged; the Go comms edge resolves name → id). No home default at the + // tool level — every ask names its channel. + test("the channel name rides the container channelId arm on the wire", async () => { + const transport = new FakeTransport(askPostResult("a-4", "t-a")); const ask = askTool(new CommsBroker(transport)); - await exec(ask, "tc-3", { + await exec(ask, "tc-4", { questions: [{ id: "q1", question: "a?", options: [] }], + channel: "eng", }); const call = transport.requests[0]?.call; if (call?.case !== "post") throw new Error("expected a post call"); - expect(call.value.container.case).toBeUndefined(); + expect(call.value.container).toEqual({ + case: "channelId", + value: "eng", + }); }); - test("channel_id threads through when supplied", async () => { - const transport = new FakeTransport(askPostResult("a-4", "t-a")); + // create_topic defaults false and passes through on the wire. + test("create_topic defaults false and passes through on the wire", async () => { + const transport = new FakeTransport(askPostResult("a-ct0", "t-a")); const ask = askTool(new CommsBroker(transport)); - - await exec(ask, "tc-4", { + await exec(ask, "tc-ct0", { questions: [{ id: "q1", question: "a?", options: [] }], - channel_id: "chan-b", + channel: "eng", }); + const call0 = transport.requests[0]?.call; + if (call0?.case !== "post") throw new Error("expected a post call"); + expect(call0.value.createTopic).toBe(false); - const call = transport.requests[0]?.call; - if (call?.case !== "post") throw new Error("expected a post call"); - expect(call.value.container).toEqual({ - case: "channelId", - value: "chan-b", + const transport2 = new FakeTransport(askPostResult("a-ct1", "t-a")); + const ask2 = askTool(new CommsBroker(transport2)); + await exec(ask2, "tc-ct1", { + questions: [{ id: "q1", question: "a?", options: [] }], + channel: "eng", + create_topic: true, }); + const call1 = transport2.requests[0]?.call; + if (call1?.case !== "post") throw new Error("expected a post call"); + expect(call1.value.createTopic).toBe(true); }); - // An empty-string channel_id is a schema reject before any broker call. - test("an empty channel_id is rejected before any broker call", async () => { + // A missing or empty channel is a schema reject before any broker call. + test("a missing or empty channel is rejected before any broker call", async () => { + expect( + postAskParameters({ + questions: [{ id: "q1", question: "a?", options: [] }], + }) instanceof ArkErrors, + ).toBe(true); expect( postAskParameters({ questions: [{ id: "q1", question: "a?", options: [] }], - channel_id: "", + channel: "", }) instanceof ArkErrors, ).toBe(true); @@ -2201,6 +2361,7 @@ describe("comms_post_ask", () => { { id: "q1", question: "a?", options: [] }, { id: "q1", question: "b?", options: [] }, ], + channel: "c", }) instanceof ArkErrors, ).toBe(true); expect(transport.requests).toHaveLength(0); @@ -2213,6 +2374,7 @@ describe("comms_post_ask", () => { await exec(ask, "tc-6", { questions: [{ id: "q1", question: "a?", options: [] }], + channel: "c", }); const call = transport.requests[0]?.call; @@ -2226,6 +2388,7 @@ describe("comms_post_ask", () => { await exec(ask, "tc-7", { questions: [{ id: "q1", question: "a?", options: [] }], + channel: "c", topic: "planning", }); @@ -2242,6 +2405,7 @@ describe("comms_post_ask", () => { await exec(ask, "tc-8", { questions: [{ id: "q1", question: "a?", options: [] }], + channel: "c", }); const call = transport.requests[0]?.call; @@ -2258,6 +2422,7 @@ describe("comms_post_ask", () => { const result = await exec(ask, "tc-9", { questions: [{ id: "q1", question: "a?", options: [] }], + channel: "c", }); const text = textOf(result); @@ -2288,6 +2453,7 @@ describe("comms_post_ask", () => { const text = textOf( await exec(ask, "tc-nl", { questions: [{ id: "q1", question: "a?", options: [] }], + channel: "c", }), ); expect(text.split("\n")).toHaveLength(1); diff --git a/packages/compass-agent/src/comms.ts b/packages/compass-agent/src/comms.ts index 205a738a..37fe9ae5 100644 --- a/packages/compass-agent/src/comms.ts +++ b/packages/compass-agent/src/comms.ts @@ -19,13 +19,18 @@ // stream, and a test fakes one method instead of four. // // THE HOME-CHANNEL DEFAULT. Both requests carry a `container` oneof. Leaving it -// unset is not "no channel" — it is the documented request for the acting -// agent's `home_channel_id`, which the Server resolves from the session it -// already owns (comms.proto:138-141; go/internal/comms/agent_caller.go -// `defaultChannel`). That is why an omitted `channel_id` tool parameter must -// leave `case: undefined` rather than send an empty string: the common case -// ("reply in my own channel") needs no channel id plumbed into the container at -// all, and the agent never names an account or a channel it was not given. +// unset is the documented request for the acting agent's `home_channel_id`, +// which the Server resolves from the session it already owns (comms.proto:138-141; +// go/internal/comms/agent_caller.go `defaultChannel`). As of the peer-DM cutover +// (record R2/R5) that default is DROPPED at the tool level for post/ask: those +// tools require an explicit `channel` NAME — the agent must name its target +// channel even for a self-post (its home channel name rides its prompt/roster). +// `comms_list_messages` is EXEMPT and keeps omit-=home. The `channel` param now +// carries a NAME, not an id: it is sent in the container's `channel_id` arm +// (the proto field name is unchanged) and the Go comms edge resolves name → id +// within the caller-visible set. An omitted list `channel` still leaves +// `case: undefined` rather than sending an empty string, so the home branch is +// reached with no container plumbed at all. // // IDENTITY. The agent presents no token and asserts no account: the Runner owns // which container (hence which session) a call arrived on, and the Server @@ -138,18 +143,26 @@ export const postParameters = type({ .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .narrow((s, ctx) => s.length <= 120 || ctx.mustBe("at most 120 characters")) .describe( - "Named conversation within the channel; an unknown name creates the topic", + "Named conversation within the channel; a name-miss is an error unless create_topic is true", ), - // An empty string is not "omitted": both execute bodies gate on truthiness, - // so `""` takes the home-channel branch and a model whose channel lookup - // missed posts to its own channel instead of being told it was wrong. Same - // bound as `text`, and repeated in the description for the same reason — the - // `.narrow` does not survive into the JSON Schema the model is shown. - "channel_id?": type("string") + // The target channel BY NAME. Required (peer-DM record R1+R2+R5): the home + // default is dropped at the tool level for post/ask — the agent must NAME its + // channel, even its own home (whose name is in its prompt/roster). The Go + // comms edge resolves this name → id within the caller-visible set. Non-blank + // via the same `.narrow` idiom `text` uses; the predicate does not survive + // into the JSON Schema the model is shown, so the rule is repeated here. + channel: type("string") .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .describe( - "Target channel; omit entirely for your home channel (an empty string is rejected)", + "Target channel by name (required); to post to your own channel, name it — there is no default", ), + // Gate get-or-create of an unknown `topic`: when true a name that names no + // existing topic MINTS it; when false (default), a name-miss is an error, + // not a silent create (peer-DM record DL-293). Optional; the wire default is + // false. + "create_topic?": type("boolean").describe( + "When true, an unknown topic name is created; when false (default) a topic name-miss is an error", + ), }); /** @@ -211,21 +224,31 @@ export const postAskParameters = type({ .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .narrow((s, ctx) => s.length <= 120 || ctx.mustBe("at most 120 characters")) .describe( - 'Named conversation within the channel; an unknown name creates the topic (default "general")', + 'Named conversation within the channel; a name-miss is an error unless create_topic is true (default "general")', ), - "channel_id?": type("string") + // The target channel BY NAME. Required, same rationale as + // `postParameters.channel`: the home default is dropped at the tool level and + // the Go comms edge resolves name → id within the caller-visible set. + channel: type("string") .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .describe( - "Target channel; omit entirely for your home channel (an empty string is rejected)", + "Target channel by name (required); to post to your own channel, name it — there is no default", ), + // Gate get-or-create of an unknown `topic`, mirroring `postParameters`. + "create_topic?": type("boolean").describe( + "When true, an unknown topic name is created; when false (default) a topic name-miss is an error", + ), }); /** Exported so a test can validate the wire contract the agent loop enforces. */ export const listParameters = type({ - "channel_id?": type("string") + // The target channel BY NAME. List is EXEMPT from the required-channel rule + // (peer-DM record R5): omit entirely for your home channel. Non-blank via the + // same `.narrow` idiom; the Go comms edge resolves the name → id. + "channel?": type("string") .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .describe( - "Target channel; omit entirely for your home channel (an empty string is rejected)", + "Target channel by name; omit entirely for your home channel (an empty string is rejected)", ), // The server resolves an omitted limit to 50 (`store/ids.go` defaultPageLimit); // the model cannot see that number anywhere else, and a range alone does not @@ -343,8 +366,10 @@ export function createCommsTools(broker: CommsBroker): AgentTool[] { approval: "write", description: "Post a markdown message to a Compass channel you are a member of. " + - "A topic names the conversation within the channel; an unknown name " + - "creates it. Omit channel_id to post to your home channel.", + "channel is required (a name) — name your target channel, even your " + + "own; there is no default. topic is required (a name); a new topic " + + "needs create_topic: true (otherwise a topic name-miss is an error, " + + "not a silent create).", parameters: postParameters, execute: async (toolCallId, params) => { const result = await broker.call( @@ -353,15 +378,14 @@ export function createCommsTools(broker: CommsBroker): AgentTool[] { call: { case: "post", value: create(PostMessageRequestSchema, { - container: params.channel_id - ? { case: "channelId", value: params.channel_id } - : { case: undefined }, + container: { case: "channelId", value: params.channel }, blocks: [ create(MessageBlockSchema, { block: { case: "text", value: params.text }, }), ], topic: { case: "topicName", value: params.topic }, + createTopic: params.create_topic ?? false, // Idempotency key, so that if a retry path is ever added on this // leg a replayed post returns the stored message rather than // duplicating it (comms.proto:566-570). Broker-scoped, never the @@ -406,9 +430,11 @@ export function createCommsTools(broker: CommsBroker): AgentTool[] { "Raise a structured question (an 'ask') on a Compass channel. The ask " + "is posted as an async channel message: it returns immediately with a " + "server-minted ask id, and the operator's answer arrives on a LATER " + - "turn — you do NOT wait for it here. A topic names the conversation " + - 'within the channel (default "general"); omit channel_id to post to ' + - "your home channel.", + "turn — you do NOT wait for it here. channel is required (a name) — " + + "name your target channel, even your own; there is no default. A topic " + + 'names the conversation within the channel (default "general"); a new ' + + "topic needs create_topic: true (otherwise a topic name-miss is an " + + "error, not a silent create).", parameters: postAskParameters, execute: async (toolCallId, params) => { const topic = params.topic ?? "general"; @@ -448,11 +474,10 @@ export function createCommsTools(broker: CommsBroker): AgentTool[] { call: { case: "post", value: create(PostMessageRequestSchema, { - container: params.channel_id - ? { case: "channelId", value: params.channel_id } - : { case: undefined }, + container: { case: "channelId", value: params.channel }, blocks: [askBlock], topic: { case: "topicName", value: topic }, + createTopic: params.create_topic ?? false, clientRequestId: broker.idempotencyKey(toolCallId), }), }, @@ -489,7 +514,7 @@ export function createCommsTools(broker: CommsBroker): AgentTool[] { description: "Read a channel's recent messages in conversation order, oldest first. " + "Each record carries its author, time, and topic. " + - "Omit channel_id for your home channel.", + "Omit channel for your home channel.", parameters: listParameters, execute: async (toolCallId, params) => { const result = await broker.call( @@ -498,8 +523,8 @@ export function createCommsTools(broker: CommsBroker): AgentTool[] { call: { case: "list", value: create(ListMessagesRequestSchema, { - container: params.channel_id - ? { case: "channelId", value: params.channel_id } + container: params.channel + ? { case: "channelId", value: params.channel } : { case: undefined }, limit: params.limit ?? 0, beforeMessageId: params.before_message_id ?? "", diff --git a/packages/compass-agent/src/gen/compass/v1/agent_pb.ts b/packages/compass-agent/src/gen/compass/v1/agent_pb.ts index 2c00806a..de0e634e 100644 --- a/packages/compass-agent/src/gen/compass/v1/agent_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/agent_pb.ts @@ -35,7 +35,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file compass/v1/agent.proto. */ export const file_compass_v1_agent: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2FnZW50LnByb3RvEgpjb21wYXNzLnYxIt0CCgpBZ2VudEZyYW1lEisKB3Nlc3Npb24YAyABKAsyGC5jb21wYXNzLnYxLlNlc3Npb25GcmFtZUgAEjwKE3JlcGxheV9jb21wbGV0ZV9hY2sYBCABKAsyHS5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlQWNrSAASLQoLY29udHJvbF9hY2sYBSABKAsyFi5jb21wYXNzLnYxLkNvbnRyb2xBY2tIABIvCgxkZWxpdmVyeV9hY2sYBiABKAsyFy5jb21wYXNzLnYxLkRlbGl2ZXJ5QWNrSAASNwoQdHJhbnNjcmlwdF9lbnRyeRgHIAEoCzIbLmNvbXBhc3MudjEuVHJhbnNjcmlwdEVudHJ5SAASQgoWZm9yZ2Vfbm90aWZpY2F0aW9uX2FjaxgIIAEoCzIgLmNvbXBhc3MudjEuRm9yZ2VOb3RpZmljYXRpb25BY2tIAEIHCgVmcmFtZSJMCg9UcmFuc2NyaXB0RW50cnkSEgoKZW50cnlfanNvbhgBIAEoCRISCgpjaGVja3BvaW50GAIgASgIEhEKCWVudHJ5X3NlcRgDIAEoBCJrCgxTZXNzaW9uRnJhbWUSLAoFc3RhdGUYASABKA4yHS5jb21wYXNzLnYxLkFnZW50U2Vzc2lvblN0YXRlEi0KC3R5cGVkX2V2ZW50GAIgASgLMhguY29tcGFzcy52MS5TZXNzaW9uRXZlbnQihgMKDEFnZW50Q29udHJvbBITCgtjb250cm9sX3NlcRgIIAEoBBIrCgZwcm9tcHQYASABKAsyGS5jb21wYXNzLnYxLlByb21wdENvbnRyb2xIABIpCgVzdGVlchgCIAEoCzIYLmNvbXBhc3MudjEuU3RlZXJDb250cm9sSAASLQoHZGVsaXZlchgDIAEoCzIaLmNvbXBhc3MudjEuRGVsaXZlckNvbnRyb2xIABIrCgZjb25maWcYBSABKAsyGS5jb21wYXNzLnYxLkNvbmZpZ0NvbnRyb2xIABIuCgZyZXBsYXkYBiABKAsyHC5jb21wYXNzLnYxLlRyYW5zY3JpcHRSZXBsYXlIABI1Cg9yZXBsYXlfY29tcGxldGUYByABKAsyGi5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlSAASOwoSZm9yZ2Vfbm90aWZpY2F0aW9uGAkgASgLMh0uY29tcGFzcy52MS5Gb3JnZU5vdGlmaWNhdGlvbkgAQgkKB2NvbnRyb2wiHgoNUHJvbXB0Q29udHJvbBINCgVpbnB1dBgBIAEoCSIQCg5SZXBsYXlDb21wbGV0ZSJeCgxTdGVlckNvbnRyb2wSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRITCgtmcm9tX2hhbmRsZRgCIAEoCRITCgt0cmFjZXBhcmVudBgDIAEoCSISChBUcmFuc2NyaXB0UmVwbGF5Ig8KDUNvbmZpZ0NvbnRyb2widAoORGVsaXZlckNvbnRyb2wSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRISCgp0b3BpY19uYW1lGAIgASgJEhMKC2Zyb21faGFuZGxlGAMgASgJEhMKC3RyYWNlcGFyZW50GAQgASgJIiEKC0RlbGl2ZXJ5QWNrEhIKCm1lc3NhZ2VfaWQYASABKAkiQQoURm9yZ2VOb3RpZmljYXRpb25BY2sSFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJEhAKCHJldmlzaW9uGAIgASgJIhMKEVJlcGxheUNvbXBsZXRlQWNrIjYKCkNvbnRyb2xBY2sSEQoJYWNrZWRfc2VxGAEgASgEEhUKDWFwcGxpZWRfYWJvdmUYAiADKARiBnByb3RvMw", [file_compass_v1_comms, file_compass_v1_compass, file_compass_v1_forge]); + fileDesc("ChZjb21wYXNzL3YxL2FnZW50LnByb3RvEgpjb21wYXNzLnYxIt0CCgpBZ2VudEZyYW1lEisKB3Nlc3Npb24YAyABKAsyGC5jb21wYXNzLnYxLlNlc3Npb25GcmFtZUgAEjwKE3JlcGxheV9jb21wbGV0ZV9hY2sYBCABKAsyHS5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlQWNrSAASLQoLY29udHJvbF9hY2sYBSABKAsyFi5jb21wYXNzLnYxLkNvbnRyb2xBY2tIABIvCgxkZWxpdmVyeV9hY2sYBiABKAsyFy5jb21wYXNzLnYxLkRlbGl2ZXJ5QWNrSAASNwoQdHJhbnNjcmlwdF9lbnRyeRgHIAEoCzIbLmNvbXBhc3MudjEuVHJhbnNjcmlwdEVudHJ5SAASQgoWZm9yZ2Vfbm90aWZpY2F0aW9uX2FjaxgIIAEoCzIgLmNvbXBhc3MudjEuRm9yZ2VOb3RpZmljYXRpb25BY2tIAEIHCgVmcmFtZSJMCg9UcmFuc2NyaXB0RW50cnkSEgoKZW50cnlfanNvbhgBIAEoCRISCgpjaGVja3BvaW50GAIgASgIEhEKCWVudHJ5X3NlcRgDIAEoBCJrCgxTZXNzaW9uRnJhbWUSLAoFc3RhdGUYASABKA4yHS5jb21wYXNzLnYxLkFnZW50U2Vzc2lvblN0YXRlEi0KC3R5cGVkX2V2ZW50GAIgASgLMhguY29tcGFzcy52MS5TZXNzaW9uRXZlbnQihgMKDEFnZW50Q29udHJvbBITCgtjb250cm9sX3NlcRgIIAEoBBIrCgZwcm9tcHQYASABKAsyGS5jb21wYXNzLnYxLlByb21wdENvbnRyb2xIABIpCgVzdGVlchgCIAEoCzIYLmNvbXBhc3MudjEuU3RlZXJDb250cm9sSAASLQoHZGVsaXZlchgDIAEoCzIaLmNvbXBhc3MudjEuRGVsaXZlckNvbnRyb2xIABIrCgZjb25maWcYBSABKAsyGS5jb21wYXNzLnYxLkNvbmZpZ0NvbnRyb2xIABIuCgZyZXBsYXkYBiABKAsyHC5jb21wYXNzLnYxLlRyYW5zY3JpcHRSZXBsYXlIABI1Cg9yZXBsYXlfY29tcGxldGUYByABKAsyGi5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlSAASOwoSZm9yZ2Vfbm90aWZpY2F0aW9uGAkgASgLMh0uY29tcGFzcy52MS5Gb3JnZU5vdGlmaWNhdGlvbkgAQgkKB2NvbnRyb2wiHgoNUHJvbXB0Q29udHJvbBINCgVpbnB1dBgBIAEoCSIQCg5SZXBsYXlDb21wbGV0ZSKIAQoMU3RlZXJDb250cm9sEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2USEwoLZnJvbV9oYW5kbGUYAiABKAkSEwoLdHJhY2VwYXJlbnQYAyABKAkSEgoKdG9waWNfbmFtZRgEIAEoCRIUCgxjaGFubmVsX25hbWUYBSABKAkiEgoQVHJhbnNjcmlwdFJlcGxheSIPCg1Db25maWdDb250cm9sIooBCg5EZWxpdmVyQ29udHJvbBIkCgdtZXNzYWdlGAEgASgLMhMuY29tcGFzcy52MS5NZXNzYWdlEhIKCnRvcGljX25hbWUYAiABKAkSEwoLZnJvbV9oYW5kbGUYAyABKAkSEwoLdHJhY2VwYXJlbnQYBCABKAkSFAoMY2hhbm5lbF9uYW1lGAUgASgJIiEKC0RlbGl2ZXJ5QWNrEhIKCm1lc3NhZ2VfaWQYASABKAkiQQoURm9yZ2VOb3RpZmljYXRpb25BY2sSFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJEhAKCHJldmlzaW9uGAIgASgJIhMKEVJlcGxheUNvbXBsZXRlQWNrIjYKCkNvbnRyb2xBY2sSEQoJYWNrZWRfc2VxGAEgASgEEhUKDWFwcGxpZWRfYWJvdmUYAiADKARiBnByb3RvMw", [file_compass_v1_comms, file_compass_v1_compass, file_compass_v1_forge]); /** * The agent's stdout envelope: one discriminated frame per newline-delimited @@ -411,6 +411,24 @@ export type SteerControl = Message<"compass.v1.SteerControl"> & { * @generated from field: string traceparent = 3; */ traceparent: string; + + /** + * The names of the steer message's topic and channel, denormalized onto the + * steer op so the agent renders the mention's source — and can reply naming + * both — without a lookup per steer (mirrors from_handle above and + * DeliverControl's topic_name/channel_name; peer-DM record DL-292). The ids + * are carried transitively by the Message; only the names are denormalized + * here. A name-resolve miss degrades exactly as from_handle does — it never + * blocks a steer — so each is EMPTY on a resolve miss. + * + * @generated from field: string topic_name = 4; + */ + topicName: string; + + /** + * @generated from field: string channel_name = 5; + */ + channelName: string; }; /** @@ -503,6 +521,19 @@ export type DeliverControl = Message<"compass.v1.DeliverControl"> & { * @generated from field: string traceparent = 4; */ traceparent: string; + + /** + * The name of the message's channel, denormalized onto the deliver op so the + * agent renders the delivery's source channel — and can reply naming it — + * without a channel lookup per delivery (mirrors the topic_name denorm + * rationale above; peer-DM record DL-292). The channel *id* is already + * carried transitively by `message.channel_id`; only the name is + * denormalized here. A name-resolve miss degrades exactly as from_handle + * does — it never blocks a delivery — so this is EMPTY on a resolve miss. + * + * @generated from field: string channel_name = 5; + */ + channelName: string; }; /** diff --git a/packages/compass-agent/src/gen/compass/v1/comms_pb.ts b/packages/compass-agent/src/gen/compass/v1/comms_pb.ts index 7c3081b5..71fcb63d 100644 --- a/packages/compass-agent/src/gen/compass/v1/comms_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/comms_pb.ts @@ -27,7 +27,7 @@ import type { Message as Message$1 } from "@bufbuild/protobuf"; * Describes the file compass/v1/comms.proto. */ export const file_compass_v1_comms: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkitwIKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeSJsCgtQaW5uZWRFbnRyeRISCgptZXNzYWdlX2lkGAEgASgJEhAKCHBvc2l0aW9uGAIgASgFEhkKEXBpbm5lZF9hdF91bml4X21zGAMgASgDEhwKFHBpbm5lZF9ieV9hY2NvdW50X2lkGAQgASgJIjYKDkFnZW50V29ya3NwYWNlEgoKAmlkGAEgASgJEhgKEGFnZW50X2FjY291bnRfaWQYAiABKAkiggEKBVRvcGljEgoKAmlkGAEgASgJEhIKCmNoYW5uZWxfaWQYAiABKAkSDAoEbmFtZRgDIAEoCRIaChJjcmVhdGVkX2F0X3VuaXhfbXMYBCABKAMSHQoVY3JlYXRlZF9ieV9hY2NvdW50X2lkGAUgASgJEhAKCGFyY2hpdmVkGAYgASgIIoABCgdNZXNzYWdlEgoKAmlkGAEgASgJEhAKCHRvcGljX2lkGAIgASgJEhkKEWF1dGhvcl9hY2NvdW50X2lkGAMgASgJEhIKCmF0X3VuaXhfbXMYBCABKAMSKAoGYmxvY2tzGAUgAygLMhguY29tcGFzcy52MS5NZXNzYWdlQmxvY2sieQoMTWVzc2FnZUJsb2NrEg4KBHRleHQYASABKAlIABIeCgNhc2sYAiABKAsyDy5jb21wYXNzLnYxLkFza0gAEjAKCmFza19hbnN3ZXIYAyABKAsyGi5jb21wYXNzLnYxLkFza0Fuc3dlckJsb2NrSABCBwoFYmxvY2siSAoOQXNrQW5zd2VyQmxvY2sSHAoDYXNrGAEgASgLMg8uY29tcGFzcy52MS5Bc2sSGAoQYXNrZXJfYWNjb3VudF9pZBgCIAEoCSJTCgNBc2sSDgoGYXNrX2lkGAEgASgJEioKCXF1ZXN0aW9ucxgCIAMoCzIXLmNvbXBhc3MudjEuQXNrUXVlc3Rpb24SEAoIYW5zd2VyZWQYAyABKAgi8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIokFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSABCCQoHcGF5bG9hZCI1Cg1NZXNzYWdlUG9zdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiNgoOTWVzc2FnZVVwZGF0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZSIxCg1Ub3BpY1Vwc2VydGVkEiAKBXRvcGljGAEgASgLMhEuY29tcGFzcy52MS5Ub3BpYyJTCg5DaGFubmVsQ2hhbmdlZBIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsEhsKE3JlbW92ZWRfYWNjb3VudF9pZHMYAiADKAkiPgoTQ2hhbm5lbEdyb3VwQ2hhbmdlZBInCgVncm91cBgBIAEoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIjYKDkFjY291bnRDaGFuZ2VkEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiRgoVQWdlbnRXb3Jrc3BhY2VDaGFuZ2VkEi0KCXdvcmtzcGFjZRgBIAEoCzIaLmNvbXBhc3MudjEuQWdlbnRXb3Jrc3BhY2UiFQoTQ29tbXNSZXN5bmNSZXF1aXJlZCJvChRBZ2VudFByZXNlbmNlQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEisKCHByZXNlbmNlGAIgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAMgASgJIjkKEUNyZWF0ZVVzZXJSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiOgoSQ3JlYXRlVXNlclJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiUQoSQ3JlYXRlQWdlbnRSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSFQoNcGFyZW50X2hhbmRsZRgDIAEoCSI7ChNDcmVhdGVBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiKwoTTGlzdEFjY291bnRzUmVxdWVzdBIUCgxzbmFwc2hvdF9zZXEYASABKAQiPQoUTGlzdEFjY291bnRzUmVzcG9uc2USJQoIYWNjb3VudHMYASADKAsyEy5jb21wYXNzLnYxLkFjY291bnQiegoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5IkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsInUKFENyZWF0ZUNoYW5uZWxSZXF1ZXN0EgwKBG5hbWUYASABKAkSEAoIZ3JvdXBfaWQYAiABKAkSJQoEa2luZBgDIAEoDjIXLmNvbXBhc3MudjEuQ2hhbm5lbEtpbmQSFgoObWVtYmVyX2hhbmRsZXMYBCADKAkiPQoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwipAEKG1VwZGF0ZUNoYW5uZWxNZW1iZXJzUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEhoKEmFkZF9tZW1iZXJfaGFuZGxlcxgCIAMoCRIdChVyZW1vdmVfbWVtYmVyX2hhbmRsZXMYAyADKAkSGQoRc3Vic2NyaWJlX2hhbmRsZXMYBCADKAkSGwoTdW5zdWJzY3JpYmVfaGFuZGxlcxgFIAMoCSJEChxVcGRhdGVDaGFubmVsTWVtYmVyc1Jlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwiRwoUUmVwYXJlbnRBZ2VudFJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJEhkKEW5ld19wYXJlbnRfaGFuZGxlGAIgASgJIj0KFVJlcGFyZW50QWdlbnRSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IpcBChdTZXRDaGFubmVsUG9saWN5UmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEjIKC3Bvc3RfcG9saWN5GAIgASgOMh0uY29tcGFzcy52MS5DaGFubmVsUG9zdFBvbGljeRIUCgxvd25lcl9oYW5kbGUYAyABKAkSHgoWbWFuZGF0b3J5X3N1YnNjcmlwdGlvbhgEIAEoCCJAChhTZXRDaGFubmVsUG9saWN5UmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCJSChBHZXRSb3N0ZXJSZXF1ZXN0EiYKBXNjb3BlGAEgASgOMhcuY29tcGFzcy52MS5Sb3N0ZXJTY29wZRIWCg52YW50YWdlX2hhbmRsZRgCIAEoCSI9ChFHZXRSb3N0ZXJSZXNwb25zZRIoCgdlbnRyaWVzGAEgAygLMhcuY29tcGFzcy52MS5Sb3N0ZXJFbnRyeSLCAQoLUm9zdGVyRW50cnkSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIOCgZoYW5kbGUYAiABKAkSFAoMZGlzcGxheV9uYW1lGAMgASgJEhcKD3BhcmVudF9hZ2VudF9pZBgEIAEoCRIrCghwcmVzZW5jZRgFIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgGIAEoCRIbChNhY3Rpdml0eV9hdF91bml4X21zGAcgASgDIncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlIq8BChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJQgsKCWNvbnRhaW5lckIHCgV0b3BpYyI7ChNQb3N0TWVzc2FnZVJlc3BvbnNlEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQQoRTGlzdFRvcGljc1JlcXVlc3QSEgoKY2hhbm5lbF9pZBgBIAEoCRIYChBpbmNsdWRlX2FyY2hpdmVkGAIgASgIIjcKEkxpc3RUb3BpY3NSZXNwb25zZRIhCgZ0b3BpY3MYASADKAsyES5jb21wYXNzLnYxLlRvcGljImYKElVwZGF0ZVRvcGljUmVxdWVzdBIQCgh0b3BpY19pZBgBIAEoCRIRCgRuYW1lGAIgASgJSACIAQESFQoIYXJjaGl2ZWQYAyABKAhIAYgBAUIHCgVfbmFtZUILCglfYXJjaGl2ZWQiNwoTVXBkYXRlVG9waWNSZXNwb25zZRIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiVQoTUmVzcG9uZFRvQXNrUmVxdWVzdBIOCgZhc2tfaWQYASABKAkSLgoHYW5zd2VycxgCIAMoCzIdLmNvbXBhc3MudjEuQXNrUXVlc3Rpb25BbnN3ZXIiWAoRQXNrUXVlc3Rpb25BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGQoRY2hvc2VuX29wdGlvbl9pZHMYAiADKAkSEwoLY3VzdG9tX3RleHQYAyABKAkiFgoUUmVzcG9uZFRvQXNrUmVzcG9uc2UiagoVU2VhcmNoTWVzc2FnZXNSZXF1ZXN0Eg0KBXF1ZXJ5GAEgASgJEhQKCmNoYW5uZWxfaWQYAiABKAlIABINCgVsaW1pdBgDIAEoDRIUCgxzbmFwc2hvdF9zZXEYBCABKARCBwoFc2NvcGUiPwoWU2VhcmNoTWVzc2FnZXNSZXNwb25zZRIlCghtZXNzYWdlcxgBIAMoCzITLmNvbXBhc3MudjEuTWVzc2FnZSJCChVTdWJzY3JpYmVDb21tc1JlcXVlc3QSEQoJc2luY2Vfc2VxGAEgASgEEhYKDmluc3RhbmNlX2Vwb2NoGAIgASgEKjUKCFVzZXJSb2xlEhQKEFVTRVJfUk9MRV9NRU1CRVIQABITCg9VU0VSX1JPTEVfQURNSU4QASphChZDaGFubmVsR3JvdXBWaXNpYmlsaXR5EiIKHkNIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9PV05FUhAAEiMKH0NIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9TSEFSRUQQASpVChFDaGFubmVsUG9zdFBvbGljeRIcChhDSEFOTkVMX1BPU1RfUE9MSUNZX09QRU4QABIiCh5DSEFOTkVMX1BPU1RfUE9MSUNZX09XTkVSX09OTFkQASpXCgtDaGFubmVsS2luZBIYChRDSEFOTkVMX0tJTkRfQ0hBTk5FTBAAEhMKD0NIQU5ORUxfS0lORF9ETRABEhkKFUNIQU5ORUxfS0lORF9HUk9VUF9ETRACKpwBCg1BZ2VudFByZXNlbmNlEh4KGkFHRU5UX1BSRVNFTkNFX1VOU1BFQ0lGSUVEEAASFwoTQUdFTlRfUFJFU0VOQ0VfSURMRRABEhoKFkFHRU5UX1BSRVNFTkNFX1dPUktJTkcQAhIaChZBR0VOVF9QUkVTRU5DRV9XQUlUSU5HEAMSGgoWQUdFTlRfUFJFU0VOQ0VfT0ZGTElORRAEKl4KC1Jvc3RlclNjb3BlEh0KGVJPU1RFUl9TQ09QRV9ORUlHSEJPUkhPT0QQABIYChRST1NURVJfU0NPUEVfU1VCVFJFRRABEhYKElJPU1RFUl9TQ09QRV9PV05FUhACMuYNCgxDb21tc1NlcnZpY2USSwoKQ3JlYXRlVXNlchIdLmNvbXBhc3MudjEuQ3JlYXRlVXNlclJlcXVlc3QaHi5jb21wYXNzLnYxLkNyZWF0ZVVzZXJSZXNwb25zZRJOCgtDcmVhdGVBZ2VudBIeLmNvbXBhc3MudjEuQ3JlYXRlQWdlbnRSZXF1ZXN0Gh8uY29tcGFzcy52MS5DcmVhdGVBZ2VudFJlc3BvbnNlElEKDExpc3RBY2NvdW50cxIfLmNvbXBhc3MudjEuTGlzdEFjY291bnRzUmVxdWVzdBogLmNvbXBhc3MudjEuTGlzdEFjY291bnRzUmVzcG9uc2USYwoSQ3JlYXRlQ2hhbm5lbEdyb3VwEiUuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXF1ZXN0GiYuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXNwb25zZRJgChFMaXN0Q2hhbm5lbEdyb3VwcxIkLmNvbXBhc3MudjEuTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0GiUuY29tcGFzcy52MS5MaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlElEKDExpc3RDaGFubmVscxIfLmNvbXBhc3MudjEuTGlzdENoYW5uZWxzUmVxdWVzdBogLmNvbXBhc3MudjEuTGlzdENoYW5uZWxzUmVzcG9uc2USVAoNQ3JlYXRlQ2hhbm5lbBIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3QaIS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxSZXNwb25zZRJpChRVcGRhdGVDaGFubmVsTWVtYmVycxInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0GiguY29tcGFzcy52MS5VcGRhdGVDaGFubmVsTWVtYmVyc1Jlc3BvbnNlElQKDVJlcGFyZW50QWdlbnQSIC5jb21wYXNzLnYxLlJlcGFyZW50QWdlbnRSZXF1ZXN0GiEuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVzcG9uc2USYwoST3BlbkFnZW50V29ya3NwYWNlEiUuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0GiYuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRJRCgxMaXN0TWVzc2FnZXMSHy5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlEk4KC1Bvc3RNZXNzYWdlEh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3QaHy5jb21wYXNzLnYxLlBvc3RNZXNzYWdlUmVzcG9uc2USSwoKTGlzdFRvcGljcxIdLmNvbXBhc3MudjEuTGlzdFRvcGljc1JlcXVlc3QaHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZRJOCgtVcGRhdGVUb3BpYxIeLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXF1ZXN0Gh8uY29tcGFzcy52MS5VcGRhdGVUb3BpY1Jlc3BvbnNlElEKDFJlc3BvbmRUb0FzaxIfLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVxdWVzdBogLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVzcG9uc2USVwoOU2VhcmNoTWVzc2FnZXMSIS5jb21wYXNzLnYxLlNlYXJjaE1lc3NhZ2VzUmVxdWVzdBoiLmNvbXBhc3MudjEuU2VhcmNoTWVzc2FnZXNSZXNwb25zZRJdChBTZXRDaGFubmVsUG9saWN5EiMuY29tcGFzcy52MS5TZXRDaGFubmVsUG9saWN5UmVxdWVzdBokLmNvbXBhc3MudjEuU2V0Q2hhbm5lbFBvbGljeVJlc3BvbnNlEkgKCUdldFJvc3RlchIcLmNvbXBhc3MudjEuR2V0Um9zdGVyUmVxdWVzdBodLmNvbXBhc3MudjEuR2V0Um9zdGVyUmVzcG9uc2USYAoRVXBkYXRlUGlubmVkQm9hcmQSJC5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBolLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZRJZCg5TdWJzY3JpYmVDb21tcxIhLmNvbXBhc3MudjEuU3Vic2NyaWJlQ29tbXNSZXF1ZXN0GiIuY29tcGFzcy52MS5TdWJzY3JpYmVDb21tc1Jlc3BvbnNlMAFiBnByb3RvMw"); + fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkitwIKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeSJsCgtQaW5uZWRFbnRyeRISCgptZXNzYWdlX2lkGAEgASgJEhAKCHBvc2l0aW9uGAIgASgFEhkKEXBpbm5lZF9hdF91bml4X21zGAMgASgDEhwKFHBpbm5lZF9ieV9hY2NvdW50X2lkGAQgASgJIjYKDkFnZW50V29ya3NwYWNlEgoKAmlkGAEgASgJEhgKEGFnZW50X2FjY291bnRfaWQYAiABKAkiggEKBVRvcGljEgoKAmlkGAEgASgJEhIKCmNoYW5uZWxfaWQYAiABKAkSDAoEbmFtZRgDIAEoCRIaChJjcmVhdGVkX2F0X3VuaXhfbXMYBCABKAMSHQoVY3JlYXRlZF9ieV9hY2NvdW50X2lkGAUgASgJEhAKCGFyY2hpdmVkGAYgASgIIoABCgdNZXNzYWdlEgoKAmlkGAEgASgJEhAKCHRvcGljX2lkGAIgASgJEhkKEWF1dGhvcl9hY2NvdW50X2lkGAMgASgJEhIKCmF0X3VuaXhfbXMYBCABKAMSKAoGYmxvY2tzGAUgAygLMhguY29tcGFzcy52MS5NZXNzYWdlQmxvY2sieQoMTWVzc2FnZUJsb2NrEg4KBHRleHQYASABKAlIABIeCgNhc2sYAiABKAsyDy5jb21wYXNzLnYxLkFza0gAEjAKCmFza19hbnN3ZXIYAyABKAsyGi5jb21wYXNzLnYxLkFza0Fuc3dlckJsb2NrSABCBwoFYmxvY2siSAoOQXNrQW5zd2VyQmxvY2sSHAoDYXNrGAEgASgLMg8uY29tcGFzcy52MS5Bc2sSGAoQYXNrZXJfYWNjb3VudF9pZBgCIAEoCSJTCgNBc2sSDgoGYXNrX2lkGAEgASgJEioKCXF1ZXN0aW9ucxgCIAMoCzIXLmNvbXBhc3MudjEuQXNrUXVlc3Rpb24SEAoIYW5zd2VyZWQYAyABKAgi8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIokFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSABCCQoHcGF5bG9hZCI1Cg1NZXNzYWdlUG9zdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiNgoOTWVzc2FnZVVwZGF0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZSIxCg1Ub3BpY1Vwc2VydGVkEiAKBXRvcGljGAEgASgLMhEuY29tcGFzcy52MS5Ub3BpYyJTCg5DaGFubmVsQ2hhbmdlZBIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsEhsKE3JlbW92ZWRfYWNjb3VudF9pZHMYAiADKAkiPgoTQ2hhbm5lbEdyb3VwQ2hhbmdlZBInCgVncm91cBgBIAEoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIjYKDkFjY291bnRDaGFuZ2VkEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiRgoVQWdlbnRXb3Jrc3BhY2VDaGFuZ2VkEi0KCXdvcmtzcGFjZRgBIAEoCzIaLmNvbXBhc3MudjEuQWdlbnRXb3Jrc3BhY2UiFQoTQ29tbXNSZXN5bmNSZXF1aXJlZCJvChRBZ2VudFByZXNlbmNlQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEisKCHByZXNlbmNlGAIgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAMgASgJIjkKEUNyZWF0ZVVzZXJSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiOgoSQ3JlYXRlVXNlclJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiUQoSQ3JlYXRlQWdlbnRSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSFQoNcGFyZW50X2hhbmRsZRgDIAEoCSI7ChNDcmVhdGVBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiKwoTTGlzdEFjY291bnRzUmVxdWVzdBIUCgxzbmFwc2hvdF9zZXEYASABKAQiPQoUTGlzdEFjY291bnRzUmVzcG9uc2USJQoIYWNjb3VudHMYASADKAsyEy5jb21wYXNzLnYxLkFjY291bnQiegoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5IkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsInUKFENyZWF0ZUNoYW5uZWxSZXF1ZXN0EgwKBG5hbWUYASABKAkSEAoIZ3JvdXBfaWQYAiABKAkSJQoEa2luZBgDIAEoDjIXLmNvbXBhc3MudjEuQ2hhbm5lbEtpbmQSFgoObWVtYmVyX2hhbmRsZXMYBCADKAkiPQoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwipAEKG1VwZGF0ZUNoYW5uZWxNZW1iZXJzUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEhoKEmFkZF9tZW1iZXJfaGFuZGxlcxgCIAMoCRIdChVyZW1vdmVfbWVtYmVyX2hhbmRsZXMYAyADKAkSGQoRc3Vic2NyaWJlX2hhbmRsZXMYBCADKAkSGwoTdW5zdWJzY3JpYmVfaGFuZGxlcxgFIAMoCSJEChxVcGRhdGVDaGFubmVsTWVtYmVyc1Jlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwiRwoUUmVwYXJlbnRBZ2VudFJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJEhkKEW5ld19wYXJlbnRfaGFuZGxlGAIgASgJIj0KFVJlcGFyZW50QWdlbnRSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IpcBChdTZXRDaGFubmVsUG9saWN5UmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEjIKC3Bvc3RfcG9saWN5GAIgASgOMh0uY29tcGFzcy52MS5DaGFubmVsUG9zdFBvbGljeRIUCgxvd25lcl9oYW5kbGUYAyABKAkSHgoWbWFuZGF0b3J5X3N1YnNjcmlwdGlvbhgEIAEoCCJAChhTZXRDaGFubmVsUG9saWN5UmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCJSChBHZXRSb3N0ZXJSZXF1ZXN0EiYKBXNjb3BlGAEgASgOMhcuY29tcGFzcy52MS5Sb3N0ZXJTY29wZRIWCg52YW50YWdlX2hhbmRsZRgCIAEoCSI9ChFHZXRSb3N0ZXJSZXNwb25zZRIoCgdlbnRyaWVzGAEgAygLMhcuY29tcGFzcy52MS5Sb3N0ZXJFbnRyeSLCAQoLUm9zdGVyRW50cnkSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIOCgZoYW5kbGUYAiABKAkSFAoMZGlzcGxheV9uYW1lGAMgASgJEhcKD3BhcmVudF9hZ2VudF9pZBgEIAEoCRIrCghwcmVzZW5jZRgFIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgGIAEoCRIbChNhY3Rpdml0eV9hdF91bml4X21zGAcgASgDIncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlIsUBChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJEhQKDGNyZWF0ZV90b3BpYxgGIAEoCEILCgljb250YWluZXJCBwoFdG9waWMiOwoTUG9zdE1lc3NhZ2VSZXNwb25zZRIkCgdtZXNzYWdlGAEgASgLMhMuY29tcGFzcy52MS5NZXNzYWdlIkEKEUxpc3RUb3BpY3NSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSGAoQaW5jbHVkZV9hcmNoaXZlZBgCIAEoCCI3ChJMaXN0VG9waWNzUmVzcG9uc2USIQoGdG9waWNzGAEgAygLMhEuY29tcGFzcy52MS5Ub3BpYyJmChJVcGRhdGVUb3BpY1JlcXVlc3QSEAoIdG9waWNfaWQYASABKAkSEQoEbmFtZRgCIAEoCUgAiAEBEhUKCGFyY2hpdmVkGAMgASgISAGIAQFCBwoFX25hbWVCCwoJX2FyY2hpdmVkIjcKE1VwZGF0ZVRvcGljUmVzcG9uc2USIAoFdG9waWMYASABKAsyES5jb21wYXNzLnYxLlRvcGljIlUKE1Jlc3BvbmRUb0Fza1JlcXVlc3QSDgoGYXNrX2lkGAEgASgJEi4KB2Fuc3dlcnMYAiADKAsyHS5jb21wYXNzLnYxLkFza1F1ZXN0aW9uQW5zd2VyIlgKEUFza1F1ZXN0aW9uQW5zd2VyEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhkKEWNob3Nlbl9vcHRpb25faWRzGAIgAygJEhMKC2N1c3RvbV90ZXh0GAMgASgJIhYKFFJlc3BvbmRUb0Fza1Jlc3BvbnNlImoKFVNlYXJjaE1lc3NhZ2VzUmVxdWVzdBINCgVxdWVyeRgBIAEoCRIUCgpjaGFubmVsX2lkGAIgASgJSAASDQoFbGltaXQYAyABKA0SFAoMc25hcHNob3Rfc2VxGAQgASgEQgcKBXNjb3BlIj8KFlNlYXJjaE1lc3NhZ2VzUmVzcG9uc2USJQoIbWVzc2FnZXMYASADKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQgoVU3Vic2NyaWJlQ29tbXNSZXF1ZXN0EhEKCXNpbmNlX3NlcRgBIAEoBBIWCg5pbnN0YW5jZV9lcG9jaBgCIAEoBCo1CghVc2VyUm9sZRIUChBVU0VSX1JPTEVfTUVNQkVSEAASEwoPVVNFUl9ST0xFX0FETUlOEAEqYQoWQ2hhbm5lbEdyb3VwVmlzaWJpbGl0eRIiCh5DSEFOTkVMX0dST1VQX1ZJU0lCSUxJVFlfT1dORVIQABIjCh9DSEFOTkVMX0dST1VQX1ZJU0lCSUxJVFlfU0hBUkVEEAEqVQoRQ2hhbm5lbFBvc3RQb2xpY3kSHAoYQ0hBTk5FTF9QT1NUX1BPTElDWV9PUEVOEAASIgoeQ0hBTk5FTF9QT1NUX1BPTElDWV9PV05FUl9PTkxZEAEqVwoLQ2hhbm5lbEtpbmQSGAoUQ0hBTk5FTF9LSU5EX0NIQU5ORUwQABITCg9DSEFOTkVMX0tJTkRfRE0QARIZChVDSEFOTkVMX0tJTkRfR1JPVVBfRE0QAiqcAQoNQWdlbnRQcmVzZW5jZRIeChpBR0VOVF9QUkVTRU5DRV9VTlNQRUNJRklFRBAAEhcKE0FHRU5UX1BSRVNFTkNFX0lETEUQARIaChZBR0VOVF9QUkVTRU5DRV9XT1JLSU5HEAISGgoWQUdFTlRfUFJFU0VOQ0VfV0FJVElORxADEhoKFkFHRU5UX1BSRVNFTkNFX09GRkxJTkUQBCpeCgtSb3N0ZXJTY29wZRIdChlST1NURVJfU0NPUEVfTkVJR0hCT1JIT09EEAASGAoUUk9TVEVSX1NDT1BFX1NVQlRSRUUQARIWChJST1NURVJfU0NPUEVfT1dORVIQAjLmDQoMQ29tbXNTZXJ2aWNlEksKCkNyZWF0ZVVzZXISHS5jb21wYXNzLnYxLkNyZWF0ZVVzZXJSZXF1ZXN0Gh4uY29tcGFzcy52MS5DcmVhdGVVc2VyUmVzcG9uc2USTgoLQ3JlYXRlQWdlbnQSHi5jb21wYXNzLnYxLkNyZWF0ZUFnZW50UmVxdWVzdBofLmNvbXBhc3MudjEuQ3JlYXRlQWdlbnRSZXNwb25zZRJRCgxMaXN0QWNjb3VudHMSHy5jb21wYXNzLnYxLkxpc3RBY2NvdW50c1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RBY2NvdW50c1Jlc3BvbnNlEmMKEkNyZWF0ZUNoYW5uZWxHcm91cBIlLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBomLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVzcG9uc2USYAoRTGlzdENoYW5uZWxHcm91cHMSJC5jb21wYXNzLnYxLkxpc3RDaGFubmVsR3JvdXBzUmVxdWVzdBolLmNvbXBhc3MudjEuTGlzdENoYW5uZWxHcm91cHNSZXNwb25zZRJRCgxMaXN0Q2hhbm5lbHMSHy5jb21wYXNzLnYxLkxpc3RDaGFubmVsc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RDaGFubmVsc1Jlc3BvbnNlElQKDUNyZWF0ZUNoYW5uZWwSIC5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxSZXF1ZXN0GiEuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVzcG9uc2USaQoUVXBkYXRlQ2hhbm5lbE1lbWJlcnMSJy5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVxdWVzdBooLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXNwb25zZRJUCg1SZXBhcmVudEFnZW50EiAuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVxdWVzdBohLmNvbXBhc3MudjEuUmVwYXJlbnRBZ2VudFJlc3BvbnNlEmMKEk9wZW5BZ2VudFdvcmtzcGFjZRIlLmNvbXBhc3MudjEuT3BlbkFnZW50V29ya3NwYWNlUmVxdWVzdBomLmNvbXBhc3MudjEuT3BlbkFnZW50V29ya3NwYWNlUmVzcG9uc2USUQoMTGlzdE1lc3NhZ2VzEh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXNwb25zZRJOCgtQb3N0TWVzc2FnZRIeLmNvbXBhc3MudjEuUG9zdE1lc3NhZ2VSZXF1ZXN0Gh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlEksKCkxpc3RUb3BpY3MSHS5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXF1ZXN0Gh4uY29tcGFzcy52MS5MaXN0VG9waWNzUmVzcG9uc2USTgoLVXBkYXRlVG9waWMSHi5jb21wYXNzLnYxLlVwZGF0ZVRvcGljUmVxdWVzdBofLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXNwb25zZRJRCgxSZXNwb25kVG9Bc2sSHy5jb21wYXNzLnYxLlJlc3BvbmRUb0Fza1JlcXVlc3QaIC5jb21wYXNzLnYxLlJlc3BvbmRUb0Fza1Jlc3BvbnNlElcKDlNlYXJjaE1lc3NhZ2VzEiEuY29tcGFzcy52MS5TZWFyY2hNZXNzYWdlc1JlcXVlc3QaIi5jb21wYXNzLnYxLlNlYXJjaE1lc3NhZ2VzUmVzcG9uc2USXQoQU2V0Q2hhbm5lbFBvbGljeRIjLmNvbXBhc3MudjEuU2V0Q2hhbm5lbFBvbGljeVJlcXVlc3QaJC5jb21wYXNzLnYxLlNldENoYW5uZWxQb2xpY3lSZXNwb25zZRJICglHZXRSb3N0ZXISHC5jb21wYXNzLnYxLkdldFJvc3RlclJlcXVlc3QaHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlEmAKEVVwZGF0ZVBpbm5lZEJvYXJkEiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3QaJS5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USWQoOU3Vic2NyaWJlQ29tbXMSIS5jb21wYXNzLnYxLlN1YnNjcmliZUNvbW1zUmVxdWVzdBoiLmNvbXBhc3MudjEuU3Vic2NyaWJlQ29tbXNSZXNwb25zZTABYgZwcm90bzM"); /** * An account in the communication layer: a human user or an owned agent. Both @@ -1934,6 +1934,18 @@ export type PostMessageRequest = Message$1<"compass.v1.PostMessageRequest"> & { * @generated from field: string client_request_id = 5; */ clientRequestId: string; + + /** + * Gates get-or-create of `topic_name`: when true, a name that names no + * existing topic in the channel MINTS it; when false (default), a + * name-miss is NOT_FOUND rather than a silent create. Guards accidental + * topic proliferation now that agents must always name their topic + * (peer-DM record DL-293; amends the get-or-create semantics of DL-098). + * Ignored when `topic_id` is set or `topic_name` is unset. + * + * @generated from field: bool create_topic = 6; + */ + createTopic: boolean; }; /** diff --git a/packages/compass-agent/src/transport/control-source.ts b/packages/compass-agent/src/transport/control-source.ts index 14f411f8..40cae0c0 100644 --- a/packages/compass-agent/src/transport/control-source.ts +++ b/packages/compass-agent/src/transport/control-source.ts @@ -56,6 +56,7 @@ import { create } from "@bufbuild/protobuf"; import { Effect, Either, Fiber, Logger, ManagedRuntime, Metric } from "effect"; +import type { DeliverSourceNames } from "./../agent"; import type { ForgeNotification, Message } from "./../compassv1"; import type { AgentControl, ControlSource } from "./../control"; import { ControlSubscribeRequestSchema } from "./../gen/compass/v1/agent_gateway_pb"; @@ -170,9 +171,22 @@ export interface ImmediateControl { // control (RIG-2486 T1) — the value the CompassAgent emits on the // SessionInjection observation without a per-injection roster lookup. Empty // when the Server could not resolve the author handle (a handle miss is - // logged server-side, never a delivery block). - steer(msg: Message, fromHandle: string): void; // compass.v1.Message — .id intact - deliver(msg: Message, fromHandle: string): void; // compass.v1.Message — .id intact + // logged server-side, never a delivery block). The fourth arg carries the + // denormalized SOURCE channel + topic NAMES off the wire control (peer-DM + // record DL-292, `DeliverControl`/`SteerControl` `channel_name`/`topic_name`) + // — what the CompassAgent renders as the delivery's source and reply target + // without a per-delivery channel/topic lookup. Either is empty on a + // server-side resolve miss (a name miss never blocks a delivery). + steer( + msg: Message, + fromHandle: string, + sourceNames: DeliverSourceNames, + ): void; // compass.v1.Message — .id intact + deliver( + msg: Message, + fromHandle: string, + sourceNames: DeliverSourceNames, + ): void; // compass.v1.Message — .id intact // RIG-2732 W3 forge notification arm. UNLIKE steer/deliver — which ack their // control_seq at DECODE because they dispatch at decode — the forge arm // enqueues on the CompassAgent's turn-end queue (RT-3 coalescing) and defers @@ -433,9 +447,15 @@ export function createSocketControlSource( "empty-shell steer/deliver — payload staged (RIG-1310)", ); } else if (wire.control.case === "steer") { - immediate.steer(msg, wire.control.value.fromHandle); + immediate.steer(msg, wire.control.value.fromHandle, { + channelName: wire.control.value.channelName, + topicName: wire.control.value.topicName, + }); } else { - immediate.deliver(msg, wire.control.value.fromHandle); + immediate.deliver(msg, wire.control.value.fromHandle, { + channelName: wire.control.value.channelName, + topicName: wire.control.value.topicName, + }); } // Applied (counted or dispatched) at decode → ack now, ahead of any // queued iterator op (invariant 2 → applied_above). diff --git a/packages/compass-client/src/gen/compass/v1/comms_pb.ts b/packages/compass-client/src/gen/compass/v1/comms_pb.ts index 7c3081b5..71fcb63d 100644 --- a/packages/compass-client/src/gen/compass/v1/comms_pb.ts +++ b/packages/compass-client/src/gen/compass/v1/comms_pb.ts @@ -27,7 +27,7 @@ import type { Message as Message$1 } from "@bufbuild/protobuf"; * Describes the file compass/v1/comms.proto. */ export const file_compass_v1_comms: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkitwIKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeSJsCgtQaW5uZWRFbnRyeRISCgptZXNzYWdlX2lkGAEgASgJEhAKCHBvc2l0aW9uGAIgASgFEhkKEXBpbm5lZF9hdF91bml4X21zGAMgASgDEhwKFHBpbm5lZF9ieV9hY2NvdW50X2lkGAQgASgJIjYKDkFnZW50V29ya3NwYWNlEgoKAmlkGAEgASgJEhgKEGFnZW50X2FjY291bnRfaWQYAiABKAkiggEKBVRvcGljEgoKAmlkGAEgASgJEhIKCmNoYW5uZWxfaWQYAiABKAkSDAoEbmFtZRgDIAEoCRIaChJjcmVhdGVkX2F0X3VuaXhfbXMYBCABKAMSHQoVY3JlYXRlZF9ieV9hY2NvdW50X2lkGAUgASgJEhAKCGFyY2hpdmVkGAYgASgIIoABCgdNZXNzYWdlEgoKAmlkGAEgASgJEhAKCHRvcGljX2lkGAIgASgJEhkKEWF1dGhvcl9hY2NvdW50X2lkGAMgASgJEhIKCmF0X3VuaXhfbXMYBCABKAMSKAoGYmxvY2tzGAUgAygLMhguY29tcGFzcy52MS5NZXNzYWdlQmxvY2sieQoMTWVzc2FnZUJsb2NrEg4KBHRleHQYASABKAlIABIeCgNhc2sYAiABKAsyDy5jb21wYXNzLnYxLkFza0gAEjAKCmFza19hbnN3ZXIYAyABKAsyGi5jb21wYXNzLnYxLkFza0Fuc3dlckJsb2NrSABCBwoFYmxvY2siSAoOQXNrQW5zd2VyQmxvY2sSHAoDYXNrGAEgASgLMg8uY29tcGFzcy52MS5Bc2sSGAoQYXNrZXJfYWNjb3VudF9pZBgCIAEoCSJTCgNBc2sSDgoGYXNrX2lkGAEgASgJEioKCXF1ZXN0aW9ucxgCIAMoCzIXLmNvbXBhc3MudjEuQXNrUXVlc3Rpb24SEAoIYW5zd2VyZWQYAyABKAgi8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIokFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSABCCQoHcGF5bG9hZCI1Cg1NZXNzYWdlUG9zdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiNgoOTWVzc2FnZVVwZGF0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZSIxCg1Ub3BpY1Vwc2VydGVkEiAKBXRvcGljGAEgASgLMhEuY29tcGFzcy52MS5Ub3BpYyJTCg5DaGFubmVsQ2hhbmdlZBIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsEhsKE3JlbW92ZWRfYWNjb3VudF9pZHMYAiADKAkiPgoTQ2hhbm5lbEdyb3VwQ2hhbmdlZBInCgVncm91cBgBIAEoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIjYKDkFjY291bnRDaGFuZ2VkEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiRgoVQWdlbnRXb3Jrc3BhY2VDaGFuZ2VkEi0KCXdvcmtzcGFjZRgBIAEoCzIaLmNvbXBhc3MudjEuQWdlbnRXb3Jrc3BhY2UiFQoTQ29tbXNSZXN5bmNSZXF1aXJlZCJvChRBZ2VudFByZXNlbmNlQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEisKCHByZXNlbmNlGAIgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAMgASgJIjkKEUNyZWF0ZVVzZXJSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiOgoSQ3JlYXRlVXNlclJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiUQoSQ3JlYXRlQWdlbnRSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSFQoNcGFyZW50X2hhbmRsZRgDIAEoCSI7ChNDcmVhdGVBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiKwoTTGlzdEFjY291bnRzUmVxdWVzdBIUCgxzbmFwc2hvdF9zZXEYASABKAQiPQoUTGlzdEFjY291bnRzUmVzcG9uc2USJQoIYWNjb3VudHMYASADKAsyEy5jb21wYXNzLnYxLkFjY291bnQiegoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5IkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsInUKFENyZWF0ZUNoYW5uZWxSZXF1ZXN0EgwKBG5hbWUYASABKAkSEAoIZ3JvdXBfaWQYAiABKAkSJQoEa2luZBgDIAEoDjIXLmNvbXBhc3MudjEuQ2hhbm5lbEtpbmQSFgoObWVtYmVyX2hhbmRsZXMYBCADKAkiPQoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwipAEKG1VwZGF0ZUNoYW5uZWxNZW1iZXJzUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEhoKEmFkZF9tZW1iZXJfaGFuZGxlcxgCIAMoCRIdChVyZW1vdmVfbWVtYmVyX2hhbmRsZXMYAyADKAkSGQoRc3Vic2NyaWJlX2hhbmRsZXMYBCADKAkSGwoTdW5zdWJzY3JpYmVfaGFuZGxlcxgFIAMoCSJEChxVcGRhdGVDaGFubmVsTWVtYmVyc1Jlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwiRwoUUmVwYXJlbnRBZ2VudFJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJEhkKEW5ld19wYXJlbnRfaGFuZGxlGAIgASgJIj0KFVJlcGFyZW50QWdlbnRSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IpcBChdTZXRDaGFubmVsUG9saWN5UmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEjIKC3Bvc3RfcG9saWN5GAIgASgOMh0uY29tcGFzcy52MS5DaGFubmVsUG9zdFBvbGljeRIUCgxvd25lcl9oYW5kbGUYAyABKAkSHgoWbWFuZGF0b3J5X3N1YnNjcmlwdGlvbhgEIAEoCCJAChhTZXRDaGFubmVsUG9saWN5UmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCJSChBHZXRSb3N0ZXJSZXF1ZXN0EiYKBXNjb3BlGAEgASgOMhcuY29tcGFzcy52MS5Sb3N0ZXJTY29wZRIWCg52YW50YWdlX2hhbmRsZRgCIAEoCSI9ChFHZXRSb3N0ZXJSZXNwb25zZRIoCgdlbnRyaWVzGAEgAygLMhcuY29tcGFzcy52MS5Sb3N0ZXJFbnRyeSLCAQoLUm9zdGVyRW50cnkSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIOCgZoYW5kbGUYAiABKAkSFAoMZGlzcGxheV9uYW1lGAMgASgJEhcKD3BhcmVudF9hZ2VudF9pZBgEIAEoCRIrCghwcmVzZW5jZRgFIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgGIAEoCRIbChNhY3Rpdml0eV9hdF91bml4X21zGAcgASgDIncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlIq8BChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJQgsKCWNvbnRhaW5lckIHCgV0b3BpYyI7ChNQb3N0TWVzc2FnZVJlc3BvbnNlEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQQoRTGlzdFRvcGljc1JlcXVlc3QSEgoKY2hhbm5lbF9pZBgBIAEoCRIYChBpbmNsdWRlX2FyY2hpdmVkGAIgASgIIjcKEkxpc3RUb3BpY3NSZXNwb25zZRIhCgZ0b3BpY3MYASADKAsyES5jb21wYXNzLnYxLlRvcGljImYKElVwZGF0ZVRvcGljUmVxdWVzdBIQCgh0b3BpY19pZBgBIAEoCRIRCgRuYW1lGAIgASgJSACIAQESFQoIYXJjaGl2ZWQYAyABKAhIAYgBAUIHCgVfbmFtZUILCglfYXJjaGl2ZWQiNwoTVXBkYXRlVG9waWNSZXNwb25zZRIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiVQoTUmVzcG9uZFRvQXNrUmVxdWVzdBIOCgZhc2tfaWQYASABKAkSLgoHYW5zd2VycxgCIAMoCzIdLmNvbXBhc3MudjEuQXNrUXVlc3Rpb25BbnN3ZXIiWAoRQXNrUXVlc3Rpb25BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGQoRY2hvc2VuX29wdGlvbl9pZHMYAiADKAkSEwoLY3VzdG9tX3RleHQYAyABKAkiFgoUUmVzcG9uZFRvQXNrUmVzcG9uc2UiagoVU2VhcmNoTWVzc2FnZXNSZXF1ZXN0Eg0KBXF1ZXJ5GAEgASgJEhQKCmNoYW5uZWxfaWQYAiABKAlIABINCgVsaW1pdBgDIAEoDRIUCgxzbmFwc2hvdF9zZXEYBCABKARCBwoFc2NvcGUiPwoWU2VhcmNoTWVzc2FnZXNSZXNwb25zZRIlCghtZXNzYWdlcxgBIAMoCzITLmNvbXBhc3MudjEuTWVzc2FnZSJCChVTdWJzY3JpYmVDb21tc1JlcXVlc3QSEQoJc2luY2Vfc2VxGAEgASgEEhYKDmluc3RhbmNlX2Vwb2NoGAIgASgEKjUKCFVzZXJSb2xlEhQKEFVTRVJfUk9MRV9NRU1CRVIQABITCg9VU0VSX1JPTEVfQURNSU4QASphChZDaGFubmVsR3JvdXBWaXNpYmlsaXR5EiIKHkNIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9PV05FUhAAEiMKH0NIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9TSEFSRUQQASpVChFDaGFubmVsUG9zdFBvbGljeRIcChhDSEFOTkVMX1BPU1RfUE9MSUNZX09QRU4QABIiCh5DSEFOTkVMX1BPU1RfUE9MSUNZX09XTkVSX09OTFkQASpXCgtDaGFubmVsS2luZBIYChRDSEFOTkVMX0tJTkRfQ0hBTk5FTBAAEhMKD0NIQU5ORUxfS0lORF9ETRABEhkKFUNIQU5ORUxfS0lORF9HUk9VUF9ETRACKpwBCg1BZ2VudFByZXNlbmNlEh4KGkFHRU5UX1BSRVNFTkNFX1VOU1BFQ0lGSUVEEAASFwoTQUdFTlRfUFJFU0VOQ0VfSURMRRABEhoKFkFHRU5UX1BSRVNFTkNFX1dPUktJTkcQAhIaChZBR0VOVF9QUkVTRU5DRV9XQUlUSU5HEAMSGgoWQUdFTlRfUFJFU0VOQ0VfT0ZGTElORRAEKl4KC1Jvc3RlclNjb3BlEh0KGVJPU1RFUl9TQ09QRV9ORUlHSEJPUkhPT0QQABIYChRST1NURVJfU0NPUEVfU1VCVFJFRRABEhYKElJPU1RFUl9TQ09QRV9PV05FUhACMuYNCgxDb21tc1NlcnZpY2USSwoKQ3JlYXRlVXNlchIdLmNvbXBhc3MudjEuQ3JlYXRlVXNlclJlcXVlc3QaHi5jb21wYXNzLnYxLkNyZWF0ZVVzZXJSZXNwb25zZRJOCgtDcmVhdGVBZ2VudBIeLmNvbXBhc3MudjEuQ3JlYXRlQWdlbnRSZXF1ZXN0Gh8uY29tcGFzcy52MS5DcmVhdGVBZ2VudFJlc3BvbnNlElEKDExpc3RBY2NvdW50cxIfLmNvbXBhc3MudjEuTGlzdEFjY291bnRzUmVxdWVzdBogLmNvbXBhc3MudjEuTGlzdEFjY291bnRzUmVzcG9uc2USYwoSQ3JlYXRlQ2hhbm5lbEdyb3VwEiUuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXF1ZXN0GiYuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXNwb25zZRJgChFMaXN0Q2hhbm5lbEdyb3VwcxIkLmNvbXBhc3MudjEuTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0GiUuY29tcGFzcy52MS5MaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlElEKDExpc3RDaGFubmVscxIfLmNvbXBhc3MudjEuTGlzdENoYW5uZWxzUmVxdWVzdBogLmNvbXBhc3MudjEuTGlzdENoYW5uZWxzUmVzcG9uc2USVAoNQ3JlYXRlQ2hhbm5lbBIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3QaIS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxSZXNwb25zZRJpChRVcGRhdGVDaGFubmVsTWVtYmVycxInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0GiguY29tcGFzcy52MS5VcGRhdGVDaGFubmVsTWVtYmVyc1Jlc3BvbnNlElQKDVJlcGFyZW50QWdlbnQSIC5jb21wYXNzLnYxLlJlcGFyZW50QWdlbnRSZXF1ZXN0GiEuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVzcG9uc2USYwoST3BlbkFnZW50V29ya3NwYWNlEiUuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0GiYuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRJRCgxMaXN0TWVzc2FnZXMSHy5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlEk4KC1Bvc3RNZXNzYWdlEh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3QaHy5jb21wYXNzLnYxLlBvc3RNZXNzYWdlUmVzcG9uc2USSwoKTGlzdFRvcGljcxIdLmNvbXBhc3MudjEuTGlzdFRvcGljc1JlcXVlc3QaHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZRJOCgtVcGRhdGVUb3BpYxIeLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXF1ZXN0Gh8uY29tcGFzcy52MS5VcGRhdGVUb3BpY1Jlc3BvbnNlElEKDFJlc3BvbmRUb0FzaxIfLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVxdWVzdBogLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVzcG9uc2USVwoOU2VhcmNoTWVzc2FnZXMSIS5jb21wYXNzLnYxLlNlYXJjaE1lc3NhZ2VzUmVxdWVzdBoiLmNvbXBhc3MudjEuU2VhcmNoTWVzc2FnZXNSZXNwb25zZRJdChBTZXRDaGFubmVsUG9saWN5EiMuY29tcGFzcy52MS5TZXRDaGFubmVsUG9saWN5UmVxdWVzdBokLmNvbXBhc3MudjEuU2V0Q2hhbm5lbFBvbGljeVJlc3BvbnNlEkgKCUdldFJvc3RlchIcLmNvbXBhc3MudjEuR2V0Um9zdGVyUmVxdWVzdBodLmNvbXBhc3MudjEuR2V0Um9zdGVyUmVzcG9uc2USYAoRVXBkYXRlUGlubmVkQm9hcmQSJC5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBolLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZRJZCg5TdWJzY3JpYmVDb21tcxIhLmNvbXBhc3MudjEuU3Vic2NyaWJlQ29tbXNSZXF1ZXN0GiIuY29tcGFzcy52MS5TdWJzY3JpYmVDb21tc1Jlc3BvbnNlMAFiBnByb3RvMw"); + fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkitwIKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeSJsCgtQaW5uZWRFbnRyeRISCgptZXNzYWdlX2lkGAEgASgJEhAKCHBvc2l0aW9uGAIgASgFEhkKEXBpbm5lZF9hdF91bml4X21zGAMgASgDEhwKFHBpbm5lZF9ieV9hY2NvdW50X2lkGAQgASgJIjYKDkFnZW50V29ya3NwYWNlEgoKAmlkGAEgASgJEhgKEGFnZW50X2FjY291bnRfaWQYAiABKAkiggEKBVRvcGljEgoKAmlkGAEgASgJEhIKCmNoYW5uZWxfaWQYAiABKAkSDAoEbmFtZRgDIAEoCRIaChJjcmVhdGVkX2F0X3VuaXhfbXMYBCABKAMSHQoVY3JlYXRlZF9ieV9hY2NvdW50X2lkGAUgASgJEhAKCGFyY2hpdmVkGAYgASgIIoABCgdNZXNzYWdlEgoKAmlkGAEgASgJEhAKCHRvcGljX2lkGAIgASgJEhkKEWF1dGhvcl9hY2NvdW50X2lkGAMgASgJEhIKCmF0X3VuaXhfbXMYBCABKAMSKAoGYmxvY2tzGAUgAygLMhguY29tcGFzcy52MS5NZXNzYWdlQmxvY2sieQoMTWVzc2FnZUJsb2NrEg4KBHRleHQYASABKAlIABIeCgNhc2sYAiABKAsyDy5jb21wYXNzLnYxLkFza0gAEjAKCmFza19hbnN3ZXIYAyABKAsyGi5jb21wYXNzLnYxLkFza0Fuc3dlckJsb2NrSABCBwoFYmxvY2siSAoOQXNrQW5zd2VyQmxvY2sSHAoDYXNrGAEgASgLMg8uY29tcGFzcy52MS5Bc2sSGAoQYXNrZXJfYWNjb3VudF9pZBgCIAEoCSJTCgNBc2sSDgoGYXNrX2lkGAEgASgJEioKCXF1ZXN0aW9ucxgCIAMoCzIXLmNvbXBhc3MudjEuQXNrUXVlc3Rpb24SEAoIYW5zd2VyZWQYAyABKAgi8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIokFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSABCCQoHcGF5bG9hZCI1Cg1NZXNzYWdlUG9zdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiNgoOTWVzc2FnZVVwZGF0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZSIxCg1Ub3BpY1Vwc2VydGVkEiAKBXRvcGljGAEgASgLMhEuY29tcGFzcy52MS5Ub3BpYyJTCg5DaGFubmVsQ2hhbmdlZBIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsEhsKE3JlbW92ZWRfYWNjb3VudF9pZHMYAiADKAkiPgoTQ2hhbm5lbEdyb3VwQ2hhbmdlZBInCgVncm91cBgBIAEoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIjYKDkFjY291bnRDaGFuZ2VkEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiRgoVQWdlbnRXb3Jrc3BhY2VDaGFuZ2VkEi0KCXdvcmtzcGFjZRgBIAEoCzIaLmNvbXBhc3MudjEuQWdlbnRXb3Jrc3BhY2UiFQoTQ29tbXNSZXN5bmNSZXF1aXJlZCJvChRBZ2VudFByZXNlbmNlQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEisKCHByZXNlbmNlGAIgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAMgASgJIjkKEUNyZWF0ZVVzZXJSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiOgoSQ3JlYXRlVXNlclJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiUQoSQ3JlYXRlQWdlbnRSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSFQoNcGFyZW50X2hhbmRsZRgDIAEoCSI7ChNDcmVhdGVBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiKwoTTGlzdEFjY291bnRzUmVxdWVzdBIUCgxzbmFwc2hvdF9zZXEYASABKAQiPQoUTGlzdEFjY291bnRzUmVzcG9uc2USJQoIYWNjb3VudHMYASADKAsyEy5jb21wYXNzLnYxLkFjY291bnQiegoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5IkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsInUKFENyZWF0ZUNoYW5uZWxSZXF1ZXN0EgwKBG5hbWUYASABKAkSEAoIZ3JvdXBfaWQYAiABKAkSJQoEa2luZBgDIAEoDjIXLmNvbXBhc3MudjEuQ2hhbm5lbEtpbmQSFgoObWVtYmVyX2hhbmRsZXMYBCADKAkiPQoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwipAEKG1VwZGF0ZUNoYW5uZWxNZW1iZXJzUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEhoKEmFkZF9tZW1iZXJfaGFuZGxlcxgCIAMoCRIdChVyZW1vdmVfbWVtYmVyX2hhbmRsZXMYAyADKAkSGQoRc3Vic2NyaWJlX2hhbmRsZXMYBCADKAkSGwoTdW5zdWJzY3JpYmVfaGFuZGxlcxgFIAMoCSJEChxVcGRhdGVDaGFubmVsTWVtYmVyc1Jlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwiRwoUUmVwYXJlbnRBZ2VudFJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJEhkKEW5ld19wYXJlbnRfaGFuZGxlGAIgASgJIj0KFVJlcGFyZW50QWdlbnRSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IpcBChdTZXRDaGFubmVsUG9saWN5UmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEjIKC3Bvc3RfcG9saWN5GAIgASgOMh0uY29tcGFzcy52MS5DaGFubmVsUG9zdFBvbGljeRIUCgxvd25lcl9oYW5kbGUYAyABKAkSHgoWbWFuZGF0b3J5X3N1YnNjcmlwdGlvbhgEIAEoCCJAChhTZXRDaGFubmVsUG9saWN5UmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCJSChBHZXRSb3N0ZXJSZXF1ZXN0EiYKBXNjb3BlGAEgASgOMhcuY29tcGFzcy52MS5Sb3N0ZXJTY29wZRIWCg52YW50YWdlX2hhbmRsZRgCIAEoCSI9ChFHZXRSb3N0ZXJSZXNwb25zZRIoCgdlbnRyaWVzGAEgAygLMhcuY29tcGFzcy52MS5Sb3N0ZXJFbnRyeSLCAQoLUm9zdGVyRW50cnkSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIOCgZoYW5kbGUYAiABKAkSFAoMZGlzcGxheV9uYW1lGAMgASgJEhcKD3BhcmVudF9hZ2VudF9pZBgEIAEoCRIrCghwcmVzZW5jZRgFIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgGIAEoCRIbChNhY3Rpdml0eV9hdF91bml4X21zGAcgASgDIncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlIsUBChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJEhQKDGNyZWF0ZV90b3BpYxgGIAEoCEILCgljb250YWluZXJCBwoFdG9waWMiOwoTUG9zdE1lc3NhZ2VSZXNwb25zZRIkCgdtZXNzYWdlGAEgASgLMhMuY29tcGFzcy52MS5NZXNzYWdlIkEKEUxpc3RUb3BpY3NSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSGAoQaW5jbHVkZV9hcmNoaXZlZBgCIAEoCCI3ChJMaXN0VG9waWNzUmVzcG9uc2USIQoGdG9waWNzGAEgAygLMhEuY29tcGFzcy52MS5Ub3BpYyJmChJVcGRhdGVUb3BpY1JlcXVlc3QSEAoIdG9waWNfaWQYASABKAkSEQoEbmFtZRgCIAEoCUgAiAEBEhUKCGFyY2hpdmVkGAMgASgISAGIAQFCBwoFX25hbWVCCwoJX2FyY2hpdmVkIjcKE1VwZGF0ZVRvcGljUmVzcG9uc2USIAoFdG9waWMYASABKAsyES5jb21wYXNzLnYxLlRvcGljIlUKE1Jlc3BvbmRUb0Fza1JlcXVlc3QSDgoGYXNrX2lkGAEgASgJEi4KB2Fuc3dlcnMYAiADKAsyHS5jb21wYXNzLnYxLkFza1F1ZXN0aW9uQW5zd2VyIlgKEUFza1F1ZXN0aW9uQW5zd2VyEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhkKEWNob3Nlbl9vcHRpb25faWRzGAIgAygJEhMKC2N1c3RvbV90ZXh0GAMgASgJIhYKFFJlc3BvbmRUb0Fza1Jlc3BvbnNlImoKFVNlYXJjaE1lc3NhZ2VzUmVxdWVzdBINCgVxdWVyeRgBIAEoCRIUCgpjaGFubmVsX2lkGAIgASgJSAASDQoFbGltaXQYAyABKA0SFAoMc25hcHNob3Rfc2VxGAQgASgEQgcKBXNjb3BlIj8KFlNlYXJjaE1lc3NhZ2VzUmVzcG9uc2USJQoIbWVzc2FnZXMYASADKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQgoVU3Vic2NyaWJlQ29tbXNSZXF1ZXN0EhEKCXNpbmNlX3NlcRgBIAEoBBIWCg5pbnN0YW5jZV9lcG9jaBgCIAEoBCo1CghVc2VyUm9sZRIUChBVU0VSX1JPTEVfTUVNQkVSEAASEwoPVVNFUl9ST0xFX0FETUlOEAEqYQoWQ2hhbm5lbEdyb3VwVmlzaWJpbGl0eRIiCh5DSEFOTkVMX0dST1VQX1ZJU0lCSUxJVFlfT1dORVIQABIjCh9DSEFOTkVMX0dST1VQX1ZJU0lCSUxJVFlfU0hBUkVEEAEqVQoRQ2hhbm5lbFBvc3RQb2xpY3kSHAoYQ0hBTk5FTF9QT1NUX1BPTElDWV9PUEVOEAASIgoeQ0hBTk5FTF9QT1NUX1BPTElDWV9PV05FUl9PTkxZEAEqVwoLQ2hhbm5lbEtpbmQSGAoUQ0hBTk5FTF9LSU5EX0NIQU5ORUwQABITCg9DSEFOTkVMX0tJTkRfRE0QARIZChVDSEFOTkVMX0tJTkRfR1JPVVBfRE0QAiqcAQoNQWdlbnRQcmVzZW5jZRIeChpBR0VOVF9QUkVTRU5DRV9VTlNQRUNJRklFRBAAEhcKE0FHRU5UX1BSRVNFTkNFX0lETEUQARIaChZBR0VOVF9QUkVTRU5DRV9XT1JLSU5HEAISGgoWQUdFTlRfUFJFU0VOQ0VfV0FJVElORxADEhoKFkFHRU5UX1BSRVNFTkNFX09GRkxJTkUQBCpeCgtSb3N0ZXJTY29wZRIdChlST1NURVJfU0NPUEVfTkVJR0hCT1JIT09EEAASGAoUUk9TVEVSX1NDT1BFX1NVQlRSRUUQARIWChJST1NURVJfU0NPUEVfT1dORVIQAjLmDQoMQ29tbXNTZXJ2aWNlEksKCkNyZWF0ZVVzZXISHS5jb21wYXNzLnYxLkNyZWF0ZVVzZXJSZXF1ZXN0Gh4uY29tcGFzcy52MS5DcmVhdGVVc2VyUmVzcG9uc2USTgoLQ3JlYXRlQWdlbnQSHi5jb21wYXNzLnYxLkNyZWF0ZUFnZW50UmVxdWVzdBofLmNvbXBhc3MudjEuQ3JlYXRlQWdlbnRSZXNwb25zZRJRCgxMaXN0QWNjb3VudHMSHy5jb21wYXNzLnYxLkxpc3RBY2NvdW50c1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RBY2NvdW50c1Jlc3BvbnNlEmMKEkNyZWF0ZUNoYW5uZWxHcm91cBIlLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBomLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVzcG9uc2USYAoRTGlzdENoYW5uZWxHcm91cHMSJC5jb21wYXNzLnYxLkxpc3RDaGFubmVsR3JvdXBzUmVxdWVzdBolLmNvbXBhc3MudjEuTGlzdENoYW5uZWxHcm91cHNSZXNwb25zZRJRCgxMaXN0Q2hhbm5lbHMSHy5jb21wYXNzLnYxLkxpc3RDaGFubmVsc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RDaGFubmVsc1Jlc3BvbnNlElQKDUNyZWF0ZUNoYW5uZWwSIC5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxSZXF1ZXN0GiEuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVzcG9uc2USaQoUVXBkYXRlQ2hhbm5lbE1lbWJlcnMSJy5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVxdWVzdBooLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXNwb25zZRJUCg1SZXBhcmVudEFnZW50EiAuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVxdWVzdBohLmNvbXBhc3MudjEuUmVwYXJlbnRBZ2VudFJlc3BvbnNlEmMKEk9wZW5BZ2VudFdvcmtzcGFjZRIlLmNvbXBhc3MudjEuT3BlbkFnZW50V29ya3NwYWNlUmVxdWVzdBomLmNvbXBhc3MudjEuT3BlbkFnZW50V29ya3NwYWNlUmVzcG9uc2USUQoMTGlzdE1lc3NhZ2VzEh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXNwb25zZRJOCgtQb3N0TWVzc2FnZRIeLmNvbXBhc3MudjEuUG9zdE1lc3NhZ2VSZXF1ZXN0Gh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlEksKCkxpc3RUb3BpY3MSHS5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXF1ZXN0Gh4uY29tcGFzcy52MS5MaXN0VG9waWNzUmVzcG9uc2USTgoLVXBkYXRlVG9waWMSHi5jb21wYXNzLnYxLlVwZGF0ZVRvcGljUmVxdWVzdBofLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXNwb25zZRJRCgxSZXNwb25kVG9Bc2sSHy5jb21wYXNzLnYxLlJlc3BvbmRUb0Fza1JlcXVlc3QaIC5jb21wYXNzLnYxLlJlc3BvbmRUb0Fza1Jlc3BvbnNlElcKDlNlYXJjaE1lc3NhZ2VzEiEuY29tcGFzcy52MS5TZWFyY2hNZXNzYWdlc1JlcXVlc3QaIi5jb21wYXNzLnYxLlNlYXJjaE1lc3NhZ2VzUmVzcG9uc2USXQoQU2V0Q2hhbm5lbFBvbGljeRIjLmNvbXBhc3MudjEuU2V0Q2hhbm5lbFBvbGljeVJlcXVlc3QaJC5jb21wYXNzLnYxLlNldENoYW5uZWxQb2xpY3lSZXNwb25zZRJICglHZXRSb3N0ZXISHC5jb21wYXNzLnYxLkdldFJvc3RlclJlcXVlc3QaHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlEmAKEVVwZGF0ZVBpbm5lZEJvYXJkEiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3QaJS5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USWQoOU3Vic2NyaWJlQ29tbXMSIS5jb21wYXNzLnYxLlN1YnNjcmliZUNvbW1zUmVxdWVzdBoiLmNvbXBhc3MudjEuU3Vic2NyaWJlQ29tbXNSZXNwb25zZTABYgZwcm90bzM"); /** * An account in the communication layer: a human user or an owned agent. Both @@ -1934,6 +1934,18 @@ export type PostMessageRequest = Message$1<"compass.v1.PostMessageRequest"> & { * @generated from field: string client_request_id = 5; */ clientRequestId: string; + + /** + * Gates get-or-create of `topic_name`: when true, a name that names no + * existing topic in the channel MINTS it; when false (default), a + * name-miss is NOT_FOUND rather than a silent create. Guards accidental + * topic proliferation now that agents must always name their topic + * (peer-DM record DL-293; amends the get-or-create semantics of DL-098). + * Ignored when `topic_id` is set or `topic_name` is unset. + * + * @generated from field: bool create_topic = 6; + */ + createTopic: boolean; }; /** diff --git a/proto/compass/v1/agent.proto b/proto/compass/v1/agent.proto index da880701..e67b4b9f 100644 --- a/proto/compass/v1/agent.proto +++ b/proto/compass/v1/agent.proto @@ -211,6 +211,15 @@ message SteerControl { // blocks or fails a delivery). Server-side stamping per // docs/designs/platform/compass-agent-message-trace-continuity/design.md. string traceparent = 3; + // The names of the steer message's topic and channel, denormalized onto the + // steer op so the agent renders the mention's source — and can reply naming + // both — without a lookup per steer (mirrors from_handle above and + // DeliverControl's topic_name/channel_name; peer-DM record DL-292). The ids + // are carried transitively by the Message; only the names are denormalized + // here. A name-resolve miss degrades exactly as from_handle does — it never + // blocks a steer — so each is EMPTY on a resolve miss. + string topic_name = 4; + string channel_name = 5; } // Empty shells — payload fields parked (RIG-1310). Present so the AgentControl @@ -249,6 +258,14 @@ message DeliverControl { // blocks or fails a delivery). Server-side stamping per // docs/designs/platform/compass-agent-message-trace-continuity/design.md. string traceparent = 4; + // The name of the message's channel, denormalized onto the deliver op so the + // agent renders the delivery's source channel — and can reply naming it — + // without a channel lookup per delivery (mirrors the topic_name denorm + // rationale above; peer-DM record DL-292). The channel *id* is already + // carried transitively by `message.channel_id`; only the name is + // denormalized here. A name-resolve miss degrades exactly as from_handle + // does — it never blocks a delivery — so this is EMPTY on a resolve miss. + string channel_name = 5; } // DeliveryAck — the agent's per-message delivery receipt (RIG-1569), an diff --git a/proto/compass/v1/comms.proto b/proto/compass/v1/comms.proto index 041cbe32..2663de1b 100644 --- a/proto/compass/v1/comms.proto +++ b/proto/compass/v1/comms.proto @@ -819,6 +819,13 @@ message PostMessageRequest { // store of record, D12), returning the already-stored message rather than // posting a duplicate. Optional; empty disables dedup for this call. string client_request_id = 5; + // Gates get-or-create of `topic_name`: when true, a name that names no + // existing topic in the channel MINTS it; when false (default), a + // name-miss is NOT_FOUND rather than a silent create. Guards accidental + // topic proliferation now that agents must always name their topic + // (peer-DM record DL-293; amends the get-or-create semantics of DL-098). + // Ignored when `topic_id` is set or `topic_name` is unset. + bool create_topic = 6; } message PostMessageResponse {