Compass message cost gate
Tracker: RIG-1713. Freezes on merge.
Builds on: compass-manager-comms-substrate, compass-agent-comms-tools, compass-agent-inbound-batching.
Ledger-impact: adds docs/designs/decisions/agent/DL-459.md to DL-465.md, one row per Q heading Q1 to Q7 in order, plus DL-470.md and DL-471.md for Q8.
Problem / Intent
Section titled “Problem / Intent”One agent post can wake many agents, and each woken agent runs a turn. Today a Manager cannot see that cost before it posts.
This record adds a server-side cost estimate for an agent post, a gate that holds a post over an operator threshold until the agent confirms, and hard limits a confirm cannot pass. Human posts are never gated.
Approach
Section titled “Approach”What exists today
Section titled “What exists today”- Post path.
comms_post_message,comms_post_askandcomms_dm’s second leg (createCommsTools,packages/compass-agent/src/comms.ts) send thepostarm.executeCall(relay_comms.go) callslinkTrigger, thenPostAsAccountByName(go/internal/comms/agent_caller.go), the only agent post entry. Internal posts (CommitAgentPost) and the humanPostMessageRPC bypass it. The tools throw “protocol violation” on a result with nomessage. - Store checks.
AppendMessagechecks, in one transaction: membership (requireChannelMember),OWNER_ONLY, the topic (resolveTopicForAppend), then the(author, client_request_id)dedup. No message size limit exists. - Recipient set.
fanOut(go/internal/delivery/dispatch.go) steers live mentioned agents (routeMentionsFor), delivers to the otherSubscribedAgents, and wakes offline ones; for a despawned agent the wake (go/server/lifecycle.go) is “a logged no-op”. Agent posts fire at the author’s settle (DL-382), whenfireHeldre-resolves recipients. A batch queue “lives inCompassAgentmemory, with no new server or Runner state” (inbound-batching Q4). - Context and last-active.
session-tee.tstees each SDK session line intoagent_session_transcript_entries.entry_json; a full-body rewrite becomes onecheckpointrow. Every entry has atimestamp; an assistant message hasusage.input,cacheReadandcacheWrite, whose sum is the context the model last read. - Prompt cache. Anthropic cache entries last five minutes by default (
resolveCacheRetention(cacheRetention, "short")). - Root-only channels. The restricted-post ACL is not enforced yet (
config/skills/supervisor-channel/SKILL.md); the gate does not depend on it. - Wire numbering.
CommsCallRequestarms 2 to 12 are used (12 islist_topics, #2082); draft PR #2078 reserves 13 and 14. This design adds no arm. Next free fields:PostMessageRequest8,PostMessageResponse2.
The change
Section titled “The change”Every agent post passes the pre-checks (Q6) and hard limits (Q8). The server then computes fanout_tokens and reload_tokens (Q1, Q2), holds the post when fanout_tokens exceeds the threshold (Q3), and posts on a cost-bound confirm (Q4).
sequenceDiagram participant A as Agent tool participant C as PostAsAccountByName participant S as Store.AppendMessage A->>C: post C->>C: pre-checks, hard limits, estimate alt fanout_tokens <= threshold C->>S: append C-->>A: message else fanout_tokens > threshold C-->>A: cost_estimate, held (nothing posted) A->>C: compass_message_confirm → same post + confirmed_estimate_tokens C->>C: recompute; new <= confirmed × 1.1 C->>S: append C-->>A: message endQ1: Cost model
Section titled “Q1: Cost model”Ledger row: DL-459.
fanout_tokens is a token count summed over all recipients, not a share of one context window. It prices what the post adds, not each recipient’s context re-load:
m=ceil(text bytes / 4) + 50. Text bytes cover text blocks and ask question and option text (textContentingo/internal/store/blocks.go, plus option labels);50is the deliver envelope.cost_r = mwhenris mentioned and WORKING: the steer joins the running turn. A WORKING subscriber’s deliver runs as a new turn atagent_end.- Every other placed recipient costs
m + turnOverhead.turnOverhead = 2000is a deliberately low estimate of a turn start (about 20,000 prompt and tool tokens cost about 2,000 warm, 25,000 cold), so cold agents are not over-held. Its default is part of OQ1. - A recipient with no placement is excluded: its wake is a no-op.
fanout_tokens = Σ_r cost_r.
Worked examples against the default threshold of 100,000 (Q3):
- A 1,000-token message to 10 idle recipients: about 10 × 3,000 = 30,000. It posts.
- A 4,000-token message to 50 idle recipients: about 50 × 6,000 = 300,000. It is held.
Other messages in a recipient’s batch are not counted (Matt, 2026-10-11). The server cannot see a batch, so an idle recipient inside its batch window still pays turnOverhead.
The context re-load is shown, never gated: most agents are stopped and cold, so gating it would hold nearly every post. reload_tokens = Σ ctx_r × w_r over non-WORKING recipients: ctx_r = min(200000, input + cacheRead + cacheWrite) from the newest assistant usage in r’s newest session (T3), else 0; w_r = 0.1 when the newest entry is within the 5 min warm window, else 1.25. A compaction lowers it. Both numbers are approximate.
Q2: Where the estimate is computed
Section titled “Q2: Where the estimate is computed”Ledger row: DL-460.
Server-side, in the comms service: only the server holds the recipient set, live presence and transcript state, and an agent-side estimate would race the post.
Delivery already imports comms, so T2 moves the mention parse and resolve into package comms, keeping one mention body (“a second mention-routing body is a second correctness surface and is prohibited”, routeMentionsFor). The estimate is taken at post time and fanOut re-resolves at settle, so small drift is expected.
Q3: Threshold and its default
Section titled “Q3: Threshold and its default”Ledger row: DL-461.
One server-wide setting, --message-cost-threshold / COMPASS_MESSAGE_COST_THRESHOLD, compared to fanout_tokens. It is parsed at startup like resolveUsageEventRetention (go/cmd/compass-server/main.go), not positiveCap, which treats 0 as unset. Empty means the default, 0 turns the gate off, and a non-integer fails startup.
The default is 100000, proposed (OQ1): about 48 idle recipients of a short post (each ≈ m + 2,000), or 9 idle agents for the longest allowed post (8,000 tokens, Q8). A deployment chooses the value (see self-host and managed and the OSS core boundary).
For tuning, comms counts outcomes on compass.message_cost_gate (compass_message_cost_gate_total, attribute outcome = posted, held, confirmed, estimate_only, rejected_length or rejected_ceiling), built like the fabricPublishFailures counter in go/internal/comms/comms.go.
Q4: Per-message confirm, no session opt-out
Section titled “Q4: Per-message confirm, no session opt-out”Ledger row: DL-462.
Every held post needs its own confirm. A session opt-out would be forgotten at the next compaction.
The confirm binds to cost: the tool re-sends the held request with confirmed_estimate_tokens, and the server posts if the recomputed fanout_tokens ≤ confirmed × 1.1, else holds again. No key or server state is needed, and a shrinking audience never re-holds.
The re-sent request keeps the original client_request_id, so a repeated confirm lands once, and restamps turn_sequence to the current turn (DL-382).
Q5: Wire and tool shape
Section titled “Q5: Wire and tool shape”Ledger row: DL-463.
The gate rides the existing post arm; no arm is added.
PostMessageRequest:estimate_only = 8,confirmed_estimate_tokens = 9.PostMessageResponse.cost_estimate = 2: new messageMessageCostEstimatewithfanout_tokens,threshold_tokens,recipient_count,cold_recipient_count,heldandreload_tokens(T1).messageset means posted; otherwisecost_estimate.heldtells a hold fromestimate_only.- Every agent post faces the gate (Matt, 2026-10-11). Older agent images throw “protocol violation” on a hold, so the release notes say to ship the agent image with the server.
executeCallskipslinkTriggerforestimate_only.
A typed result is needed: commsCallError carries only a code and a message.
The issue’s two tools stay:
compass_message_cost({channel, text})sendsestimate_onlywith no topic (the topic does not change the cost) and renders both numbers.compass_message_confirm({handle})re-sends a held request (Q4). Handles last 10 minutes in tool memory; an unknown handle tells the agent to post again.
The post tools render a held result as “not posted: fan-out N tokens over threshold T across R recipients (C cold, re-load about L tokens); call compass_message_confirm with handle H to post”.
Q6: Gate order against the post ACL
Section titled “Q6: Gate order against the post ACL”Ledger row: DL-464.
The gate never holds a post the store will deny. In PostAsAccountByName, after the channel resolves and before any estimate:
- If
client_request_idis non-empty and a message exists for(author, client_request_id), return it, so a landed retry is never reported “not posted”. - If the author is not a channel member (
Store.IsChannelMember), skip the gate. - If
create_topicis false and the topic does not exist in the channel, skip the gate. - If the channel is
OWNER_ONLYand the author is not its owner, skip the gate.
A skipped gate falls through to PostAsAccount, and the store returns its own error. For estimate_only, step 3 is skipped (Q5), and steps 2 and 4 return the store error directly.
Q7: Last-active from the transcript
Section titled “Q7: Last-active from the transcript”Ledger row: DL-465.
Last-active is the timestamp of the newest entry in r‘s newest session, read by the T3 query that reads ctx_r. It feeds reload_tokens and the cold count. It is durable, so a restart or second replica sees the same value. Live presence comes from comms’ existing PresenceSource.
Q8: Hard limits
Section titled “Q8: Hard limits”Ledger rows: DL-470 (length cap), DL-471 (fan-out ceiling).
Matt asked for a backstop a confirm cannot pass, leaning to length only. This record proposes two, each rejecting with ResourceExhausted; defaults are OQ1:
- Length cap (DL-470). An agent post whose
ceil(text bytes / 4)exceedsCOMPASS_MESSAGE_MAX_TOKENSis rejected. Default8000: a longer post belongs in a file or issue link, and one post’s text stays at most 4% of a 200,000-token context. Human posts are not capped. - Fan-out ceiling (DL-471). A post whose
fanout_tokensexceedsCOMPASS_MESSAGE_FANOUT_HARD_LIMITis rejected. Default 10 × the threshold. It is a separate row, so dropping it (OQ1 b) changes nothing else.
Both parse like the threshold; 0 turns a limit off. Order: Q6 pre-checks, length cap, fan-out ceiling, threshold hold. estimate_only returns the same rejections.
Alternatives considered
Section titled “Alternatives considered”- Gate on context re-load (
m + ctx_r × w_r). A cold recipient scored up to 250,000, so nearly every post to idle agents would be held, and Matt warned agents would stop sending. Kept asreload_tokens. - Checkpoint-onward transcript bytes as
ctx_r. Checkpoints do not follow every compaction, and the stored body keeps entries the loader drops. - In-memory last-active map. Lost on restart, split across replicas.
- HMAC token over a message and recipient digest. Needs a key and re-holds a shrinking audience.
- An
accepts_holdcapability flag. Matt ruled every agent post faces the gate. - Client-side estimate, new call arms, or a gate in
Store.AppendMessage. The agent lacks the recipient state; arms duplicate the post arm; the store would gate human posts.
Global Constraints
Section titled “Global Constraints”- Proto fields use the Q5 tags. Regenerate with
moon run compass-proto:genand commit the output.MessageCostEstimateis public, so nogen-fenceentry. - No new table; no edit to
0001_init.sql. New SQL is one sqlc query. - No new
CommsCallRequestorCommsCallResultarm; a later one starts at 15. - Human
CommsService.PostMessage,PostAsAccountandCommitAgentPoststay ungated and uncapped. - Setting
COMPASS_MESSAGE_COST_THRESHOLD,COMPASS_MESSAGE_FANOUT_HARD_LIMITandCOMPASS_MESSAGE_MAX_TOKENSto0restores today’s behavior exactly. - No tracker ids in source comments. No names of private repositories.
- Existing test assertions stay unmodified, except the
createCommsToolsexact-list assertion (comms.test.ts) and thecomms.tsheader comment, which gain the two tools. Tests moved in T2 keep their assertions. - Go: errors through
edgeErrorand the existing connect codes. TS: render failures throughcommsFailure.
T1: Wire fields (lane proto)
Section titled “T1: Wire fields (lane proto)”- Add the fields and message below to
proto/compass/v1/comms.proto. - Document on
PostMessageRequestthat the human RPC ignores fields 8 and 9. - Tests: none beyond generation; the behavior lands in T4.
Interfaces:
message MessageCostEstimate { // Sum over recipients of what the post adds; the gated number (design: compass-message-cost-gate Q1). uint64 fanout_tokens = 1; // The server threshold in force; 0 means the gate is off. uint64 threshold_tokens = 2; uint32 recipient_count = 3; uint32 cold_recipient_count = 4; // True when the post was held for confirmation; false for estimate_only. bool held = 5; // Approximate context re-load of non-WORKING recipients; never compared to the threshold. uint64 reload_tokens = 6;}
message PostMessageRequest { // ... fields 1-7 unchanged ... bool estimate_only = 8; uint64 confirmed_estimate_tokens = 9;}
message PostMessageResponse { Message message = 1; MessageCostEstimate cost_estimate = 2;}T2: Shared mention body in comms (lane server)
Section titled “T2: Shared mention body in comms (lane server)”- Move
mentionRE,parseMentions,mentionHandles,resolveMentionedandreservedMentionsfromgo/internal/deliveryintogo/internal/comms/mentions.go. Move theparseMentionstable test with it. - Delivery’s
routeMentionsForcalls the comms functions; on error it logs its two existing lines with the same attributes and drops all mentions, as today. - Point the
go/internal/store/handle.gocomments naming delivery’smentionREandreservedMentionsat comms. - Tests: existing delivery mention tests pass unchanged.
Interfaces:
package comms
// MentionReads is the store surface mention resolution needs; *store.Store satisfies it.type MentionReads interface { ChannelAgentMembers(ctx context.Context, channel store.ChannelID, author store.AccountID) ([]store.ChannelAgentMember, error) ResolveOwner(ctx context.Context, caller store.AccountID) (store.AccountID, error)}
// ResolveMentions wraps its two read failures in these, so delivery keeps its two log lines.var ( ErrMentionMembers = errors.New("comms: resolve channel agent members") ErrMentionOwner = errors.New("comms: resolve author owner"))
// MentionHandles returns the @-handles in blocks' text, in order, deduplicated.func MentionHandles(blocks []*compassv1.MessageBlock) []string
// ResolveMentions returns the mentioned channel agents author may reach.func ResolveMentions(ctx context.Context, r MentionReads, channel store.ChannelID, author store.AccountID, handles []string) (map[store.AccountID]bool, error)T3: Recipient state read (lane server)
Section titled “T3: Recipient state read (lane server)”- Add sqlc query
RecipientCostStateingo/internal/store/queries/agent_transcripts.sql. Per account, for its newest session (recorded_at_unix_ms DESC, session_id DESC, asLatestSessionForAccount), it returns:- whether an
agent_placementsrow exists; message.usage.input + cacheRead + cacheWriteof the newest non-checkpoint assistant row;- the newest row’s
timestamp.
- whether an
- Cast
entry_jsontojsonbonly for non-checkpoint rows; a value found only in a checkpoint body is parsed in Go from the body’s end. - Add
Store.MessageByRequestID(exportsgetMessageByRequestID) andStore.TopicInChannel(overGetTopicChannelandGetTopicByName) for Q6. An empty key returns(Message{}, false, nil)without a query, sinceGetMessageByRequestIDwould match unkeyed messages. - Tests (pgtest): usage sums the newest assistant delta’s three fields; a checkpoint body supplies usage and timestamp when no later delta does; only the newest session counts; no session means absent; no placement gives
Placed = false.
Interfaces:
type RecipientCostState struct { ContextTokens int64 // input + cacheRead + cacheWrite; 0 when unknown LastActiveUnixMs int64 // 0 when unknown Placed bool}
func (s *Store) RecipientCostStates(ctx context.Context, agents []AccountID) (map[AccountID]RecipientCostState, error)func (s *Store) MessageByRequestID(ctx context.Context, author AccountID, clientRequestID string) (Message, bool, error)func (s *Store) TopicInChannel(ctx context.Context, channel ChannelID, topic TopicRef) (bool, error)T4: Estimate, limits and gate (lane server)
Section titled “T4: Estimate, limits and gate (lane server)”- New file
go/internal/comms/cost_gate.go: the estimator (Q1), hard limits (Q8) and gate. PostAsAccountByNameorder:- Resolve the channel.
- All three settings
0and notestimate_only→ post as today. - Run the Q6 pre-checks, then the length cap unless it is
0. - Recipients =
SubscribedAgents(channel, account)∪ResolveMentions(...), minus the author. ReadRecipientCostStatesandPresenceFor; computefanout_tokensandreload_tokens. - Apply the fan-out ceiling unless it is
0. estimate_only→ return the estimate.threshold == 0orfanout_tokens ≤ threshold→ post.confirmed_estimate_tokens > 0andfanout_tokens ≤ confirmed × 1.1→ post. Otherwise → hold; nothing is appended.
- In
executeCall, skiplinkTriggerwhenestimate_onlyis set. - Add
resolveTokenLimitingo/cmd/compass-server/main.gofor the three settings (firstNonEmpty(flag, env)), intoServeConfig; wireSetCostGateingo/server/serve.go. - Add the Q3 counter beside
fabricPublishFailures. - Tests:
- Under the threshold posts; over it holds and appends nothing;
estimate_onlynever posts and ignores the topic; all settings0reads no recipients; threshold0with limits on never holds. - A confirm within 10% posts; a grown audience holds again; a smaller one posts.
- Over the length cap or ceiling returns
ResourceExhausted, even with a confirm; a human post over the cap posts. - A large
reload_tokenswith a smallfanout_tokensposts. - A stored
client_request_idreturns the stored message; an empty key with earlier unkeyed messages posts. A non-member, a missing topic withoutcreate_topic, and a non-ownerOWNER_ONLYpost return the store error, not a hold. - A mentioned WORKING recipient costs
m; a WORKING subscriber or idle one costsm + turnOverhead; no placement costs nothing; ask text counts towardm;ctx_rclamps atmaxContextTokens. resolveTokenLimitmaps empty to the default and0to off, and rejects junk.
- Under the threshold posts; over it holds and appends nothing;
Interfaces:
type CostGateConfig struct { ThresholdTokens uint64 // 0 = off; default 100000 (OQ1) HardLimitTokens uint64 // 0 = off; default 10 × ThresholdTokens (OQ1) MaxMessageTokens uint64 // 0 = off; default 8000 (OQ1) TurnOverheadTokens uint64 // 2000 WarmWindow time.Duration // 5 * time.Minute MaxContextTokens int64 // 200000 ConfirmSlack float64 // 1.1 Now func() time.Time}
func (c *Comms) SetCostGate(cfg CostGateConfig)
func (c *Comms) estimateCost(ctx context.Context, author store.AccountID, channel store.ChannelID, blocks []*compassv1.MessageBlock) (*compassv1.MessageCostEstimate, error)
// go/cmd/compass-server/main.gofunc resolveTokenLimit(v string, def uint64) (uint64, error)T5: Agent tools and docs (lane agent)
Section titled “T5: Agent tools and docs (lane agent)”- In
packages/compass-agent/src/comms.ts, addcompass_message_costandcompass_message_confirmtocreateCommsTools(twelve tools become fourteen). - On a held result,
comms_post_message,comms_post_askandcomms_dmstore{request, estimate}under a local handle (h1,h2, …; 10-minute expiry, at most 32 entries, oldest evicted) and render the Q5 text. AResourceExhaustedrenders throughcommsFailure. compass_message_confirmre-sends the stored request with the sameclientRequestId,confirmedEstimateTokens= the storedfanoutTokens, andturnSequence= the current turn. A re-hold updates the stored estimate under the same handle.- Update
docs/concepts/tools.md(write-tool list) anddocs/concepts/comms-model.md(one paragraph on the gate and limits). - Tests (
comms.test.ts): a held result renders handle, fan-out and re-load and posts nothing; confirm re-sends the originalclientRequestIdwith the confirmed fan-out and current turn sequence; an unknown handle returns the re-post instruction;compass_message_costsetsestimateOnly.
Interfaces:
export const messageCostParameters = type({ channel: type("string") .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .describe("Target channel by name; must not be blank"), text: type("string") .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .describe("Markdown message body to price; must not be blank"),});
export const messageConfirmParameters = type({ handle: type("string") .narrow((s, ctx) => s.trim().length > 0 || ctx.mustBe("non-blank")) .describe("Handle from a held post, such as h1; must not be blank"),});- T1: Wire fields in
comms.proto, regenerated. - T2: Mention body moved to package comms; delivery unchanged in behavior.
- T3:
RecipientCostStates,MessageByRequestID,TopicInChannelstore reads. - T4: Estimate, hard limits and gate in
PostAsAccountByName; three settings; counter. - T5:
compass_message_cost,compass_message_confirm, held rendering, docs.
Resolved decisions
Section titled “Resolved decisions”Settled by Matt on 2026-10-11:
- Option A: every agent post faces the server hold and cost-bound confirm, with no
accepts_hold(Q5). - Warm window 5 min; constants
0.1,1.25, bytes/4, the 200,000 clamp and 1.1 slack, tuned from the Q3 counter (Q1). - A recipient’s other batched messages are not counted (Q1).
Open Questions
Section titled “Open Questions”OQ1: Default numbers on the new fanout_tokens unit (load-bearing)
Section titled “OQ1: Default numbers on the new fanout_tokens unit (load-bearing)”Matt has not seen the fanout_tokens unit. All options keep turnOverhead = 2000:
- (a) Threshold
100000, length cap8000, plus the fan-out ceiling. - (b) The same without the ceiling: a length-only backstop, as Matt leaned.
- (c) Threshold
250000, his suggested start, length cap only.
Recommendation: (b): it follows his length-only lean, and the ceiling is a separate row (DL-471) to drop. DL-461, DL-470 and DL-471 stay proposed until he rules.
The Plan builds (a). For (b), drop T4 step 5, CostGateConfig.HardLimitTokens, COMPASS_MESSAGE_FANOUT_HARD_LIMIT, the rejected_ceiling outcome and DL-471. For (c), also set the threshold default to 250000. The ruling is folded in before merge.