Skip to content

RIG-2257 — Compass ask-answer recovery: answer-as-message

Status: Draft

This record is the RIG-2257 successor to the T7 runner/hub dependency filed by docs/designs/product/compass-ask-comms-roundtrip/design.md Decision 3. It is a pre-freeze rewrite of this same record: Matt ruled the prior draft’s owed-marker + control-op-wake architecture out (see Alternatives considered), in favor of the answer to an ask becoming a normal message on the existing durable delivery rail. Pre-GA nothing is frozen — the RIG-1509 agent half (the AskAnswerControl consumer) is reworked here, not preserved. Owner lane: compass-server.

The ask-answer wake is keyed on the session id live at RespondToAsk time, so an answer submitted while the asking agent has no live session — or after a Runner reconnect / container resume minted a new session id — is silently dropped and never redelivered: go/internal/runnerhub/ask_waker.go:34-36 reads

sessionID, ok := h.SessionForAccount(agent)
if !ok {
return // no live session to wake; the agent reads the answer on its next turn
}

and the “reads the answer on its next turn” fallback in that comment is false for asks: the durable delivery sweep excludes the author — AND m.author_account_id <> $1 (go/internal/store/delivery_cursors.go:484) — and the ask (including its answered-in-place block) is agent-authored, so the agent’s own sweep skips it. That author-exclusion is the entire reason the ask answer ever needed a bespoke wake.

Fix it at the root: make the answer a new message authored by the answerer (a participant, ≠ the agent), carrying an answer block correlated by ask_id. An answerer-authored message is NOT author-excluded, so it is swept, delivered, acked, and reconnect-redelivered by the one delivery rail that already exists — no new durability machinery, no agent-side boot poll (the ratified RIG-1509 Decision 3 exclusion stands).

RespondToAsk keeps recording the answer on the ask block (the answer-once guard is untouched) and additionally posts a new message — authored by the answering participant, into the ask’s own channel + topic — whose single block is a new ask_answer message-block variant carrying the answered ask snapshot. That message rides the normal post → fan-out → deliver → ack → sweep pipeline unchanged.

The rail was never unusable for answers — it was unusable for agent-authored rows. UndeliveredMessages is the sweep read (delivery_cursors.go:460), and its exclusion is precisely the author:

AND m.author_account_id <> $1

(delivery_cursors.go:484). Flip the author — the answer message’s author_account_id is the answerer, resolved server-side from the RPC actor (c.actorFromContext(ctx), the same value AnswerAsk authorizes on, comms.go:397) — and the exclusion no longer bites. The agent is a member of the ask’s channel by construction (it posted the ask there, and AppendMessage gates posting on membership: “the author must be a member of the target channel”, store/messages.go:51-57), so the answer lands in a channel the agent can be swept for.

The rail is ack-gated at-least-once end to end, with the ack frame already on the wire — no new proto ack is needed:

  • Ack-gated cursor. The per-(agent, channel) delivery cursor advances only on the recipient’s delivery_ack receipt: the hub’s deliverAck “advances the durable delivery cursor for a recipient’s delivery_ack … the Runner->Server receipt that a relayed deliver reached the session” (go/internal/runnerhub/hub.go:699-710), through store.AckDelivery (delivery_cursors.go:307).
  • Reconnect resweep. UndeliveredMessages returns “the messages still owed to this agent, ascending seq per channel: seq > acked_seq” (delivery_cursors.go:451-455), and the delivery consumer runs it on every session start: OnSessionStarted “is the hub’s SessionStartSink hook … called from promoteSession right after the hub binds account->session … and, in the single-Runner MVP, on the re-promotion each session takes after a Runner re-enroll clears the bindings” (go/internal/delivery/settle.go:37-43).
  • Agent-side dedup. The agent dedups delivered messages by msg.id (the control source “dispatched through immediate.steer / immediate.deliver, where the CompassAgent dedups on msg.id”, packages/compass-agent/src/transport/control-source.ts:22-25), so an at-least-once redelivery of the answer message is absorbed.

So offline answer, crash-before-ack, Runner reconnect, and container resume all collapse into cases the rail already handles for every other message.

A third variant on the wire block oneof — today message MessageBlock { oneof block { string text = 1; Ask ask = 2; } } (proto/compass/v1/comms.proto:343-350) — and a matching third pointer on the store model, whose doc already states the invariant to extend: “Exactly one of Text / Ask is non-nil, mirroring the wire block oneof” (go/internal/store/types.go:308-316).

// comms.proto — new field on the existing oneof (additive, buf-safe)
message MessageBlock {
oneof block {
string text = 1;
Ask ask = 2;
AskAnswerBlock ask_answer = 3;
}
}
// The answer to a previously-posted Ask, authored by the answerer. Entirely
// server-owned: constructed by RespondToAsk from the just-answered stored ask;
// a client-supplied ask_answer block (PostMessage / the agent update path) is
// rejected — answers enter only through RespondToAsk, which enforces the
// answer-once guard and answer validation.
message AskAnswerBlock {
// The answered ask snapshot: ask_id for correlation, answered=true, and the
// per-question recorded answers (chosen_option_ids / custom_text — the Ask
// message already carries answer state "kept for audit", comms.proto:403).
Ask ask = 1;
// The asking agent's account id, denormalized so the delivery consumer can
// target the asker without an ask_id -> author lookup per message.
string asker_account_id = 2;
}

Why a full snapshot, not a slim {ask_id, answers} pair: the RIG-1509 agent half currently renders an answer against a session-scoped in-memory PendingAsks registry (“keyed by the server-minted ask_id … session-scoped and not durable”, packages/compass-agent/src/comms.ts:224-234) — exactly the state a restart wipes, producing the unknown-ask-id degraded arm (agent.ts:713-724). The snapshot carries the questions AND recorded answers in the delivered message itself, so the agent renders registry-free and the registry is deleted outright rather than made durable. The snapshot is cheap: it is the same Ask the answered message row already stores.

Server-owned: only RespondToAsk constructs the block (from AnswerAsk’s returned, validated, answer-once-guarded ask). The wire mappers reject an inbound ask_answer on both client write paths — blocksFromWire (POST, comms/mapping.go:312) and updateBlocksFromWire (agent update, mapping.go:338) — mirroring how askFromWire already strips client-supplied ask_id because it is server-owned (mapping.go:355-362).

One instance of a general pattern: special message blocks

Section titled “One instance of a general pattern: special message blocks”

The ask_answer variant is deliberately framed as instance #1 of a “special message block” pattern, not a one-off. The pattern:

  1. Wire: a new message type + a new field on the MessageBlock oneof (additive proto3 field — buf-breaking-safe, exactly as the repo already notes for control-oneof additions: “Defining the empty shells now is additive and buf-breaking-safe”, proto/compass/v1/agent.proto:152-153).
  2. Store: a new pointer field on store.MessageBlock + a new blockKind discriminant and storedBlock arm (store/blocks.go:20-33), with its validation in marshalBlocks’ exactly-one-of switch (blocks.go:71-91) and its decode arm in unmarshalBlocks (blocks.go:178-217, whose “totality discipline: a missing arm must never pass silently” already enforces that every new kind is handled everywhere).
  3. Edge: a blocksToWire arm (mapping.go:187-198) plus an ownership decision per variant — client-postable (like text/ask) or server-owned (like ask_answer), enforced in blocksFromWire/updateBlocksFromWire.
  4. Delivery: free for channel-broadcast blocks. Any message carrying the new block inherits fan-out, the settle gate, live deliver, the ack-gated cursor, and the reconnect sweep with zero delivery-layer work, because the rail moves whole messages and never inspects block kinds. A block needing recipient-targeted delivery (an approval grant addressed to one agent, a status card for a specific session) adds one kind-aware arm in the shared fan-out/recovery body — exactly as ask_answer does in T5 to reach an out-of-sweep asker; the free inheritance is the untargeted broadcast case.
  5. Agent: one rendering arm in the deliver lane keyed on the block kind.

Future variants — approval requests/grants, structured status cards, richer attachments — each follow the same five steps and inherit the same durability. This is the scaling argument against the rejected design: a bespoke control op per interaction type re-invents delivery, ordering, ack, and recovery once per type; a block variant inherits all four.

Atomicity: answer-record and answer-message commit together

Section titled “Atomicity: answer-record and answer-message commit together”

AnswerAsk is already a single serialized read-modify-write transaction (FOR UPDATE on the matched message row, store/messages.go:545-571). The answer message is inserted in that same transaction: flip Answered, write the updated blocks, insert the answer message (same channel, same topic — the ask row’s TopicID), commit. So “ask answered” and “answer message exists” are one atomic fact — there is no window where the answer is recorded but the delivering message was lost to a crash. The insert reuses AppendMessage’s core insert + topic last_seq maintenance, extracted into a tx-scoped helper (T3) that runs inside AnswerAsk’s existing transaction.

No idempotency key on the answer insert — the answer-once guard is the sole single-fire mechanism. The helper passes client_request_id = "", so AppendMessage’s dedup ON CONFLICT (author_account_id, client_request_id) partial index (store/messages.go:96-98) is structurally unreachable for the answer row. A derived key like ask-answer:<ask_id> was rejected: it lives in the client-controllable namespace (PostMessage passes req.Msg.GetClientRequestId() through verbatim, comms.go:367), so if the answering account had ever posted any message with that exact client_request_id, the answer insert would be silently suppressed by DO NOTHING while the Answered flip commits — the ask reads answered, no MessagePosted fires, and the asker never gets the answer: the exact silent-loss class this design exists to kill, reintroduced by its own belt. It defends nothing the guard does not: the answer-once guard already makes the insert single-fire (a post-commit retry hits ErrConflict before reaching the insert; a rollback undoes both flip and insert atomically).

The extracted helper does NOT inherit AppendMessage’s conflict arm. AppendMessage’s ON CONFLICT path handles noRows by re-reading the committed row through the pool and letting its own tx roll back (messages.go:105-110, “the tx rolls back, unwinding the topic get-or-create too”). That arm cannot be transplanted into AnswerAsk’s tx: a rollback there would lose the Answered flip, and a pool read races the still-uncommitted row. insertMessageTx therefore contains only the insert + last_seq bump — no conflict arm, no pool re-read, no rollback-and-return. Since the answer insert carries no dedup key, the conflict path is unreachable by construction; if the insert ever reports zero rows affected it is an invariant violation (the guard says it cannot happen), so the helper returns an error and AnswerAsk rolls the whole answer back rather than committing a flip with no message.

The answer-once guard is untouched and remains the sole answered-gate: “if ask.Answered { return … ErrConflict …}” — “Answered flips only here, so this is the sole gate” (store/messages.go:631-634). A second RespondToAsk still fails before any message is posted, so at most one answer message ever exists per ask.

RespondToAsk publishes MessagePosted for the answer message (the delivery trigger: “A MessagePosted is the delivery trigger”, go/internal/delivery/consumer.go:356-365) alongside the existing MessageUpdated for the ask message (comms.go:404, kept — it is the UI update, not a delivery trigger). Live agent ⇒ normal deliver control; offline agent ⇒ the cursor sweep owes it.

Audience is the channel, not just the asker. The answer is a normal channel message, so it fans out to every subscribed agent in the ask’s channel — each renders “X answered the ask: …” and pays one deliver+ack cycle. This is intended channel semantics (the ask was asked in that channel; an ask in a home channel or DM has an audience of one anyway), not asker-private delivery. Only the out-of-sweep-set backstop below is asker-targeted. If channel-wide agent rendering is ever unwanted, that is a T6 render-arm policy choice (render only when asker_account_id == self) — named here as deliberately NOT taken.

One edge needs a targeted arm: the deliver set and the sweep set are the same disjunct — “subscribed OR home OR mandatory”. SubscribedAgents, the set fanOut iterates for live delivery (delivery_reads.go:29-39), and InSweepSet, the cursor-sweep predicate (delivery_cursors.go:498-512), mirror each other exactly “so the two never drift”. So an asker in the channel but not subscribed (and not home, not mandatory) is invisible to both the live deliver loop and the reconnect sweep — without a targeted arm its answer is never delivered at all.

The owed row alone is not sufficient here: OwedMentions is drained only by sweepOwedMentions at OnSessionStarted (delivery/settle.go:136), so a durable-row-only backstop delivers to an unsubscribed asker only at its next session start — an unsubscribed asker with a live session at answer time would have its answer stranded for the whole session lifetime (potentially the exact wedged-waiting-agent scenario RIG-2257 exists to fix). The fix is the full shape RIG-1641’s routeMentionsFor already uses (dispatch.go:139-152): when the answer block’s asker_account_id is outside the channel’s sweep set, (1) record the durable owed row via the existing RecordOwedMention (delivery_cursors.go:90) — the offline backstop, drained by the existing OwedMentions sweep (consumer.go:91-93); then (2) re-check SessionForAccount and, if the asker is live, dispatch the answer directly to it — the latency path. The owed row and the live dispatch are complements (durability vs latency), not alternatives. No new table, no new sweep — the answer is a structurally-targeted message exactly like a mention.

The targeted arm must be recovery-replayable, not fan-out-only. The owed row is recorded at fanOut, which runs off the ephemeral MessagePosted bus — the exact window RIG-2490’s pre-settle scan exists to close. If the consumer restarts between AnswerAsk’s commit and fanOut processing the in-memory event, the answer message is durably committed but still mentions_routed_at IS NULL, so it reappears in UnroutedMentionMessages (the recovery read selects purely on the NULL mark, delivery_cursors.go:240) — but scanMissedMentions replays only routeMentionsFor (scan.go:55), a no-op for a mention-less answer message, and then marks it routed. So a fan-out-only ask_answer arm would let a restart in that window strand the out-of-sweep asker permanently — the very no-silent-loss hole this section closes. The arm therefore lives in the same shared body routeMentionsFor does: both fanOut (dispatch.go:100) and scanMissedMentions (scan.go:55) call it, so recovery re-derives the owed row exactly as it re-derives mentions.

The owed backstop delivers as a STEER, not a deliver. sweepOwedMentions dispatches every owed row through the steer vehicle (settle.go:201-203), so the same ask_answer message reaches the asker via two agent lanes depending on subscription state: the coalesced deliver-lane prompt (subscribed asker) or a steer interrupt (owed backstop). Both render through the same formatDeliversForPrompt block switch the agent grows below (the steer arm already routes through it, agent.ts:449) and both DeliveryAck per msg.id, so the single new render arm covers both lanes and dedup absorbs any overlap — but the T6 render arm therefore has two callers, pinned by a steer-lane test (T7).

The agent half, reworked (RIG-1509 is fair game)

Section titled “The agent half, reworked (RIG-1509 is fair game)”

The agent stops consuming answers from the control lane and starts recognizing them on the deliver lane it already runs:

  • The deliver path’s renderer today ignores non-text blocks (formatDeliversForPrompt; test: “The ask block is ignored — deliver carries channel text only”, agent.test.ts:1767-1771). It grows an ask_answer arm: a delivered message carrying an answer block renders via the existing formatAskAnswerForPrompt (agent.ts:846, kept — adapted to read questions and answers from the block’s answered-Ask snapshot instead of a registry + control payload).
  • The askAnswer control case (agent.ts:691-744), the PendingAsks registry (comms.ts:231-249), the ask tool’s record() call, and the #askAnswerQueue plumbing are deleted. Delivery ordering, the replay barrier, turn coalescing, dedup by msg.id, and the ack all come from the deliver lane for free.
  • On the wire, AgentControl.ask_answer (tag 4) and message AskAnswerControl (proto/compass/v1/agent.proto:164,181-191) are removed, not reserved — the repo’s established pre-GA convention: “the oneof and field 7 … are both REMOVED, not reserved (F9: pre-dogfood, zero stored payloads)” (comms.proto:327-329).

This design deletes more than it adds. Removed outright:

  • go/internal/runnerhub/ask_waker.go — the whole file (WakeAskAnswer).
  • go/internal/comms/ask_waker.go — the whole file (AskAnswerWaker).
  • comms.Comms.askWaker field, SetAskWaker (comms.go:49-57, 82-84), and the wake call in RespondToAsk (comms.go:412-414).
  • The var _ comms.AskAnswerWaker = (*runnerhub.Hub)(nil) assertion (go/server/sinks.go:80) and the SetAskWaker wiring in server assembly.
  • Proto: AgentControl.ask_answer oneof field + AskAnswerControl (agent.proto:164, 181-191), removed not reserved; regenerated in all three gens (go/internal/gen/…, go/gen/…, packages/compass-agent/src/gen/…); the AskAnswerControl token drops from the proto leak-gate grep (proto/moon.yml:162).
  • Agent: the askAnswer control arm (agent.ts:691-744), the #askAnswerQueue and #pendingAsks fields, PendingAsks/createPendingAsks (comms.ts:231-249), the raise tool’s registry record(), and the askAnswer arms in control.ts/frame.ts/control-source.ts docs+code.
  • Tests bound to the deleted wake rail (compiler-caught, but named so the tree builds): in comms/comms_test.go, fakeAskWaker + wakeCall (283-312) and the three SetAskWaker tests (314-390, 488-520) + the nil-waker test (522-531); in runnerhub/deliveryarm_test.go, the three TestWakeAskAnswer* tests (483-581) exercising the deleted Hub.WakeAskAnswer; in runner/gateway/control_test.go:269-270, the AskAnswerControl “representable variant” example — swapped to a surviving op (prompt/deliver), not deleted. On the TS side, control-wire.test.ts and compassv1.ts:73-74 regenerate/rebuild with the removed control arm.
  • From the superseded draft of this record (never implemented — verified by grep: no AnswerDelivered/UndeliveredAskAnswers/MarkAskAnswerDelivered symbols exist): the owed-marker, the owed queries, the router un-flip machinery, and the WakeAskAnswer sweep arm are simply never built.

Rejected: the prior draft of this record — owed-marker + control-op wake

Section titled “Rejected: the prior draft of this record — owed-marker + control-op wake”

The superseded draft kept the AskAnswerControl wake and made it durable with an AnswerDelivered bool marker on the stored ask block, a derived owed-set JSONB query, an OnSessionStarted drain arm, and un-flip-on-observed-refusal machinery riding the router’s refusal correlation. Rejected NOW because:

  • It builds a second delivery system. Marker, owed query, sweep arm, refusal un-flip, ABA-race analysis — each a bespoke re-implementation of durability the message rail already provides (ack-gated cursor + resweep, hub.go:699-710 / delivery_cursors.go:451-455). Its best case was “at-least-once for the observable-refusal class”; the rail is at-least-once for everything, because the cursor advances only on the recipient’s own ack — no refusal inference needed.
  • It doesn’t scale. Every future structured interaction (approvals, etc.) would repeat the whole apparatus: its own control op, its own owed marker, its own sweep arm, its own refusal handling. The special-message-block pattern amortizes all of it into one block variant per type.
  • Its premise dissolved. The draft treated the merged RIG-1509 agent half as frozen substrate it must not touch, which forced the answer onto the control lane. Pre-GA nothing is frozen; reworking the agent half to consume a delivered message removes the constraint that made the bespoke rail necessary.

Its sub-variants are subsumed with it: marker-on-block (its Option A) and owed-answer table (its Option B) both exist only to track owed control-op delivery, which no longer exists; per-message delivered-column (its Option C) was rejected there on granularity and stays rejected — here granularity is a non-issue because the answer block carries its own ask_id and one answer message exists per ask.

Rejected: slim answer block ({ask_id, answers} without the ask snapshot)

Section titled “Rejected: slim answer block ({ask_id, answers} without the ask snapshot)”

Smaller on the wire, but it forces the agent to keep (or re-fetch) the question set to render an answer — either resurrecting the in-memory PendingAsks registry this design deletes (with its restart-wipe hole, comms.ts:224-234) or adding a fetch-by-ask_id RPC round-trip on the render path. The snapshot costs one duplicated Ask JSONB per answered ask and buys a stateless agent renderer.

Rejected: in-place mutation as the delivery vehicle (status quo)

Section titled “Rejected: in-place mutation as the delivery vehicle (status quo)”

Keeping only today’s AnswerAsk in-place block mutation + MessageUpdated cannot deliver: MessageUpdated “is NOT itself a trigger” (delivery/consumer.go:356-359), and the mutated message stays agent-authored and thus sweep-excluded (delivery_cursors.go:484). This is the bug, not a design.

Still excluded by the ratified RIG-1509 Decision 3 (“There is deliberately no agent-side boot poll”, compass-ask-comms-roundtrip/design.md:192-194) — and now also unnecessary: the start-edge sweep is the poll, server-side, already built.

Do NOT edit DECISIONS.md in this PR — the caller applies the rows at PR time (the ledger is concurrently relocating docs/designs/product/DECISIONS.mddocs/designs/DECISIONS.md under compass PR #576). Ledger-impact: deferred to freeze. These rows REPLACE the prior draft’s DL-241..244 (never applied to any ledger; the draft did not merge). Rows start at DL-241:

  • DL-241 — The answer to an ask is delivered as a new message authored by the answerer, carrying a server-owned ask_answer block (an answered-Ask snapshot + asker_account_id), posted in the ask’s channel/topic within the AnswerAsk transaction. Delivery, ordering, ack, and recovery are the existing message rail’s (MessagePosted fan-out + ack-gated cursor + OnSessionStarted resweep); there is no ask-specific delivery machinery. Supersedes the owed-marker + AskAnswerControl-wake approach; the control variant is removed from the wire (removed, not reserved — pre-GA, zero stored payloads).
  • DL-242 — Structured interactions ride special message blocks: a new MessageBlock oneof variant (wire) + pointer field/blockKind (store) + edge-mapper arm with a per-variant ownership rule (client-postable vs server-owned) + one agent render arm. Every variant inherits the delivery rail’s guarantees; adding a control op for a per-type interaction requires a design record justifying why a block cannot carry it.
  • DL-243 — Ask-answer delivery is at-least-once (may over-deliver) via the rail’s ack-gated cursor; the agent absorbs redelivery by the deliver lane’s existing msg.id dedup. Exactly-one-answer is preserved independently by the store’s answer-once guard (Answered flip → ErrConflict), which also caps the answer messages at one per ask.
  • DL-244 — An asker outside the answer channel’s sweep set is backstopped by the RIG-1641 owed-mentions machinery (an owed row recorded at fan-out, drained by the existing owed sweep) — no new table or sweep. Deploy is under the recreate-on-schema-change regime (Matt ruled recreate): no backfill for pre-existing answered asks; a fresh DB has none.
  • Go on the server (compass/go, go1.26.6); TypeScript/Bun on the agent (compass/packages/compass-agent). //go:build unix on every runnerhub / delivery arm file (matches delivery/settle.go:1).
  • Proto changes are additive on compass.v1.MessageBlock (new oneof field 3) and removal, not reservation, of AgentControl.ask_answer (tag 4) + AskAnswerControl — the repo’s pre-GA convention (comms.proto:327-329). All three gens regenerate together: go/internal/gen/…, go/gen/…, packages/compass-agent/src/gen/….
  • The ask_answer block is server-owned: constructed only by RespondToAsk. Server-ownership is enforced at the wire edgeblocksFromWire/updateBlocksFromWire reject an inbound ask_answer as CodeInvalidArgument, because that is the only layer with caller identity. marshalBlocks(blocks []MessageBlock) (blocks.go:71) receives only the block slice and cannot distinguish RespondToAsk’s insert from any other caller, so it does not reject the variant (rejecting it would break AnswerAsk’s own answer-message persist); it accepts and structurally validates it, per T2.
  • The answer-once guard (store/messages.go:632-634) is untouched by every task; the answer-message insert is strictly downstream of it in the same tx.
  • The store block invariant extends, never forks: exactly ONE of Text / Ask / AskAnswer is non-nil per block (types.go:310), enforced in marshalBlocks and unmarshalBlocks’ totality switch (a missing arm never passes silently, blocks.go:178-181).
  • The delivery layer stays block-kind-agnostic on the deliver/ack/sweep path; the only kind-aware delivery code is the ask_answer asker-targeting arm, which lives in the shared fan-out/recovery body (called by both fanOut and the RIG-2490 recovery scan scanMissedMentions, T5) so live and recovery both re-derive it.
  • runnerhub never imports comms and vice versa (preserved; T4 only deletes from the boundary, adds nothing to it).
  • Tests are red-green (rule://red-green-testing): each task writes its failing test first, then the smallest change to green.

T1 — Proto: ask_answer block variant; remove the control op

Section titled “T1 — Proto: ask_answer block variant; remove the control op”

Add AskAnswerBlock and the MessageBlock.ask_answer = 3 oneof field (comms.proto:343-350); delete AskAnswerControl and the AgentControl.ask_answer = 4 oneof field (agent.proto:164, 181-191), removed not reserved. Regenerate all three gens; drop \bAskAnswerControl\b from the proto leak-gate grep (proto/moon.yml:162).

Interfaces:

comms.proto
message MessageBlock {
oneof block {
string text = 1;
Ask ask = 2;
AskAnswerBlock ask_answer = 3;
}
}
message AskAnswerBlock {
Ask ask = 1; // answered snapshot: ask_id, answered=true, recorded answers
string asker_account_id = 2; // the asking agent, denormalized for fan-out targeting
}

Tests: buf lint + generate clean; the leak gate passes with the token removed.

T2 — Store: AskAnswer block model + JSONB marshal

Section titled “T2 — Store: AskAnswer block model + JSONB marshal”

Add the third pointer to store.MessageBlock (types.go:311-316), an AskAnswerBlock store type, a blockKindAskAnswer discriminant + storedBlock arm (blocks.go:22-33), marshal/unmarshal arms (blocks.go:71-91, 178-217), and fold the answered snapshot’s text into textContent (blocks.go:219-236) so answers are searchable. marshalBlocks validates the variant: non-nil Ask with a non-empty AskID, Answered=true, and well-formed questions (reuse askQuestionsWellFormed, blocks.go:121-143); a non-empty AskerAccountID. mintAskIDs skips it (the snapshot’s id is the original ask’s, never re-minted).

Interfaces:

store/types.go
type MessageBlock struct {
Text *string
Ask *Ask
AskAnswer *AskAnswerBlock // exactly one of the three is non-nil
}
// AskAnswerBlock is the answer to a previously-posted Ask, authored by the
// answerer: the answered ask snapshot plus the asking agent's account id.
// Server-owned — constructed only by AnswerAsk; the comms edge rejects it on
// every client write path.
type AskAnswerBlock struct {
Ask Ask // answered snapshot (AskID set, Answered=true)
AskerAccountID AccountID // the ask message's author
}

Tests: round-trip marshal/unmarshal; exactly-one-of rejection for every two-set combination; corrupted-row error on kind/payload disagreement; textContent includes the answered custom text.

T3 — Store: AnswerAsk posts the answer message in-tx

Section titled “T3 — Store: AnswerAsk posts the answer message in-tx”

Extend AnswerAsk (messages.go:540-603): after applyAskAnswer + updateMessageBlocksExec, build the answer message — author actor, topic askMsg.TopicID, one AskAnswer block snapshotting the just-answered ask with AskerAccountID: askMsg.AuthorAccountID — and insert it in the SAME tx via an insert helper extracted from AppendMessage’s insert + topic last_seq maintenance (messages.go:90-137). The answer insert carries NO idempotency key — the helper is called with clientRequestID = "", so AppendMessage’s ON CONFLICT (author_account_id, client_request_id) dedup (messages.go:96-98) is structurally unreachable; the answer-once guard is the sole single-fire mechanism (see Atomicity). insertMessageTx contains only the row insert + last_seq bump — it does not port AppendMessage’s conflict arm (no pool re-read, no rollback-and-return, messages.go:105-110), which cannot run inside AnswerAsk’s tx without losing the Answered flip. A zero rowsAffected from the insert is an invariant violation → return an error so the whole tx rolls back. Return both messages. The membership/post-policy gates are not re-run for the insert: the actor’s membership in the ask’s channel is already proven by AnswerAsk’s visibility JOIN (messages.go:565-571), in this same tx.

Interfaces:

// store/messages.go — signature change; both callers (comms.RespondToAsk and
// its tests) migrate in this task.
func (s *Store) AnswerAsk(ctx context.Context, actor AccountID, askID string, answers []AskAnswer) (askMsg Message, answerMsg Message, err error)
// tx-scoped insert core shared by AppendMessage and AnswerAsk: row insert +
// topic last_seq GREATEST maintenance ONLY. No ON CONFLICT / dedup arm — the
// dedup path stays in AppendMessage, which calls this then handles conflict
// itself; AnswerAsk calls this with clientRequestID="" and relies on the
// answer-once guard for single-fire.
func insertMessageTx(ctx context.Context, tx pgx.Tx, m Message, topicID string, clientRequestID string) (Message, error)

Tests: answering posts exactly one answer message in the ask’s channel+topic, authored by the answerer, block snapshot carrying the recorded answers; a second RespondToAsk is ErrConflict with NO second message; a crash simulation (tx rollback) leaves neither the flip nor the message; the answer-once guard test corpus still green.

T4 — Comms: publish the answer; delete the wake rail

Section titled “T4 — Comms: publish the answer; delete the wake rail”

RespondToAsk (comms.go:383-416): consume the new two-message return, publish MessageUpdated(askMsg) (kept — the UI ask-state update) AND MessagePosted(answerMsg) (the delivery trigger). Delete the askWaker field, SetAskWaker (comms.go:49-57, 82-84), the wake call (comms.go:412-414), comms/ask_waker.go (the AskAnswerWaker interface file), runnerhub/ask_waker.go (the whole file), the structural assertion + wiring (server/sinks.go:75-80 and the SetAskWaker call in assembly). Add the ask_answer arms to blocksToWire (mapping.go:187-198, maps the snapshot via the existing askToWire) and the REJECTION arms to blocksFromWire / updateBlocksFromWire (mapping.go:312, 338) — CodeInvalidArgument, “ask_answer blocks are server-owned”.

Interfaces:

// comms/mapping.go — new wire<->store arms
// blocksToWire: case b.AskAnswer != nil -> &compassv1.MessageBlock{Block:
// &compassv1.MessageBlock_AskAnswer{AskAnswer: askAnswerToWire(b.AskAnswer)}}
func askAnswerToWire(b *store.AskAnswerBlock) *compassv1.AskAnswerBlock
// blocksFromWire / updateBlocksFromWire: case *compassv1.MessageBlock_AskAnswer
// -> connect.NewError(connect.CodeInvalidArgument, errServerOwnedBlock)

Tests: RespondToAsk fans out MessagePosted for the answer message; PostMessage carrying an ask_answer block is invalid-argument; the waker’s test consumers are deleted with it — fakeAskWaker/wakeCall and the three SetAskWaker tests (comms_test.go:283-390, 489-520) plus the nil-waker test (comms_test.go:522-531).

T5 — Delivery: targeted asker delivery (owed backstop + live dispatch)

Section titled “T5 — Delivery: targeted asker delivery (owed backstop + live dispatch)”

At fanOut (delivery/dispatch.go:99), when a posted message carries an ask_answer block, target the asker the way RIG-1641’s routeMentionsFor targets a mentioned agent (dispatch.go:139-152) — because the deliver set (SubscribedAgents, delivery_reads.go:29-39) and the sweep set (InSweepSet, delivery_cursors.go:498-512) are the same disjunct (subscribed OR home OR mandatory), so an unsubscribed non-home asker is reached by neither the live deliver loop nor the reconnect sweep without a targeted arm. Resolve the block’s asker_account_id; if InSweepSet(asker, channel) is false: (1) record the durable owed row via RecordOwedMention(ctx, asker, channel, msg.id) (delivery_cursors.go:90) — the offline backstop drained by the existing OwedMentions start-edge sweep (consumer.go:91-93); then (2) re-check SessionForAccount(asker) and, if the asker is live, dispatch the answer directly to it — the latency path. Owed row and live dispatch are complements (durability vs latency), not either/or: without the live dispatch, an unsubscribed asker with a live session strands until its next restart. A subscribed/home/mandatory asker needs neither arm — the normal deliver+cursor-sweep path already covers it.

The targeted arm lives in the shared fan-out/recovery body (§“Fan-out and the out-of-sweep-set edge”): both fanOut (dispatch.go:100) and the RIG-2490 recovery scan scanMissedMentions (scan.go:55) call it, so a consumer restart between AnswerAsk’s commit and fanOut re-derives the owed row on the next recovery scan (the answer message is committed mentions_routed_at IS NULL, so it appears in UnroutedMentionMessages) rather than stranding the out-of-sweep asker.

sweepOwedMentions dispatches an owed row as a STEER (settle.go:201-203), not a deliver — so T6’s render arm is reached by both the deliver lane and the steer lane (both route through formatDeliversForPrompt, both ack per msg.id).

Interfaces:

// delivery/dispatch.go — consumes only existing DeliveryReads methods:
// InSweepSet(ctx, agent store.AccountID, channel store.ChannelID) (bool, error)
// RecordOwedMention(ctx, agent store.AccountID, channel store.ChannelID, messageID string) error
// SessionForAccount(agent store.AccountID) (sessionID string, live bool) // hub, already used by routeMentionsFor
// (InSweepSet joins DeliveryReads if not yet on the interface; *store.Store
// already implements it.)
func askAnswerTarget(msg *compassv1.Message) (asker store.AccountID, ok bool)

Tests: answer posted while asker is member-not-subscribed + OFFLINE → owed row recorded → next OnSessionStarted delivers; answer posted while asker is member-not-subscribed + LIVE → dispatched directly WITHOUT a session restart (the F2 regression); asker subscribed → normal deliver, no owed row, no direct dispatch.

T6 — Agent: consume the answer from the deliver lane; delete the control arm

Section titled “T6 — Agent: consume the answer from the deliver lane; delete the control arm”

In packages/compass-agent: regen picks up the wire changes (the askAnswer control case no longer compiles — deletion is compiler-driven). Delete the askAnswer arm in #applyControl (agent.ts:691-744), the #askAnswerQueue, PendingAsks/createPendingAsks (comms.ts:231-249) and the raise tool’s record() call, plus the ask_answer mentions in control.ts/frame.ts/control-source.ts. Rework formatAskAnswerForPrompt (agent.ts:846) to render from the delivered block’s answered snapshot — questions and answers now live on the same AskQuestion objects (chosen_option_ids/custom_text) — and call it from the deliver render path (formatDeliversForPrompt, agent.ts:814) for a message whose block is ask_answer. Ordering, replay barrier, coalescing, msg.id dedup, and the delivery_ack all come from the deliver lane unchanged.

Interfaces:

// agent.ts — replaces formatAskAnswerForPrompt(questions, answers)
export function formatAskAnswerForPrompt(ask: Ask): string
// formatDeliversForPrompt grows the block-kind switch: text -> as today,
// askAnswer -> formatAskAnswerForPrompt(block.value.ask); bare ask blocks stay
// ignored on deliver (unchanged).

Tests: a delivered ask_answer message renders question text + chosen option labels + custom text (port the existing formatAskAnswerForPrompt corpus, agent.test.ts:1808-1891); redelivery of the same message id injects once; an answer delivered to a fresh session (post-restart, no registry) renders fully — the old unknown-ask-id degraded arm is gone.

Server-side integration tests over store + delivery + hub fakes (the existing consumer/hub test harness): (1) answer submitted with NO live session → OnSessionStarted sweep delivers the answer message → ack advances the cursor → second start delivers nothing; (2) answer delivered but ack lost → reconnect re-promotion redelivers (at-least-once); (3) duplicate RespondToAskErrConflict, exactly one answer message ever; (4) member-not-subscribed OFFLINE asker → owed-row path delivers at next start; (5) member-not-subscribed LIVE asker → direct dispatch delivers without a restart; (6) owed answer swept at session start arrives as a STEER and the agent’s steer arm renders it through the same ask_answer render path; (7) answer posted, consumer restarts before fanOut runs, out-of-sweep asker still recovers — the committed answer message (mentions_routed_at IS NULL) is re-picked by scanMissedMentions, which re-derives the owed row through the shared targeting body, and the next OnSessionStarted delivers it.

Interfaces: none new — exercises T2-T5 surfaces as built.

  • T1 — Proto: AskAnswerBlock + MessageBlock.ask_answer=3; remove AskAnswerControl + AgentControl.ask_answer; regen ×3; moon.yml gate.
  • T2 — Store: MessageBlock.AskAnswer + blockKindAskAnswer + marshal/unmarshal/validation/textContent.
  • T3 — Store: AnswerAsk returns (askMsg, answerMsg, err), inserts the answer message in-tx via extracted insertMessageTx.
  • T4 — Comms: publish MessagePosted(answerMsg); wire-mapper arms (to-wire map, from-wire reject); delete waker interface/impl/wiring.
  • T5 — Delivery: targeted asker delivery — owed row (RecordOwedMention) as offline backstop + live direct dispatch (SessionForAccount re-check).
  • T6 — Agent: deliver-lane ask_answer render; delete control arm, PendingAsks, #askAnswerQueue.
  • T7 — E2E: offline answer, reconnect redelivery, answer-once, owed-row backstop.

The prior draft’s three load-bearing OQs are resolved by Matt’s ruling: delivery mechanism → answer-as-message (this record); at-least-once vs ack frame → the rail’s existing delivery_ack (no new frame); deploy/backfill → recreate regime, no backfill (DL-244).

  • OQ-1 (non-load-bearing) — human-facing rendering of the answer message. Web/UI clients will now see an answerer-authored message carrying an ask_answer block in the channel timeline. Recommendation: render it as a compact “answered the ask” card keyed off the block; defer to the UI lane — the server contract (this record) is complete without it, and a client that ignores the block loses nothing (the ask block itself still shows answered state via MessageUpdated).
  • OQ-2 (RESOLVED in-design, not a fork) — is the live-targeted deliver arm droppable in favor of the owed row alone? No. The deliver set (SubscribedAgents) and the sweep set (InSweepSet) are the identical disjunct, and the owed row is drained only at OnSessionStarted (settle.go:136) — so an unsubscribed asker with a live session would have its answer stranded until its next restart if the owed row were the only arm. The owed row (durability) and the live dispatch (latency) are complements, both required; T5 specifies both, and T7 case (5) pins the live-unsubscribed path. Recorded here (not deleted) because the prior draft framed this as a droppable simplification, which the red-team falsified.