Skip to content

Multi-actor test coverage for the Compass comms surface (RIG-3473)

Status: Draft

The comms surface is densely covered horizontally and barely covered vertically. Measured across four tiers: the store tier alone carries 370 pgtest funcs, go/internal/comms another 83 pgtest + 41 untagged/unix, and pgtest is not a soft gate — it runs on every Go PR (its pgtest_affected flag is “setup’s closure contains compass-go”, ci.yml:457-461) under -race with a fail-closed anti-skip guard (ci.yml:596-620). So the DB tier is real and enforced, and “add more DB tests” is the lowest-value axis available.

The gap is that every tier stops at a fake of the tier below it, so the joins are unproven:

Surface Population Joined end-to-end
CommsService RPCs 21 (comms.proto) 5CreateAgent, ListAccounts, UpdateChannelMembers, PostMessage, SubscribeComms
Native agent tools 7 (packages/compass-agent/src/comms.ts — the five comms_* plus compass_roster and compass_set_status) 1comms_post_message
Relay execute-arms 9 (relay_comms.go CommsCallRequest_*) 0RelayCommsCall occurs in zero go/e2e files

Both sides of the relay are tested against a stub of the other side: the TS tool tests fake the transport by design (comms.test.ts:9-11: “The transport is faked to the one method the broker consumes (comms), so there is no socket, no Connect client, and no timing”, 95 cases), and the Go relay tests drive a hand-written fakeCommsCaller recorder (runnerhub/helpers_test.go). Each is a good test of its own half. Neither proves the halves agree. comms_dm, comms_open_dm, comms_list_messages, comms_post_ask, compass_roster, and compass_set_status have therefore never executed against a real server at any tier — six of seven. (Counting only the comms_* prefix hides the last two; the tool set is what createCommsTools returns, not what shares a prefix.)

Three specific holes fall out of the same measurement, beyond the multi-actor scenarios originally asked for:

  • DMs are absent above the store tier. OpenDM has 0 go/e2e occurrences; two of the six unjoined tools are the DM pair.
  • DM→channel conversion is store-tier only. It is UpdateChannelMembers.convert_channel_name (comms.proto:686-693) and carries a stateful side effect — converting frees the DM’s deterministic name so a later OpenDM mints a fresh pair DM (comms.proto:285-288). Exactly 2 tests exist, both go/internal/store/dm_pgtest_test.go (TestConvertOnAddRequiresNameAndConverts, TestOpenDMAfterConvertMintsFreshPair); ConvertChannelName has zero hits in any _test.go under go/internal/comms, go/server, and go/e2e — the sole non-store reference anywhere is the handler pass-through itself (go/internal/comms/comms.go:269). And no agent can convert today: update_members is a relay arm with no native tool in front of it (agent_gateway.proto:128-129, RIG-2673 in flight), so arm reachability and tool reachability are not the same thing — the convert is exercised over the admin client until a channel-management tool lands (T15).
  • The Pin relay arm has zero tests at every tier. CommsCallRequest_Pin (relay_comms.go, calling UpdatePinnedBoardAsAccount) has 0 constructions and 0 recorder reads in any _test.go in the module — while fakeCommsCaller does carry a pin field (helpers_test.go) that nothing ever populates. A recorded-but-never-dispatched field is worse than an absent one: it makes the fake read as complete.

This record therefore designs coverage on two axes: the multi-actor scenarios Matt asked for (many agent conversations, multiple channels and topics, DMs, DM→channel conversion), and the vertical joins those scenarios expose — the tool→relay→server path for every agent-reachable call.

Three tiers, each extended where it is the cheapest tier that can catch the regression class, with e2e reserved for what only the real stack proves (container agent loop, native tools, real transport, real credentials). The selection rule for the e2e tier is the join, not the scenario: a behavior whose semantics a lower tier already pins gets exactly one e2e case proving the path reaches that semantics, and no more.

  1. DB (pgtest) tier stays the home of visibility/policy semantics. visibility_filter_test.go already drives the D9 filter “end to end through the real connect server-stream” against real Postgres (visibility_filter_test.go:5-8: “The SubscribeComms per-event D9 visibility filter … driven end to end through the real connect server-stream”), with deterministic canary-ordered negatives. Re-proving each leak case behind podman would cost minutes per case for no new signal. We deepen this tier only where it is genuinely thin: supervisor orchestration, DM lifecycle, resolve failure modes, roster presence, and a comms-tier ring-lag resync test. This tier is already dense and already gated (Problem), so it is the smallest half of this record, not the largest.
  2. e2e (podman) tier gains multi-actor legs plus the tool→relay→server joins nothing else can reach: two session-running container agents conversing through native tools; delivery to a non-author subscriber; the six currently-unjoined agent tools (comms_open_dm, comms_dm, comms_list_messages, comms_post_ask) executed through the real relay for the first time; DM→channel conversion joined through an agent-opened DM (the convert itself rides the admin client — no native tool exposes UpdateMembers); a non-admin observer client exercising the D9 filter over the real TLS door (transport proof, one positive + one negative — not the full leak matrix); and positive offline→resume redelivery.
  3. The relay-arm tier (go/internal/runnerhub) is where arm coverage is completed cheaply. Nine arms exist; the Pin arm has no test at all. An arm’s own dispatch/attribution contract is a microsecond unit test against fakeCommsCaller (runnerhub/helpers_test.go) — it needs neither podman nor Postgres — so every arm gets one there, and only the agent-reachable scenarios pay for e2e. This is the tier the original draft omitted entirely, and it is why the record now closes 9-of-9 arms without 9 new containers.
  4. Fixture plumbing is extended minimally, driven by the two measured limitations below (see Plan task T1) — note the first turned out to be a non-limitation on measurement:
    • No per-account credential exists for a container agent — and none should. The draft conflated two roles. (a) Agent authorship needs no credential at all. The ratified trust model is that the Runner is a pure forwarder asserting NO account: it sends RelayCommsCall{session_id, call}, and the SERVER resolves session_id → agent account from its own binding (recorded from Provision’s agent_account_id via bindContainer, promoted onto the minted session id at Start via promoteSession) and executes under that account through the CommsCaller, which sets comms.WithActor in-process — runnerhub/relay_comms.go:7-15, headed “OQ-2, ratified — the load-bearing security leg”: “a session_id on the wire selects an account, it never carries one”, and an unknown/stopped/reconnect-dropped session fails closed CodeNotFound, “never the bootstrap-admin fallback”. This path is already proven end-to-end: legcomms_test.go:168 asserts GetAuthorAccountId() equals the provisioned poster account after Provision+StartSession, and the real compass-runner binary is in the stack (go/e2e/main_test.go:72 builds it once per run). So T2/T3/T5 author posts by scripting agent turns — zero new credential plumbing. (b) A test observer reading as a non-admin account is a CLIENT, not an agent — the only genuine gap. f.SubscribeComms rides the admin bearer (comms_ops.go:72-73, clients.go:63), so no e2e stream can assert what account X cannot see. IssueToken is the correct seam for that role — “Sole public-contract path to mint a non-bootstrap account’s token” (compass.proto:118-121) — and gains its first e2e coverage for free.
    • WithCannedScript is one shared positional script per fixture, not per-agent. The canned backend serves “request N settles on script[N]” (cannedmodel.go:92: “request N serves script[N]”), and every agent in the fixture dials the same backend (the root supervisor’s Setup turn already has to be marker-routed off the script for exactly this reason, cannedmodel.go:184: “it races the test agent on the positional counter”). Two agents’ turns therefore CAN be driven with existing plumbing iff strictly serialized (settle A’s turn before triggering B’s, so positional order is deterministic). Concurrent turns need marker routing — but newCannedMarker(marker, reply string) settles only text turns (cannedmodel.go:149-150: return cannedMarker{marker: marker, reply: reply}), never tool calls. T1 adds a marker-routed tool-call turn so a second agent can issue comms_post_message off-script.

Every proposed test obeys the house event-gating discipline: //go:build podman + podmanUsable() guard for e2e, //go:build pgtest + pgtest.RequireDSN(t) for DB, ctx-bounded waits only, and the canary-ordering pattern for negatives (visibility_filter_test.go:16-23) — no sleeps, no polling, no retries.

Recommendation (revised on Matt’s push — the draft’s four-independent-fixtures model was wasteful): share one fixture across the new comms legs and run them in parallel where they don’t contend, rather than paying a stack Up + seed container per leg. Prefer this over a -timeout bump; bump only if measurement says the shared shape still overruns.

  • Why the draft was expensive, stated plainly: it gave each leg its own NewFixture, and each NewFixture pays a real stack.Up plus an unavoidable root-supervisor seed container — the seed drives its OWN Provision+Start before the fixture returns (seed_settle_test.go:13-28: “NewFixture must not return until the first-launch root-supervisor seed has finished provisioning its container”), with no option to suppress it (fixture.go:368-375 unconditionally waits via f.waitSeedSettled). So four legs meant four Ups and four seed containers before any leg’s own work. That is the ≈10-14 min, and most of it is duplicated setup, not coverage.
  • The reuse seam already exists and is proven in-tree. TestMain is the precedent for run-scoped sharing: it builds the three stack binaries once for the whole package (“The suite stands up ~11 fixtures per run … building them per fixture meant 33 go-build invocations”; main_test.go). And newPersistentSite + WithSite already re-attach one postgres cluster and one port pair across two back-to-back Ups in a single test (fixture.go:520-528). So a site/stack outliving one leg is an established pattern here, not a new mechanism.
  • Plan: one shared fixture for the comms legs. Stand up a single fixture (one Up, one seed container) and run T2-T5 against it as top-level test funcs sharing it package-scoped (not subtests of one parent — see Global Constraints), each isolating on its own accounts, channels, and topics rather than its own stack. Per-leg cost collapses to only what the leg genuinely needs: T2 two agent containers, T3 one, T4 none (pure per-account RPC), T5 one. Estimated additional cost ≈ 4-6 min instead of ≈10-14, because three Ups and three seed containers disappear. T14 and T16 add two more legs on that shared fixture, and that is not free: ≈4-6 min was the T2-T5 figure. Each new leg pays its own agent container (Global Constraints forbid reusing another leg’s), taking the container count 4→6, and container provision is the expensive unit here — provisions serialize against a 30s rpcTimeout under CI load (fixture.go:359-380, RIG-2403). Adding ~10 model turns on top, expect ≈6-9 min, unmeasured; T2’s measurement governs and the -timeout bump stays the fallback. T13 and T15’s handler half add no podman time at all.
  • Parallelism: the suite currently has ZERO t.Parallel() (measured: no occurrence anywhere in go/e2e), so wall-clock today is strictly serial. T4 (no containers) and T3 (one) are the safe candidates to parallelise against a shared stack, since account/channel/topic isolation makes them non-contending. T2 and T5 must stay serial — the shared canned backend advances one positional script counter (cannedmodel.go:92), so two legs drawing unmarked turns concurrently race it, and T5 depends on session-start sweep timing. This is the real constraint on parallelism, and it is a property of the canned backend, not of the stack.
  • What this costs: failure isolation drops (a wedged shared fixture can affect the sibling legs) and the legs no longer shard on file boundaries. Mitigated by per-leg account/channel namespacing and by keeping T2/T5 serial. [INFERENCE] on every figure above — no per-leg timing exists in the tree; the one hard bound is the JOB ceiling timeout-minutes: 60 (ci.yml:1746) against the package’s -timeout 20m (ci.yml:2184). T2’s executor measures the shared-fixture shape first and records actuals; the -timeout bump is the fallback, not the plan.
  • Assert the full multi-tenant leak matrix at e2e. Rejected: visibility_filter_test.go and channel_policy_pgtest_test.go already pin the per-variant semantics against the real handler + real Postgres + real connect streams; the only thing they cannot prove is that the production credential path (TLS door + bearer → actor) feeds the same filter. One e2e positive (member sees) + one negative (cross-account stranger, canary-ordered, does not) proves the transport wiring; the matrix stays DB-tier where a case costs milliseconds.
  • One combined multi-actor leg. Rejected on wall-clock and failure-isolation grounds (above).
  • Extend legcomms_test.go in place. Rejected: the existing leg is the minimal “native tool through the loop” proof and is referenced as such; multi-actor scenarios get sibling files (legcomms_duo_test.go, legcomms_fanout_test.go, legcomms_tenant_test.go, legcomms_redeliver_test.go) mirroring the existing leg-file naming.
  • A new build tag / new CI tier for the multi-actor legs. Rejected: they are ordinary //go:build podman legs in the existing package, covered by the existing skip-guard. A new tag would need its own vacuous-green guard for zero benefit.
  • Toolchain: go1.27.1 linux/amd64, moon 2.5.3. Go tooling runs via direnv exec . bash -c 'cd go && …'.
  • No sleeps, no polling, no retries (rule://no-retries, fleet-binding). Every wait is ctx-bounded (AwaitTurnSettled, AwaitDelivery, AwaitControlDispatchOn) or canary-ordered (publish-before-subscribe replay with a globally-visible sentinel, per visibility_filter_test.go:16-23). A negative assertion is always proven by in-order delivery ahead of a canary, never by a timeout elapsing.
  • Context discipline: context.Background() appears only as the test root in _test.go files (the rule://go-thread-context exemption, matching legcomms_test.go:37); every helper accepts and threads the caller’s ctx.
  • New e2e legs: //go:build podman, podmanUsable() skip first (legcomms_test.go:33-35), NewFixture(ctx, t, ...), container-reaping t.Cleanup registered before StartSession (legcomms_test.go:89-96), store reads via store.Open(ctx, f.DSN()), tail-before-post ordering (DL-310 registration-ack happens-before), subscribe-before-post live-fan observation.
  • New DB tests: //go:build pgtest + pgtest.RequireDSN(t) (go/internal/pgtest/pgtest.go:72), own schema per test. New tagged files are named *_pgtest_test.go so the tag and the name agree; the existing 16-vs-11 mismatch is declined as a rename here (renames churn blame for zero behavior; see Resolved decisions).
  • Vacuous-green guarding: no new build tag and no new CI tier is introduced, so the existing guard covers the podman-skip class. Every new e2e leg lives in go/e2e under the existing podman tag and inherits that guard, which (a) greps the skip text out of go/e2e/harness_test.go source (ci.yml:2207-2208: skip=$(sed -n 's/.*t\.Skip("\(rootless podman[^";]*\)[^"]*").*/\1/p' e2e/harness_test.go)), (b) fails if that text appears in the log (ci.yml:2213-2217), and (c) separately requires the package ok line (ci.yml:2218: grep -qE "^ok[[:space:]]+github\.com/RigelBuild/compass/go/e2e[[:space:]]"). But package-level coverage is not leg-level coverage — one new guard half IS required; see the next three bullets. Every new pgtest file calls pgtest.RequireDSN(t), which auto-enrolls it in the pgtest guard’s source-derived package list (ci.yml:608: pkgs=$({ grep -rl 'pgtest\.RequireDSN' --include='*.go' . || true; }) and the COMPASS_REQUIRE_LIVE=1 hard-fail (ci.yml:564).
  • Guard coverage is by STRING IDENTITY, not by package membership — measured. The e2e guard greps one literal sed’d out of go/e2e/harness_test.go:29 (ci.yml:2207-2208) and requires the package ok line (:2218). The 9 existing skip sites in go/e2e (client_mode_test.go:46, go/e2e/harness_test.go:29, legcomms_test.go:34, legfive_test.go:35, legsix_test.go:51, legthreefour_test.go:52, legtwo_test.go:24+:81, seed_settle_test.go:37) each carry that literal copy-pasted verbatim — there is no shared skip helper, so podmanUsable() is re-checked per leg and the message is duplicated per leg. Consequence for every new leg: a leg that skips for any reason whose message differs from that literal still emits ok for the package (a skip is not a failure), and the skip-string grep does not match it ⇒ both guard halves pass while the leg asserted nothing. So the “no new guard needed” claim holds only under the constraint below.
  • Therefore, mandatory for T2-T5: the sole skip in a new leg is if !podmanUsable() { t.Skip(<the exact harness literal>) }, byte-identical to go/e2e/harness_test.go:29. No second skip condition, no reworded message, no t.Skipf. A leg needing a different skip reason is a design change requiring its own guard half, not a copy-paste. Reviewers: diff the skip line against go/e2e/harness_test.go:29 rather than reading it.
  • Wall-clock: no CI change is planned. The -timeout 20m30m bump (ci.yml:2184) is a fallback, not the plan — Matt rejected buying time before reducing it. T2’s executor measures the shared-fixture shape first and only proposes the bump if the measured package total lands within ~5 min of 20m. Ceiling context: the job allows timeout-minutes: 60 (ci.yml:1746), so the package -timeout is the binding limit, not the job.
  • Second fail-open class, which discipline CANNOT close — a wrong or missing build tag. A new leg whose constraint is misspelled (//go:build podman2) or absent is compiled out of the -tags podman run entirely: it never runs, emits no skip line, and the package still prints okboth existing guard halves pass and the leg is invisible. Unlike the skip-string class this is not detectable by reading the file, because the file looks correct in isolation. Mechanism (required, rides T2): extend the guard with a source-derived per-leg presence check in the same style as its existing halves — enumerate the podman-tagged leg test funcs from source (grep -oE '^func (Test[A-Za-z0-9_]+)' e2e/legcomms_*_test.go) and require a matching ^--- PASS: <FuncName> line for each in /tmp/e2e.log, erroring if the enumeration comes back empty (an empty enumeration must fail, never pass vacuously). -race -v is already set (ci.yml:2184), so the --- PASS: lines are present.
  • The four legs stay TOP-LEVEL func Test…, sharing the fixture package-scoped — not as subtests of one parent. This is a decision the guard depends on, so it is stated here rather than left to an executor: a subtest topology would emit --- PASS: TestParent/<subtest> and force the guard to derive names from t.Run literals instead of func declarations, which no existing guard half does. Top-level funcs keep the enumeration a straight func Test grep and are fully compatible with sharing one fixture (the sharing seam is package-scoped, the same scope TestMain already uses for the stack binaries, go/e2e/main_test.go:72).
  • Tests are deterministic, isolated, and parallel-safe within their package (or explicitly serial with a reason). The new comms legs SHARE one fixture and isolate on their own accounts, channels, and topics (Approach §Leg topology) — T3+T4 may run parallel, T2/T5 stay serial. No leg reuses another leg’s agent container; the shared fixture’s own stack and seeded supervisor are shared by design.
  • Ledger: Ledger-impact: one new row — id minted at merge time, NOT hardcoded. DL-324 is already taken (DECISIONS.md:222, forge Linear app-actor credential); tree max is DL-337 (DECISIONS.md:78), and in-flight PRs claim 338-341 (#913→338/339, #900→340, #932→341), so the next free id is ≥342. See Resolved decisions.

Dependency order: T1 → {T2, T3, T4, T5, T14, T16}; T14 → T15-e2e (T15’s e2e half rides T14’s leg and container, so it cannot start before T14 exists). T13 depends on nothing and can land first (relay-arm unit tests, no podman, no Postgres); T15’s handler half is likewise independent. T1 is no longer gated — OQ-2 is resolved: agent authorship needs no credential (the Runner relay binds session→account server-side), and only the observer-read seam needs a token (Approach §3). Once T1 merges the legs proceed together against one shared fixture — T3+T4 parallel, T2/T5 serial while the canned script stays one positional counter (cannedmodel.go:92); T12 lifts that constraint if T2’s measurement says it is worth buying. T2 lands first only because it carries the new per-leg --- PASS: guard half and the cost measurement. T6-T10 (DB tier), T13 (relay arms), and T15’s handler half have no dependency on T1-T5 or on each other and can all run in parallel immediately.

T1 — e2e fixture plumbing: observer-scoped clients + marker-routed tool-call turns

Section titled “T1 — e2e fixture plumbing: observer-scoped clients + marker-routed tool-call turns”

The one measured limitation (Approach §3b) closed, plus the marker-sequence fix — and, per the OQ-2 resolution, no agent-credential work at all.

  • Observer-scoped clients — the only credential work, and NOT an agent identity. Agent-authored posts need no credential (Approach §3a: the Runner relay binds session→account server-side, relay_comms.go:7-15, and legcomms_test.go:168 already proves the author lands correctly). What is missing is a reader: every fixture RPC today is admin (clients.go:63 func newAuthedClients(caPath, serverURL, adminToken string) (...); no e2e caller of IssueToken exists), so no leg can prove a negative — what an account cannot see. Add a fixture helper calling CompassService.IssueToken (compass.proto:121) for a handle and rebuilding the two Connect clients with that bearer via the existing bearerToken interceptor (clients.go:33-50). It is named for the role it plays: it mints a client/observer token, which is contractually what IssueToken is for. Enabling seam for T4’s visibility negative and T3’s per-member streams. Explicitly not how a container agent gets identity.
  • Marker-routed tool-call turns — a marker must serve a SEQUENCE, not one turn. cannedMarker settles text only (cannedmodel.go:127-130; newCannedMarker(marker, reply string)), so a second concurrent agent cannot tool-call without racing the positional counter (cannedmodel.go:184). The naive extension (one CannedTurn per marker) does not terminate, and this is the design’s sharpest hazard: the marker route is a plain substring test on the whole request body and returns unconditionally (cannedmodel.go:376-381: for _, m := range c.markers { if strings.Contains(string(body), m.marker) { c.writeTextTurn(...); return } }), while a tool-call turn needs two POSTs to settlecannedmodel_test.go:330-346 pins “serves turn[0] on the first POST and turn[1] on the second”, and legcomms_test.go:72-74 scripts exactly that pair for one agent turn. The body of the continuation POST still contains the marker-bearing message, so a one-turn tool-call marker re-matches and re-serves the tool call forever — an infinite post loop, or at best a double-post that destroys T2’s authored-by-B assertion. Today’s markers are safe only because a text turn (finish_reason "stop") ends the turn with no continuation. Therefore: a marker owns a small positional script of its own (match N serves markerScript[N], typically [CannedToolCall, CannedText]), with the terminal element serving every match past the end so a re-matching body settles instead of looping. The continuation-request contract is documented on the helper.
  • Serialized-turn note for executors (documented on the helper): two agents on one fixture share ONE positional script; a leg either (a) strictly serializes turns (settle A before triggering B — positional order deterministic) or (b) routes one agent’s turns by marker. Both patterns are legal; concurrent unmarked turns are not.

Interfaces:

// fixture.go (//go:build podman) — mints a CLIENT/observer bearer, not an agent identity
func (f *Fixture) AsObserver(ctx context.Context, handle string) (compassServiceClient, commsServiceClient, error)
// fixture.go (//go:build podman) — setup primitives T3/T4 need and go/e2e does not have yet
func (f *Fixture) CreateUser(ctx context.Context, handle, displayName string) (ownerID string, err error)
func (f *Fixture) CreateChannel(ctx context.Context, ownerID, name string, private bool) (channelID string, err error)
// comms_ops.go (//go:build podman) — observer-scoped variants threading explicit clients
func (f *Fixture) PostMessageAsObserver(ctx context.Context, comms commsServiceClient, channelID, topicName, text string) (messageID string, err error)
func (f *Fixture) SubscribeCommsAsObserver(ctx context.Context, comms commsServiceClient, sinceSeq uint64) (*connect.ServerStreamForClient[compassv1.SubscribeCommsResponse], error)
// cannedmodel.go (UNTAGGED) — the constructor + widened struct only
func newCannedMarkerScript(marker string, turns ...CannedTurn) cannedMarker
// fixture.go (//go:build podman) — the OPTION, beside the existing WithCannedMarkerReply (fixture.go:117)
func WithCannedMarkerScript(marker string, turns ...CannedTurn) fixtureOption

The tagged/untagged split is load-bearing, not cosmetic. fixtureOption is declared in fixture.go, which is //go:build podman (go/e2e/fixture.go:1), so the option constructor cannot live in the deliberately-untagged cannedmodel.go (cannedmodel.go:7-12) — the type does not exist in that build, and the record’s own load-bearing marker teeth run in exactly that untagged lane. The existing pair already gets this right and must be mirrored: the option WithCannedMarkerReply sits in fixture.go:117 (tagged) while the constructor newCannedMarker sits in cannedmodel.go:149-150 (untagged), and the untagged cannedmodel_test.go drives startCannedModelServer(bindAddr, script, markers...) (cannedmodel.go:213) directly. The unit-lane teeth do the same — they never touch a Fixture.

CreateUser/CreateChannel are new fixture wrappers, and T1 owns them. T3 needs two channels and T4 needs two owner users plus a private channel, but go/e2e has no CreateUser and no CreateChannel wrapper today (measured: zero occurrences of either in go/e2e/). Both RPCs exist and are reachable with the admin bearer (proto/compass/v1/comms.proto:38 rpc CreateUser, :60 rpc CreateChannel), so this is a thin wrapper in the style of CreateAgent (agent_ops.go:22) — not new server work. Without it T4’s first setup line is unimplementable.

Teeth: AsObserver for an unknown handle must surface NOT_FOUND (negative test). The marker-script teeth are the load-bearing unit test: drive two consecutive marker-matching POSTs against a [CannedToolCall, CannedText] marker and assert the second is the settle, not a repeat of the tool call — that is the assertion that fails on the naive one-turn implementation, and a third POST must stay settled (terminal element repeats). Plus one podman smoke — mint an observer token, read a channel it is a member of, assert a channel it is not a member of is absent — giving IssueToken its first e2e exercise. The agent-authorship assertion needs no new plumbing and is not re-proven here: legcomms_test.go:168 already pins GetAuthorAccountId() to the provisioned account through the real Runner relay binding. The visibility teeth (an observer token for agent A must NOT see owner B’s private channel) are deferred to T4’s leg.

T2 — e2e leg: two-agent conversation (legcomms_duo_test.go)

Section titled “T2 — e2e leg: two-agent conversation (legcomms_duo_test.go)”

Two provisioned, session-running container agents (A, B). A’s scripted turn issues comms_post_message into a shared channel B is a subscribed member of (SubscribeMember, comms_ops.go:51); the server steers/delivers to B (B is driven by a marker-routed turn from T1, replying via comms_post_message naming the same channel+topic — the reply cue contract, agent.ts:1005-1007). Assertions: A’s post fans out authored by A; B receives it (observe B’s control dispatch via AwaitControlDispatchOn, agent_ops.go:213); B’s reply fans out authored by B on the same topic; and the two-agent DM leg: A DMs B via comms_open_dm + comms_dm and B receives it — asserted here as delivery between two live agents, which is the part that needs two session-running containers. Scope split with T14, stated so neither executor duplicates it: T14 owns each tool’s own contract (the deterministic dm:<lo>:<hi> name, go/internal/comms/dm.go:23-29; the server-minted ask id; the list home-default) against a single agent; T2 asserts only that a DM crosses from A to B. Measures the shared-fixture package cost and records actuals; proposes the ci.yml:2184 -timeout 20m30m bump only if the measured total lands within ~5 min of 20m (OQ-1: the bump is a fallback, never the plan). Also carries the new per-leg --- PASS: guard half.

Interfaces (consumes only T1 + existing primitives):

func TestCommsTwoAgentConversation(t *testing.T) // //go:build podman

T3 — e2e leg: fan-out and isolation across channels/topics (legcomms_fanout_test.go)

Section titled “T3 — e2e leg: fan-out and isolation across channels/topics (legcomms_fanout_test.go)”

One posting container agent; N=3 non-author subscriber accounts (fixture-created agents, NOT session-running — membership and streams need no container, CreateAgent + SubscribeMember + per-account SubscribeCommsAsObserver suffice). Two channels × two topics. Assertions: a post to channel-1/topic-1 is delivered on all three member streams (fan-out to N); a member of channel-2 only does NOT receive it ahead of a canary post on channel-2 (cross-channel isolation, canary-ordered); topic minting via create_topic:true vs a name-miss without it (the R2/R5 contract, legcomms_test.go:51-55: “post has NO home default — channel is REQUIRED … a name-miss topic needs create_topic:true”); cross-topic isolation (topic-2 traffic never matches topic-1 assertions — selector-scoped, canary-ordered).

Scope correction (red-team, MEDIUM): sinceSeq replay-boundary and concurrent-post seq-ordering are NOT in this leg — they are handler/bus semantics the pgtest tier already proves through a real connect server-stream (subscribe_test.go:68-84 newStreamHarness stands up an httptest server + the generated CommsServiceClient; subscribe_test.go:189-198 already pins TestSubscribeCommsSnapshotBoundaryFirstFrame). Putting them behind podman would contradict this record’s own cheapest-tier principle and inflate the wall-clock. They move to T10. This leg keeps only what genuinely needs containers: real-stack fan-out to N members and cross-channel/cross-topic isolation.

func TestCommsFanOutAndIsolation(t *testing.T) // //go:build podman

Note fail-CLOSED context: the per-event filter’s fail-closed arm (store fault ⇒ event never sent) is untestable at e2e (no fault injection through a real stack) and stays pinned by subscribe_failclosed_test.go (subscribe_failclosed_test.go:10-13: “Store fault resolving visibility -> event NEVER sent (fail closed)”); this leg proves the fan-out positive at N members.

T4 — e2e leg: multi-tenant transport proof (legcomms_tenant_test.go)

Section titled “T4 — e2e leg: multi-tenant transport proof (legcomms_tenant_test.go)”

Zero containers — pure observer-scoped RPC actors (fast leg). Two owner users, each with one agent; a private channel under owner-1. Positive: owner-1’s member stream (via AsObserver) receives a post to the private channel. Negative: owner-2’s observer stream, subscribed at sinceSeq=0 after the private post, receives a globally-visible canary as its FIRST matching event, never the private post (the live-tail canary discipline, visibility_filter_test.go:498-503). Also: cross-owner comms_open_dm target resolves NOT_FOUND indistinguishably from unknown (transport parity with TestOpenDMCrossOwnerIsIndistinguishableNotFound, dm_open_pgtest_test.go:102). This is deliberately a transport proof — the leak matrix stays DB-tier (Approach, Alternatives §1).

func TestCommsTenantVisibilityTransport(t *testing.T) // //go:build podman

T5 — e2e leg: offline → resume redelivery of comms traffic (legcomms_redeliver_test.go)

Section titled “T5 — e2e leg: offline → resume redelivery of comms traffic (legcomms_redeliver_test.go)”

The offline-redelivery records (docs/designs/server/compass-mention-offline-redelivery.md, …-pre-settle-closure.md) have no positive e2e coverage: today the start-sweep appears in e2e only as a hazard other legs ack around (legfive_test.go:91-94: “otherwise container2’s start-sweep would redeliver it and consume the resumed lifetime’s canned turn”). This leg makes redelivery the asserted behavior: post to a provisioned-but-not-started agent’s home channel (message owed), then StartSession, and assert the start-sweep redelivers it — AwaitControlDispatchOn matching the owed message id as a deliver op (agent_ops.go:210-211: “settle.go builds a deliverOp for every owed message”), then the turn settles. One container, one lifetime — cheaper than a full stop/resume, and it exercises the same owed-sweep path Resume does.

Observation ordering is the hazard in this leg, and the executor must NOT invent the mechanism. Unlike every other leg, T5 has no post after the tail is open to establish a happens-before: the sweep is enqueued by OnSessionStarted inside StartAgentSession (delivery/settle.go:51) and drained asynchronously by the delivery loop (settle.go:124 drainStarts), while OpenSessionTail needs the sessionID that only exists after StartSession returns (agent_ops.go:100, :51). So the tail can only be opened once the sweep may already be in flight, and AwaitControlDispatchOn reads an already-open stream (agent_ops.go:213). Betting the tail wins that race is exactly the wall-clock assumption that was the RIG-3044 flake (agent_ops.go:184-187), and rule://no-retries forbids papering over it.

Required: assert the redelivery DURABLY, not on the live fan. After an event-gated settle, read the owed/cursor state via store.Open(ctx, f.DSN()) — the delivery_cursors surface (store/db/delivery_cursors.sql.go, e.g. CountOwedMentions/ClearOwedMention) records the sweep’s effect after the fact, so the assertion has no observation window to lose. This sidesteps the race entirely rather than reasoning about who wins it. If an executor believes the live-tail form is genuinely safe here, that is a design change requiring the specific happens-before argument written down (the sweeps hold a per-session dispatch gate, settle.go:197/:252/:335) — not a silent substitution.

func TestCommsOfflineRedeliveryOnSessionStart(t *testing.T) // //go:build podman

T6 — DB tier: supervisor orchestration depth (supervisor_orchestration_pgtest_test.go)

Section titled “T6 — DB tier: supervisor orchestration depth (supervisor_orchestration_pgtest_test.go)”

The sharpest thin spot: one test func today (supervisor_orchestration_pgtest_test.go:38 TestSupervisorAssignsToTwoWorkersAuditable is the file’s only func Test). Add: (a) many concurrent conversations — one supervisor, 4 workers, per-worker topics in one coordination channel; interleaved posts; assert per-topic ListMessages returns exactly that worker’s thread in order (per-agent conversation isolation at the DB tier, complementing agent_conversation_pgtest_test.go); (b) worker→supervisor upward reports land on the worker’s topic with author intact; (c) SearchMessages audit across 4 workers scoped by membership (extending the existing outsider-zero-hits case); (d) coordination-channel membership reflects a mid-scenario ReparentAgent (consumes go/internal/comms/coordination.go:197-203 ReconcileCoordinationMembership).

Interfaces: existing handler surface only — PostMessage/ListMessages/SearchMessages (comms.go:402/:380/:478) driven as a specific account via PostMessage(WithActor(ctx, id), …), plus ReparentAgent, through the newHandler/newStreamHarness helpers (subscribe_test.go:68). This is the same in-process WithActor seam the Runner relay uses in production (relay_comms.go:11-12), which is why the DB tier can assert per-account semantics without any credential.

T7 — DB tier: DM lifecycle depth (dm_open_pgtest_test.go + dm_test.go)

Section titled “T7 — DB tier: DM lifecycle depth (dm_open_pgtest_test.go + dm_test.go)”

dmChannelName (go/internal/comms/dm.go:23-29) has no direct unit test — it is exercised only through pgtest opens (callers: comms.go:693 plus the pgtest assertions on the literal dm:alice:bob, dm_open_pgtest_test.go:40). Its properties are already proven, just at a more expensive tier: the ---handle injectivity case is pinned end-to-end by TestOpenDMDoubleHyphenHandlesResolveDistinctChannels (dm_open_pgtest_test.go:198-241) and ordering at the store tier (go/internal/comms/dm.go:11-13). So the value here is moving already-proven properties to a microsecond unit test per the cheapest-tier principle, not covering uncovered behavior; the pgtest cases stay as the integration proof. Add an untagged table-driven TestDmChannelName (ordering, -- handles, : injectivity). Pgtest additions: DM posting end-to-end as each party (open, post as A, list as B); a third same-owner agent must NOT see the pair’s DM (D9 on DM channels); RevokeToken-style negative is out of scope here (token tier is store’s).

func TestDmChannelName(t *testing.T) // untagged, table-driven
func TestOpenDMPostAndReadBothParties(t *testing.T) // pgtest
func TestOpenDMThirdPartySameOwnerCannotSee(t *testing.T) // pgtest

T8 — DB tier: resolve + mapping failure modes (resolve_pgtest_test.go)

Section titled “T8 — DB tier: resolve + mapping failure modes (resolve_pgtest_test.go)”

resolve.go (140 lines) has no dedicated test file; its contracts are pinned only where other files happen to cross them. Add direct cases: batch atomicity — one bad handle fails the whole call naming EVERY unresolved handle in submitted spelling (resolve.go:28-31: “ATOMIC (OQ-2): any unresolved handle fails the whole call with store.ErrNotFound naming EVERY unresolved handle”); order preservation of resolved ids (resolve.go:31-33); owner-qualified vs bare handle namespacing (resolve.go:115-119); resolveVisibleAgentHandle’s vantage-probe closure — real-but-invisible ⇒ same NOT_FOUND as unknown (resolve.go:96-99); non-not-found store errors pass through unmangled (resolve.go:133-135).

T9 — DB tier: roster presence three-state + activity (roster_pgtest_test.go)

Section titled “T9 — DB tier: roster presence three-state + activity (roster_pgtest_test.go)”

Existing file has 12 funcs but the presence join asserts only OFFLINE-default plus one enum (roster_pgtest_test.go:186-187). Add: all four presence states (offline vs idle vs working vs waitingAGENT_PRESENCE_WAITING = 3, comms.proto:584, the ask-pending state produced at presence/presence.go:182 and the one most likely to regress silently at the roster join) distinctly asserted in one roster read via fakePresenceSource (roster_pgtest_test.go:31); nil presence source ⇒ every agent OFFLINE (the presence_source.go:16-19 re-default contract, currently untested with a nil source at handler level — roster.go:77-79 guards if c.presence != nil); caller-visibility clipping — an agent structurally in the vantage’s tree but invisible to the CALLER never appears (roster.go:19-21: “intersected with the CALLER’s account-visible set (D9) … even one structurally in the vantage’s tree”).

T10 — DB tier: comms-tier ring-lag resync (subscribe_pgtest_test.go or extend subscribe_test.go)

Section titled “T10 — DB tier: comms-tier ring-lag resync (subscribe_pgtest_test.go or extend subscribe_test.go)”

Gap found: events_test.go pins bus-level lag (events_test.go:361 TestOverrunClosesLiveAndLatchesLagged) and subscribe_test.go pins the stale-epoch resync (subscribe_test.go:481 TestSubscribeCommsStaleEpochResyncsAndRedelivers), but no test drives the live-tail overrun ⇒ terminal CommsResyncRequired path through forwardComms (subscribe.go:177-179: if sub.Lagged() { _ = stream.Send(commsResyncRequired(sub.Epoch)) } — that _ = is the handler’s own best-effort terminal send, not a test concern) and the pre-replay lag arm (subscribe.go:40-42). Drive it with a fake events.Subscription whose Live closes lagged (same driveForwardComms harness as subscribe_failclosed_test.go:151), asserting the client’s final frame is ResyncRequired with the right epoch. Also: sinceSeq boundary exactness at the handler — a cursor equal to head replays nothing; head+1-past-eviction resyncs (bus semantics events.go:5-7 proven at the comms stream edge).

Not affected by the delivery cutover (measured, do not re-raise). DL-337 (DECISIONS.md:78) says that cutover “replaces the deleted sub.Lagged() bus-ring branch”, which reads as though this branch is scheduled for deletion. It is not: sub.Lagged() has five distinct call sitesevents/events.go:89 (the method), internal/comms/subscribe.go:177 (T10’s target), internal/delivery/consumer.go:360, internal/presence/run.go:85, server/service.go:694. DL-337’s owning record is infra/runtime/compass-managed-delivery-cutover/design.md, whose scope is the delivery consumer; it cites internal/comms only at mapping.go:503-509 and comms.go:428-430/:453-472, and never mentions subscribe.go. So the comms handler’s own lag arm survives the cutover. Executors: grep the file, not the symbol.

Absorbs the two assertions moved down from T3 (red-team, MEDIUM): sinceSeq replay-boundary exactness and concurrent-post seq ordering. Both are handler/bus semantics, and this tier already drives a real connect server-streamnewStreamHarness (subscribe_test.go:68-84) stands up an httptest server plus the generated CommsServiceClient, and TestSubscribeCommsSnapshotBoundaryFirstFrame (subscribe_test.go:189-198) already pins the since_seq=0 boundary. Extend from there: a cursor equal to head replays nothing; a cursor one past eviction resyncs; concurrent posts land in a total seq order observed identically on two streams. Executor note: the pre-replay lag arm lives in SubscribeComms itself, so it needs newStreamHarness, not driveForwardComms (which drives only the forwardComms live-tail half).

Append one row to docs/designs/DECISIONS.md (drafted in Resolved decisions below) and link this record. Rides the T1 PR (first to merge). The executor mints the id at merge time by taking max(id)+1 across the ledger AND every open PR touching it — do not reuse the drafted placeholder.

T12 — Per-agent canned-script keying (conditional on T2’s measurement)

Section titled “T12 — Per-agent canned-script keying (conditional on T2’s measurement)”

Lifts the one real serialization constraint, and only if measurement says it earns its cost. The canned backend’s counter is already mutex-guarded and race-clean (cannedmodel.go:387-393, servedMu); the limit is that served is a single global positional index (“request N serves script[N]”, cannedmodel.go:92), so two agents drawing unmarked turns interleave into one script and consume each other’s turns. That is what forces T2/T5 serial.

The escape hatch already exists and is in-tree twice for exactly this reason: marker routes are checked before the positional claim and never touch the counter (cannedmodel.go:376-381; verified — the marker branch contains no served reference), which is how the root-supervisor Setup turn stays off every leg’s script (cannedmodel.go:184: “it races the test agent on the positional counter”). So per-agent parallelism needs no new concurrency primitive — it needs one counter per key instead of one global counter: the same shape as T1’s WithCannedMarkerScript, keyed per agent rather than per marker.

Unverified prerequisite, and it decides feasibility: whether each agent’s request body reliably carries a stable per-agent discriminator. Marker routing works today only because markers are hand-chosen distinctive strings; a body-substring key that collides between two agents is worse than serial execution, because it silently serves the wrong agent’s turn and the test still passes. T12’s first step is to measure that discriminator, not to design around it — if no stable per-agent string exists in the request body, T12 is closed as infeasible and T2/T5 stay serial.

Sequenced deliberately after T2 records actual wall-clock: if the shared fixture already lands at ≈4-6 min, keying buys little and is not worth the risk; if it does not, T2’s measurement is the justification. This ordering follows OQ-1’s ruling — reduce cost first, buy capability only against a measurement.

T13 — Relay-arm coverage completion, including the untested Pin arm (go/internal/runnerhub)

Section titled “T13 — Relay-arm coverage completion, including the untested Pin arm (go/internal/runnerhub)”

The cheapest task in this record and the one closing a true zero. executeCall’s nine arms each resolve the bound account and dispatch one CommsCaller method; helpers_test.go’s fakeCommsCaller already records every one (commsCall carries post, list, roster, setStatus, pin, createChannel, updateMembers, createChannelGroup, openDM). Eight arms have at least one dispatching test — the org-management three are covered by relay_org_mgmt_test.go (RIG-2673 T3). CommsCallRequest_Pin has none: 0 constructions and 0 .pin reads across every _test.go in the module (measured), so the one arm dispatching UpdatePinnedBoardAsAccount has nothing asserting its attribution.

Reachability, stated precisely because it bounds this task: 4 of the 9 arms have no native agent tool todaypin, create_channel, update_members, create_channel_group (the in-flight org-management arms, proto/compass/v1/agent_gateway.proto:128-129: “Field 10 leaves 7-9 for the in-flight org-management oneof arms (RIG-2673)”). The agent tool set is 7: comms_post_message, comms_dm, comms_post_ask, comms_list_messages, comms_open_dm, compass_roster, compass_set_status (packages/compass-agent/src/comms.ts), covering only the post/list/open_dm/roster/set_status arms. So the Pin gap is not an exploitable hole from a container today — it is an untested arm on a wire an agent cannot yet reach, and this task closes it before a tool lands rather than after.

Add one dispatch+attribution case per arm that lacks one — at minimum Pin, and a table-driven sweep asserting every arm in the oneof resolves to the bound account rather than the bootstrap admin. Prefer the table form over nine hand-written cases: it makes a newly added arm fail loudly until it is listed, which is the property that would have caught Pin.

Also absorbs T14’s structural negative (red-team, LOW): assert the oneof’s arm set is exactly the nine known arms and that none is RespondToAsk — the “NEVER AN ASK-ANSWERING TOOL … the request oneof cannot express RespondToAsk prohibition (comms.ts). It is an assertion about the oneof, so it belongs in this untagged unit lane, not behind podman.

No podman, no Postgres: these are in-package unit tests against the existing fake. Independent of T1-T5 — it can land first.

func TestRelayCommsPinDispatchesAsBoundAccount(t *testing.T)
func TestRelayCommsEveryArmAttributesToBoundAccount(t *testing.T) // table over the oneof
func TestCommsCallRequestHasNoAskAnsweringArm(t *testing.T) // structural negative, moved from T14

T14 — e2e: the six unjoined agent tools through the real relay (legcomms_tools_test.go)

Section titled “T14 — e2e: the six unjoined agent tools through the real relay (legcomms_tools_test.go)”

The record’s highest-value leg, because it is the only one that proves the two halves of the relay agree. comms_post_message is the sole tool ever executed against a real server (Problem). The others are proven only against a faked transport in TypeScript and a faked caller in Go — neither of which would catch a wire-shape disagreement between them. Ordering note: T2 lands first and its DM crossing already executes comms_open_dm+comms_dm, so this leg is not those two tools’ first execution — it is their first per-tool contract assertion (T2 proves only that a DM crosses A→B).

One session-running container agent, scripted to issue each tool in turn (marker-routed per T1 so the turns are deterministic), asserting the server-side effect of each rather than the tool’s return string alone:

  • comms_open_dm → a kind=DM channel exists with the deterministic dm:<lo>:<hi> name (go/internal/comms/dm.go:23-29), read back via store.Open(ctx, f.DSN()).
  • comms_dm → the post lands in that DM channel authored by the agent’s bound account.
  • comms_list_messagestwo calls, because one cannot satisfy both properties. (i) naming the DM channel returns the agent’s own comms_dm post; (ii) omitting channel resolves to the agent’s home channel (this is the one tool keeping that default — comms.ts: comms_list_messages is EXEMPT and keeps omit-=home”) and returns the home-channel trigger post, not the DM. Asserting “the agent’s own prior post” on an omitted-channel call would be wrong: the prior post is in the DM, the omitted call reads home.
  • comms_post_ask → posts an ask and returns a server-minted ask id, with answered false.
  • compass_roster and compass_set_statusthe two tools a comms_* prefix search hides, equally unjoined (zero go/e2e occurrences) and driving the Roster/SetStatus arms, which are likewise never joined. Two extra marker-routed turns on the container this leg already pays for: compass_roster returns the roster, and compass_set_status lands its activity string where a store read can see it. Cheap here, and it makes the ledger row’s “every native tool has one real-server execution” true rather than nearly true.

The structural negative belongs to T13, not here. The prohibition that an agent may raise an ask but never answer one“NEVER AN ASK-ANSWERING TOOL … the request oneof cannot express RespondToAsk (comms.ts) — is asserted over the oneof itself and needs neither a container nor a fixture, so putting it behind podman would break this record’s own cheapest-tier rule. It rides T13’s oneof sweep (one extra table property: the arm set is exactly the nine known arms and none is RespondToAsk).

Depends on T1 (marker-routed tool-call turns). Shares the T2 fixture.

func TestCommsNativeToolsThroughRelay(t *testing.T) // //go:build podman

T15 — DM→channel conversion, at the handler and e2e tiers (legcomms_tools_test.go + dm_open_pgtest_test.go)

Section titled “T15 — DM→channel conversion, at the handler and e2e tiers (legcomms_tools_test.go + dm_open_pgtest_test.go)”

Conversion is UpdateChannelMembers.convert_channel_name (comms.proto:686-693) and today has exactly two tests, both store-tier (go/internal/store/dm_pgtest_test.go: TestConvertOnAddRequiresNameAndConverts, TestOpenDMAfterConvertMintsFreshPair); ConvertChannelName has zero hits in any _test.go under go/internal/comms, go/server, and go/e2e; the only non-store reference is the handler pass-through (go/internal/comms/comms.go:269), so above the store this field is plumbed but unasserted (measured). Two distinct gaps follow, and they belong at different tiers:

  • Handler tier (pgtest, cheap): the conversion’s semantics above the store — a bare third-party add on a kind=DM channel is INVALID_ARGUMENT (comms.proto:687-689), a convert sets kind=CHANNEL, renames, and detaches from the reserved DM group, and convert_channel_name is a no-op on a non-DM channel (comms.proto:692-693). Add these to dm_open_pgtest_test.go alongside the existing DM-open cases.
  • e2e tier (one case, and NOT agent-driven — measured): the draft said an agent converts its own DM. It cannot: no native tool exposes UpdateMembers. The tool set is the 7 above, and update_members is one of the four arms still tool-less (agent_gateway.proto:128-129, RIG-2673 in flight); packages/compass-agent/src/comms.ts contains zero occurrences of convert, updateMembers, or update_members (measured). So the e2e case drives the convert over the admin CommsService client (UpdateChannelMembers with convert_channel_name) against a DM an agent opened via comms_open_dm, then has that agent issue a fresh comms_open_dm to the same peer and asserts it gets a NEW channel id. That still joins the property worth joining — the name-freeing survives the agent’s own resolution path — without inventing a tool. When an org-management tool lands (RIG-2673), the agent-driven form becomes possible and is the natural follow-up; it is out of scope here.

Depends on T1 + T14’s fixture. The e2e half rides T14’s leg rather than paying its own container.

T16 — e2e: the ask round-trip closes (legcomms_ask_test.go)

Section titled “T16 — e2e: the ask round-trip closes (legcomms_ask_test.go)”

RespondToAsk has 34 references in go/internal/comms and 0 in go/e2e (measured), and comms_post_ask is one of the six unjoined tools, so the raise→answer→observe loop is proven only in pieces. There is already a design record for the round-trip (docs/designs/agent/compass-ask-comms-roundtrip/design.md), which makes the missing e2e the gap rather than the semantics.

The loop is asymmetric by design and the leg must respect it: the agent raises via comms_post_ask, the human/operator answers via CommsService.RespondToAsk (admin bearer — the agent cannot answer, T14), and the answer reaches the agent on a later turn as an async channel message, never as a session dialog (“An ask is an ASYNC channel message, never a session dialog — there is no promptable session”, comms.ts). Assertions: the ask posts with a server-minted id and answered false; RespondToAsk as the operator flips it answered with the chosen option ids echoed; the agent’s next turn observes the answer (event-gated via AwaitControlDispatchOn, agent_ops.go:213).

Ordering discipline (same class as T5): the answer is delivered on a later turn, so do not bet on a live tail catching it — assert the answered state durably from the store, and gate the agent-side observation on a settle rather than on wall-clock.

Depends on T1. Shares the T2 fixture.

func TestCommsAskRoundTripThroughAgentLoop(t *testing.T) // //go:build podman
  • T1 — Fixture plumbing: AsObserver observer-scoped clients (no longer gated — OQ-2 resolved; this mints a CLIENT bearer via IssueToken, NOT an agent identity), new CreateUser/CreateChannel fixture wrappers T3/T4 require and go/e2e lacks, WithCannedMarkerScript sequence-based tool-call marker routing (a one-turn marker does not terminate — see T1; the option goes in podman-tagged fixture.go, the constructor in untagged cannedmodel.go, because fixtureOption is podman-only), PostMessageAsObserver/SubscribeCommsAsObserver; unit-lane marker tests driving startCannedModelServer directly (two consecutive matches ⇒ second is the settle) + podman smoke.
  • T2 — legcomms_duo_test.go: two session-running agents converse (channel post → deliver → reply, plus a two-agent DM crossing A→B; each tool’s own contract belongs to T14, not here); carries the new source-derived per-leg --- PASS: guard half and measures the shared-fixture package cost (the -timeout bump is a fallback only, proposed if the measurement lands within ~5 min of 20m). Depends on T1.
  • T3 — legcomms_fanout_test.go: N-member fan-out, cross-channel + cross-topic isolation (canary-ordered negatives), create_topic minting vs name-miss. Depends on T1. (sinceSeq replay boundary + concurrent-post seq ordering moved to T10.)
  • T4 — legcomms_tenant_test.go: multi-tenant transport proof with no leg-owned containers (the fixture’s seeded supervisor still runs): observer-scoped streams; positive member delivery, canary-ordered cross-owner negative, cross-owner DM NOT_FOUND parity. Depends on T1.
  • T5 — legcomms_redeliver_test.go: offline → session-start owed-message redelivery asserted positively, durably via the delivery_cursors store surface, NOT on the live tail (the sweep is in flight before a tail can be opened — see T5’s ordering note; the live-tail form was the RIG-3044 flake). Depends on T1.
  • T6 — Supervisor orchestration depth: 4-worker per-topic conversations, upward reports, membership-scoped search audit, reparent membership resync. Parallel-safe now.
  • T7 — DM lifecycle depth: untagged TestDmChannelName table test; both-parties post/read; same-owner third-party D9 negative. Parallel-safe now.
  • T8 — resolve_pgtest_test.go: batch atomicity + full unresolved naming, order preservation, owner namespacing, vantage-probe closure, error passthrough. Parallel-safe now.
  • T9 — Roster: three-state presence assertion, nil-source OFFLINE default, caller-visibility clipping. Parallel-safe now.
  • T10 — Live-tail overrun ⇒ terminal CommsResyncRequired through forwardComms; pre-replay lag arm; plus sinceSeq replay-boundary exactness and concurrent-post seq ordering moved down from T3. Parallel-safe now.
  • T11 — Ledger row (id minted at merge, ≥342) + record link, riding the T1 PR.
  • T12 — Conditional on T2’s measurement: per-agent canned-script keying to lift the T2/T5 serialization. First step is measuring whether a stable per-agent discriminator exists in the request body — if not, close as infeasible. Depends on T2’s recorded wall-clock.
  • T13 — Relay-arm coverage completion: a dispatch+attribution test for the Pin arm (currently zero tests at any tier), a table-driven sweep asserting every CommsCallRequest oneof arm attributes to the bound account (so a newly added arm fails until listed), plus the structural negative that no arm is RespondToAsk (moved from T14 — it needs no container). No podman, no Postgres — in-package against the existing fakeCommsCaller. Independent of T1-T5; can land first.
  • T14 — legcomms_tools_test.go: the six unjoined native toolscomms_open_dm, comms_dm, comms_list_messages (two calls: named-DM and omitted-channel/home), comms_post_ask, compass_roster, compass_set_status — executed against a real server and asserted by server-side effect. First per-tool contract assertions (T2 already crosses a DM). Depends on T1; shares the T2 fixture.
  • T15 — DM→channel conversion: handler-tier semantics in dm_open_pgtest_test.go (bare third-party add on a DM is INVALID_ARGUMENT; convert sets kind=CHANNEL, renames, detaches; no-op on a non-DM channel) plus one e2e case where the convert is driven over the admin client (no native tool exposes UpdateMembers — measured) against an agent-opened DM, and the agent’s next comms_open_dm to the same peer must mint a new channel id. Depends on T1; e2e half rides T14’s leg.
  • T16 — legcomms_ask_test.go: the ask round-trip closes end-to-end — agent raises via comms_post_ask, operator answers via RespondToAsk (admin), agent observes the answer on a later turn; answered state asserted durably from the store, not on a live tail. Depends on T1; shares the T2 fixture.
  • OQ-1 — RESOLVED BY MATT: do not buy time, stop spending it. He rejected the -timeout bump as the first move and asked why the legs cost so much, whether they can run in parallel, and whether they can reuse infra instead of each spinning up its own. Both instincts check out in-tree, and the draft was wrong: the cost was four stack Ups + four seed containers of duplicated setup, and the suite has zero t.Parallel() anywhere in go/e2e. Revised plan is in Approach §Leg topology — one shared fixture, legs as top-level funcs isolating on their own accounts/channels/topics, T3+T4 parallel, T2/T5 serial (the shared canned backend’s single positional counter is the real serialization constraint, cannedmodel.go:92). Estimated ≈4-6 min added instead of ≈10-14 for T2-T5; the later-added T14/T16/T15-e2e push that to ≈6-9 min (two more containers, ~10 more turns, unmeasured — see Approach §Leg topology). The -timeout bump is now the fallback, not the plan, and T2’s executor measures the shared shape first.

  • OQ-2 — RESOLVED (Matt, on measurement): the question was malformed, and neither drafted option was right. Matt’s ruling was “runner-issued credentials, the production agent path”; measuring that path showed the production path issues no account credential to an agent at all, by design, so there was nothing to choose between. Two roles had been conflated:

    1. Agent authorship — no credential, and the coverage already exists. runnerhub/relay_comms.go:7-15 is the ratified trust model (its own header: “OQ-2, ratified — the load-bearing security leg”): the Runner is a pure forwarder asserting no account; it sends RelayCommsCall{session_id, call} and the server resolves session_id → account from its own binding (bindContainer at Provision → promoteSession at Start) and executes under it via CommsCaller/comms.WithActor. “A session_id on the wire selects an account, it never carries one”, failing closed CodeNotFound and “never the bootstrap-admin fallback”. Matt’s follow-up — “can we not test using the Runner?” — was correct: the real compass-runner binary is already in the e2e stack (go/e2e/main_test.go:72), and legcomms_test.go:168 already asserts GetAuthorAccountId() equals the provisioned account through it. No mock and no credential plumbing is needed for T2/T3/T5.
    2. Observer reads — a CLIENT credential, which is the only real gap. Asserting what an account cannot see needs a non-admin reader, since every fixture RPC rides the admin bearer (clients.go:63, comms_ops.go:72-73). IssueToken is contractually exactly that — “Sole public-contract path to mint a non-bootstrap account’s token” (compass.proto:118-121). Named AsObserver so no executor mistakes it for an agent identity.

    Why the original framing was wrong, recorded so it is not repeated: the draft read a deliberate absence (agents hold no account bearer) as a missing feature, then asked which of two ways to build it. Both options would have tested a seam production does not use — (i) minting a client token to stand in for an agent, or (ii) building per-account agent credentials the ratified model explicitly rejects. T1 is therefore no longer gated; only its observer half remains, and it shrank.

  • OQ-3 (non-load-bearing, deferred): rename the 5 pgtest-tagged files not named *_pgtest_test.go. Deferred: pure churn against blame history with zero behavior or guard impact (the CI guard keys on pgtest.RequireDSN callers, ci.yml:608, not filenames). New files follow the naming rule; existing ones stay.

  • OQ-4 (non-load-bearing, deferred): a stress-shaped “many agents” leg (e.g. 10+ concurrent container agents). Deferred: container cost makes it a CI-budget hazard, and the fan-out/N-member and 4-worker-orchestration coverage (T3, T6) exercises the same code paths at bounded cost. Revisit if a production incident implicates scale-dependent behavior (e.g. bus overrun under real load — T10 covers the overrun contract deterministically instead).

  • Ledger: mints one row. This design sets a durable tier policy, not just test count: multi-actor comms behavior is asserted at the e2e tier through the real Runner session→account binding, while visibility/leak semantics stay DB-tier with e2e proving only the transport path. That is exactly the “durable policy” bar the ledger requires.

    Id is minted at merge time, not here. The next free id is ≥342: tree max is DL-337 and open PRs claim 338-341. Draft row (Ledger-impact: one new row, DL-NNN replaced on merge):

    ID Decision Status Record
    DL-NNN Multi-actor comms coverage is tiered: cross-agent conversation, fan-out/isolation, and offline redelivery are asserted at the podman e2e tier with agent authorship carried by the real Runner session→account binding (no per-agent credential — relay_comms.go:7-15), and a non-admin observer client proves the credentialed transport path; the D9 visibility/leak matrix stays at the pgtest tier with e2e proving only one transport positive+negative. every agent-reachable relay arm is covered by a unit test at the runnerhub tier (podman is spent on the tool→relay→server JOIN, never on arm dispatch), and each native agent tool has at least one real-server execution. No new build tag or CI tier — new legs ride the existing e2e skip-guard Active (Matt, 2026-09-07) multi-actor comms coverage
  • Leg topology: one shared fixture, legs as top-level test funcs (Approach §Leg topology, revised on Matt’s OQ-1 ruling): the rejected four-fixture model meant four stack Ups plus four seed containers of duplicated setup. Per-leg isolation is by account/channel/topic instead, at the cost of some failure isolation. The legs stay top-level func Test… sharing a package-scoped fixture rather than subtests of one parent, because the new --- PASS: guard half enumerates func declarations (Global Constraints).

  • Sibling files, not extending legcomms_test.go: the existing leg stays the minimal referenced “native tool through the loop” proof.

  • Multi-tenant leak matrix stays DB-tier (Alternatives §1): e2e asserts transport wiring only.