M2-07: Agent consumers over the gateway and REST¶
Status: ready | Depends on: M1-65 | Milestone: M2
Goal¶
After this ticket an agent adapter reads the event log as a named consumer with a durable cursor, over the gateway WebSocket and over REST, exactly as 04 "Agent adapters" ("Connection and cursor", "Ack and redelivery") and 09 "Connection and cursor" require. Each pair of agent and consumer name owns one row in agent_consumer_cursors. The first connection of a consumer starts at head_seq, every later one at acked_seq + 1; at most agent.max_inflight_events unacknowledged events are on the way, then the server waits; an event that is not acknowledged within agent.ack_timeout comes again with the delivery field redelivered: true; acked_seq is saved at the latest after agent.ack_persist_interval and at shutdown. The newest consumer wins (R-SY-09): a second identify with the same consumer takes it over and closes the older connection with 4015, and the fenced save of 03 keeps that rule true when the takeover happens in another server instance. The REST operations getConsumerState, pollAgentEvents and ackAgentEvents give adapters without a WebSocket the same stream with the same rules. Without this an adapter either misses events or works twice, and the invocation tokens of M2-08 have no reliable stream to travel on.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope: "fencing across several nodes (M7)", Exit criteria items 1 and 10, Test strategy: "restart of the server with open acks", Red team, Risks), block "agents and extensions" with the rows M2-06, M2-07, M2-08, M2-09, M2-17, M2-20, M2-26, M2-29, M2-31, M2-36; section 7 rows M1-51, M1-63, M1-64, M1-65, M1-68; section 13 rows M7-04, M7-08, M7-17.- Implied dependencies (in the closure of M1-65, therefore not in the header): M1-16 (event log, envelope,
DeliveryFields), M1-19 (rate limits), M1-20 (idempotency), M1-25 (action registry), M1-28 (authorize), M1-29 (authz.Admin,access.changed), M1-44 (API tokens), M1-45 (revocation), M1-51 (agents), M1-56 (messages), M1-63 (catch-up), M1-64 (gateway connection). docs/spec/04-sync-protocol.md: 15 "Agent adapters" with 15.1 "Connection and cursor" (points 1 to 5), 15.2 "Ack and redelivery" (points 1 to 4), 15.3 "Invocation and run start" step 2 (the delivery check that M2-08 hooks in), 15.6 "Webhook as transport" (out of scope); 2.2 "Envelope" (delivery fieldsredelivered,invocation_token,trace_context); 2.3 "Frames on the WebSocket"; 6.2 "Ops" (ack,errorkeeps the connection); 6.3 "Lifecycle" (identifyfor agents withconsumer, defaultdefault); 6.4 "Close codes" (4002,4015); 7.1 "Cursor"; 7.2 "Flow"; 7.3 "resync_required"; 7.4 "REST variant"; 7.5 "Sync epoch" point 4; 8.1 "Basic rule"; 9 "Delivery guarantee"; 10 "Ordering"; 17 "Decisions of this chapter" (R-SY-02, R-SY-03, R-SY-09, R-SY-17, R-SY-23); 18 "Configuration values of this chapter" (agent.ack_timeout,agent.ack_persist_interval,agent.max_inflight_events).docs/spec/03-data-model.md: tableagent_consumer_cursors(all columns, PK, FK, the fenced save below the table); tableagents; 3 "Retention classes" (security); 6.3 "Lock order"; 6.6 "Notifying the gateway processes"; 6.7 "Sync epoch and restore".docs/spec/09-agents.md: 2.1 "Records" (row "Read position"); 4.2 "Transports"; 4.3 "Connection and cursor" (acceptance beforeack, duplicate detection); 4.4 "Events to the adapter"; 4.5 "Invocation token" paragraph "Check in the adapter" (an event without accepted token is not acknowledged and comes again afteragent.ack_timeout) and "Keys and rotation" point 3; 15 "Reliability".docs/spec/05-actions.md: rules 1 to 4 of 1 "Binding rules"; 2.1 "Column "Permission"" (agent-self); 2.7 "Effect (effect)"; 3 "Rate-limit classes" (rl.agent_runtime); rowagent.events.consumein 4.10 "Agents, commands, runs, interactions"; 6 "Durable event types and receive action"; 7 "WebSocket ops and action" (rowack); R-AZ-01, R-AZ-04.docs/spec/02-architecture.md: 4.1 "Rules" (rules 1, 4, 6); 4.3 "Ports, events and sources per module" (rowsagents:Consumers;gateway); 4.4 "Allowed dependencies" (agentsmay use onlyidentity,authz,chat;gatewaymay useagentsandsync); 4.6 "Actions per module" (agent.events.consumebelongs toagents); 4.7 "Event types and WebSocket ops per module" (ackbelongs toagents); 5 "Layers within a module" (rule 3); 7.3 "Other checks in the gate" (row "Routes and ops": the handler lies in the module that owns the action); 10 "Gateway"; 11.2 "Log tail per api process"; 11.3 "Permission cache"; 11.4 "Hub and delivery"; 11.7 "Cost of serialization"; 14.4 "Failure of a dependency"; 15.3 "Shutdown" (roleapi, step 3); 16 "Scaling path" (bullet "Agent consumers").docs/spec/06-api.md: 2 "Basic rules" (rows "Self reference", "No effect", "Unknown values"); 5 "Errors" (points 3, 4, 6, 7); 6 "Pagination" (points 3 and 5); 7 "Idempotency" (generated table:ackAgentEventsis exempt, "agent runtime: monotonic or compare-and-swap"); 9 "Rate limits"; 11 "Real-time gateway" (points 4 and 5); 16 "Endpoints" (rowsgetConsumerState,pollAgentEvents,ackAgentEvents); 17 "WebSocket ops and events" (rowack).docs/spec/07-permissions.md: 8.1 "Delivery filter" (row "Agent consumers"); 11.2 "Required tests" (AZ-P1, AZ-P2, AZ-P9, AZ-P13).docs/spec/20-privacy.md: 2 "Data categories per table" (rowagent_consumer_cursors: not personal, classsecurity, deleted with the agent).docs/api/openapi.yaml: operationsgetConsumerState,pollAgentEvents,ackAgentEvents,listEvents(for comparison); parameterConsumerPath; schemasConsumerState,ConsumerAck,EventPage,EventEnvelope(memberredelivered),GatewayIdentify(memberconsumer),GatewayResume,GatewayAck,GatewayCursor,GatewayCatchupComplete,GatewayResyncRequired,GatewayError; responseGone;x-problem-codesofProblem(validation_failed,forbidden,not_found,resync_required,rate_limited).docs/api/asyncapi.yaml: channelgateway(description: one connection per agent consumer,4015), operationreceive.ack, messageop.ack,x-close-codesentry4015 consumer_replaced.- ADRs: 0007 (migrations), 0008 (hexagonal modules), 0013 (spec first), 0017 (one authorization function, 404 before 403), 0022 (agents outside the server process), 0050 (module register), 0052 (one event envelope, delivery fields), 0053 (no JSON null), 0059 (sync epoch), 0065 (maintenance mode, point 4:
ackstays allowed for agents with a running run; the holder column). docs/DECISIONS.md: D-K-A3 (fencing column), D-W-100; section 10 for the format of new rows.docs/security/threat-model.md: 5.5 "Gateway (WebSocket)", 5.10 "Agent adapters and runtime"; register rows T-TK-02 (stolen agent token), T-AZ-03 (delivery after revocation), T-DS-01 (slow consumer).docs/RISKS.md: RK-37 (several api nodes).docs/OPEN-QUESTIONS.md: OQ-07 (default: multi-node operation in M7, "The code stays node-neutral from M1 on").docs/observability/signals.md4.3 rowbothy.agent.consumer.laganddocs/observability/alerting.mdrowBothyAgentConsumerLag: read to know they exist; this ticket does not build them (see Out of scope).docs/operations/configuration.md: rowsagent.ack_timeout,agent.ack_persist_interval,agent.max_inflight_events,sync.catchup_page_size,sync.cursor_advance_every,sync.resume_window,database.listen_reconnect_backoff_cap,cluster.mode.- M1 briefs this ticket builds on: M1-16 (
sync.Event,sync.Envelope,sync.DeliveryFields{IncludeTraceContext, InvocationToken, Redelivered},sync.Tail), M1-25 (authorize.Registry, moduleRegisterfunctions), M1-28 (AgentSelf, visibility of kindagent,authorize.Filter), M1-29 (authz.Admin.CreateGrant,RevokeGrant), M1-44 (agent API tokens withdpop_jkt), M1-45 (agent.disableand token revocation close with4012), M1-51 (agents portAgents,internal/agents/adapters/httpapi), M1-63 (CatchUp.Check,CatchUp.Page, envelope encoding,trace_contextfor agents), M1-64 (session store,Delivery{Register, Attach, Detach}, op dispatcher,gateway_opsinapi/operations.yaml, close code constants,wstestclient), M1-65 (hub, hub position,authorize.Delivery(ctx),Observe, catch-up runner, queue purge, shutdown step with4013, two server instances in one test binary). - Merged code on branch
main(read withgit show main:<path>, no checkout):services/server/architecture/modules.yaml(moduleagents: tableagent_consumer_cursors, actionagent.events.consume,ws_ops: [ack]; modulegateway:depends_onwithagentsandsync),services/server/internal/agents/README.md,services/server/internal/gateway/README.md(shutdown: save agent cursors first),services/server/internal/wire/configkeys_messaging.goandconfigchapters.go(pattern for registering configuration keys). - Code:
internal/agents(M1-51),internal/gateway(M1-64, M1-65),internal/sync(M1-16, M1-63, M1-65),internal/authz(M1-28, M1-29),internal/kernel/authorize,internal/kernel/actor,internal/kernel/clock,internal/platform/pg/pgtest,internal/identity/adapters/identitytest,internal/wire,services/server/api/operations.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- One durable cursor per agent and consumer name in
agent_consumer_cursors, exactly as 03 defines the table (columns, PK(agent_id, consumer_name), FK toagents(principal_id)withON DELETE CASCADE, nullableholder_session_id, retention classsecurity); only the moduleagentsreads and writes it (03 table, 02 4.1 rule 1, 20 rowagent_consumer_cursors). - The first connection of a consumer sets the cursor to the current
head_seq; an adapter never receives events from before its first connection (04 15.1 point 2; 09 4.3). - Delivery runs from
acked_seq + 1inseqorder, filtered like people by the agent's own grants and the operator rule, plus personal events with the agent as audience andagent.updatedabout itself; every delivery, also a redelivery, is admitted with the permissions at delivery time (04 15.1 point 4, 8.1, R-SY-02; 07 8.1 row "Agent consumers"). ack {seq}confirms every number up toseq; the gateway op andackAgentEventsboth use actionagent.events.consume(04 15.2 point 1, 6.2; 05 7 rowack; 00 "Ack").- At most
agent.max_inflight_eventsunacknowledged events are on the way; then the server waits (04 15.2 point 2). - Without an acknowledgement within
agent.ack_timeoutthe server delivers again fromacked_seq + 1with the delivery fieldredelivered: true; delivery fields are set per delivery and never stored in the log (04 15.2 point 3, 2.2, R-SY-17, ADR 0052). acked_seqis stored at the latest afteragent.ack_persist_interval(04 15.2 point 4) and immediately in step 3 of the shutdown of roleapi, before all connections close with4013(02 15.3).- One connection per consumer, the newest wins: a new
identifywrites its gateway session intoholder_session_idand closes the older connection with4015;acked_seqis saved only withWHERE holder_session_id = <own session>, and a save that finds no row closes the connection with4015(04 15.1 point 3; 03 tableagent_consumer_cursors; R-SY-09, R-SY-23; D-K-A3). agent.events.consumeisagent-self, classrl.agent_runtime, Auditno, Permissionsno; denials of requests and ops are audited byauthz, delivery filters write no audit entry (05 4.10, R-AZ-04; 07 AZ-P13). The action and the op belong toagents, so their handlers live there (02 4.6, 4.7, 7.3).- REST:
getConsumerStatereads the cursor,pollAgentEventsdelivers fromacked_seq + 1with at mostagent.max_inflight_eventsper call and redelivery afteragent.ack_timeout,ackAgentEventsis idempotency-exempt and monotone (openapi descriptions; 06 7 "Idempotency"; 09 4.2 "Transports"; 04 7.4). - No JSON
nullin any frame or body (ADR 0053); every frame and body conforms to its schema indocs/api/openapi.yaml(06 15, G1 to G4).
Scope¶
All paths under services/server/ unless noted.
Create:
migrations/<next free five-digit number>_agent_consumer_cursors.sql: tableagent_consumer_cursorsexactly as 03 (all six columns with types, nullability and defaults, PK, FK with cascade); a comment names the retention classsecurity. Down migration drops the table.internal/agents/consumers.go(port additions, doc comment on each symbol):type ConsumerKey struct { AgentID ids.ID; Consumer string },type ConsumerState struct { Consumer string; AckedSeq int64; Created bool }, sentinelErrConsumerReplaced(anerrsvalue of kindconflict).type Consumers interface, implemented byagents/app:Take(ctx, consumer string, holder ids.ID, head int64) (ConsumerState, error)(authorize, create atheadwhen absent, writeholder);AcceptAck(ctx, consumer string, seq, head int64) error(authorize and validate, no write);Save(ctx, key ConsumerKey, holder ids.ID, acked int64) error(fenced, monotone);Restart(ctx, key ConsumerKey, holder ids.ID, seq int64) error(fenced, sets the value, used only for resync). The actor comes from the context forTakeandAcceptAck;SaveandRestarttake the key because the gateway's save ticker has no request.- Declared source port
type ConsumerFeed interface(02 4.1 rule 4:agentsdeclares,gatewayimplements):Poll(ctx, consumer string, limit int) (FeedPage, error),Acked(ctx, key ConsumerKey, acked int64),Head(ctx) (head int64, epoch ids.ID, err error);type FeedPage struct { Events []json.RawMessage; NextCursor, HeadSeq int64; SyncEpoch ids.ID; Resync string }(Resyncempty,retentionorcursor_ahead). - In
Register(r *authorize.Registry)of the port (add the function if M1-51 did not): register the opackfor moduleagents(M1-25).
internal/agents/domain/consumer.go:ValidConsumerName(string) bool(pattern ofConsumerPath),DefaultConsumer = "default",CheckAck(seq, acked, head int64) (advance bool, err error)(Design note 8).internal/agents/app/consumers.go: theConsumersuse cases and the three REST use casesConsumerState,PollEvents,AckEvents(Design notes 9 and 10).internal/agents/adapters/postgres/queries/consumer_cursors.sqlwith the sqlc queriesTakeConsumer(insert atheador setholder_session_id, returningacked_seqand whether the row was created),SaveConsumerAckandRestartConsumer(both withholder_session_id = $holder),AckConsumer(monotone, no holder condition, returningacked_seq),GetConsumer; the repository methods.internal/agents/adapters/httpapi/consumers.go: the strict-server methodsGetConsumerState,PollAgentEvents,AckAgentEvents.internal/gateway/domain/consumerwindow.go: the pure delivery window of Design note 5 (no I/O, time passed in).internal/gateway/app/consumers.go: the consumer engine: a runner per consumer connection, the per-process holder index, REST windows, the save ticker, flush on takeover, detach, close and shutdown;internal/gateway/app/ack.go: the handler of opack; outgoing portDeliveryHook(Design note 7).internal/gateway/adapters/consumerfeed/: implementsagents.ConsumerFeedon the engine.internal/gateway/adapters/passdelivery/: theDeliveryHookbinding until M2-08; it returns every envelope unchanged; its doc comment names M2-08 as the ticket that bindsruns.Delivery.internal/wire/configkeys_agents.go: the keysagent.ack_timeout(duration),agent.ack_persist_interval(duration) andagent.max_inflight_events(int, minimum 1) with chapter04-sync-protocol.md, followingconfigkeys_messaging.go; reuse the chapter constant if an earlier ticket added it.
Change:
internal/gateway(M1-64, M1-65):identifyandresumeof agent principals (Design notes 3 and 4);Delivery.Attachstarts the consumer runner for agent sessions instead of the catch-up runner; the hub keeps agent sessions out of durable dispatch and wakes their runners instead (Design note 6); queue purge also releases the purged sequence numbers from the consumer window; the shutdown step of M1-65 flushes all consumer cursors beforeCloseAll(4013).internal/syncport, only where the merged code lacks it: a page variant that returnssync.Envelopevalues (orsync.Eventrows) instead of encoded JSON, so the gateway setsDeliveryFields.Redeliveredper delivery without parsing JSON; an accessor for the hub position if M1-65 has none (Design note 6).internal/wire: bindagents.Consumersinto the gateway engine andconsumerfeedinto the agents use cases (they depend on each other at run time: use a late setter or a small forwarding type inwire); register theackop handler with the dispatcher; bindpassdelivery; register the save ticker and the REST window sweeper with the lifecycle (M1-11).api/operations.yaml: movegetConsumerState,pollAgentEvents,ackAgentEventsfromlatertoenabled(sorted) andackfromgateway_ops.latertogateway_ops.enabled; rerun the route generator (make codegen-checkshows no diff afterwards).docs/api/openapi.yaml(spec first):pattern: ^[a-z0-9][a-z0-9_-]*$onGatewayIdentify.consumer(the pattern ofConsumerPath);minimum: 0onConsumerAck.seqandGatewayAck.seq; onpollAgentEventsthe response'410': $ref: '#/components/responses/Gone'and a description of the rules of Design note 10 (takeover, first poll athead_seq, window,410 resync_required); descriptions ofgetConsumerState(404for a consumer that never connected) andackAgentEvents(monotone,400abovehead_seq,404unknown). Thenmake api-lint,make api-tables,make api-checks.docs/spec/09-agents.md4.3 "Connection and cursor": a paragraph "REST fetch" with the rules of Design notes 8 to 10 and the consumer name pattern; 4.2 row "REST fetch" points to it. English, like the chapter.docs/spec/02-architecture.md: 4.3 rowagents"declaresConsumerFeed", rowgateway"Implements sources:agents.ConsumerFeed"; 10 "Gateway" and 11.4 "Hub and delivery": one sentence each that agent consumer connections read the log through their own runner up to the hub position and are woken by the hub.docs/operations/configuration.md: regenerate section 4 withmake config-reference;make config-doc-checkgreen.- READMEs of
agentsandgateway(English, like the existing files);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-07-1and following (Design note 15).
No new top-level Makefile target.
Out of scope¶
- Fresh invocation tokens,
agent.invoke, I2, SR2 and SR4 before each delivery ofagent.invocation,agent.followup,interaction.created,command.invoked, and bindingruns.DeliveryintoDeliveryHook(M2-08); signing the tokens (M2-06). - Heartbeat over the gateway (
heartbeat.agent) andsendHeartbeat, presence,agent.presence_changed(M2-20). - The ephemeral
command.autocomplete_requestedto an active consumer connection (M2-17);run.updateandcommand.autocomplete_resultops (M2-10, M2-17). - Webhook as transport for agents (04 15.6,
webhook_subscriptions.agent_id): no owner in the plan yet; reported as a plan gap, do not build it here. - The fast cross-node signal of a takeover over
NOTIFY ephemeral,cluster.mode = multiand the acceptance test with two api nodes (M7-17). The fenced save itself is part of the table's contract in 03 and is built here. - Resetting agent cursors in
bothy restore finalize(M7-04); the maintenance-mode exception forack(M7-08, ADR 0065). - The metric
bothy.agent.consumer.lagand the alertBothyAgentConsumerLag: no ticket of the plan owns them yet, and the metric infrastructure of M1-68 is not in this ticket's dependency closure; do not register the instrument. Name it in the report. - SDK handling of consumers and acknowledgements (M2-29, M2-30), the conformance suite (M2-31), load tests on the agent path (M2-36).
- Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M2-07-agent-consumers; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(agents): ...,feat(gateway): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- No agent runtime, adapter, SDK or model is started: the agent side in tests is the
wstestclient of M1-64 or plain HTTP with the agent's API token and DPoP proofs fromidentitytest; no real third-party service. - Time-dependent tests set short values for
agent.ack_timeout,agent.ack_persist_intervalandsync.resume_windowthrough the configuration loader or use thekernel/clocktest clock; no test depends on a default value. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
- Placement (02 4.4, 4.6, 7.3).
agent_consumer_cursors,agent.events.consumeand the opackbelong toagents, so the cursor use cases, the three REST handlers and the registration ofacklive there. Delivery belongs to the gateway (02 10: agent connections are consumers; invocation events go throughruns.Deliverybefore sending), andagentsmay not usesyncorgateway. Therefore one delivery engine ingatewayserves both transports: the WebSocket directly and REST through the source portagents.ConsumerFeed, whichagentsdeclares andgatewayimplements (02 4.1 rule 4). The gateway reaches the table only throughagents.Consumers. Ruling fordocs/DECISIONS.md: one engine for both transports, so the window rules and the later hook of M2-08 exist once. - Table and fencing. The table is built exactly as 03, with
holder_session_id. Every takeover writes the holder and every save ofacked_seqisUPDATE ... SET acked_seq = GREATEST(acked_seq, $acked), updated_at = now() WHERE agent_id = $1 AND consumer_name = $2 AND holder_session_id = $holder; no row means another holder took over:ErrConsumerReplaced. This holds in both values ofcluster.mode, because 03 states the conditional save without restriction and OQ-07 keeps the code node-neutral. Ruling fordocs/DECISIONS.md: fencing is always on; M7-17 adds only the faster signal between nodes. These statements take no lock beyond their row and append no events, so they take neitherevent_seqnorNOTIFY(02 11.7, 03 6.3). - Consumer name. An agent's
identifycarriesconsumer, defaultdefault(04 6.3); aresumekeeps the consumer of the resumed session (GatewayResumehas no such member). A value that does not match the pattern ofConsumerPathcloses the connection with4002(M1-64 note 3, frame does not match its schema; this ticket adds the pattern toGatewayIdentify.consumer). In REST the path parameter is checked by the chain:400 validation_failedwith pointer#path/consumer. Consumers exist only for principals of kindagent(04 6.2 rowack, 6.4 row4015: "agents only"); for people and apps the memberconsumeris accepted and ignored, and their delivery stays as M1-65 built it. Ruling fordocs/DECISIONS.md. - Agent sessions on the gateway. For an agent the durable cursor decides where delivery starts (04 15.1 point 4), so the gateway ignores
cursorandsync_epochofidentifyandresumeand skips the client cursor check of M1-64 note 12 for agent sessions. Order afterready(the hub registration before readinghead_seqstays as M1-64 note 11 and M1-65 built it), inDelivery.Attach, under a per-process lock perConsumerKey: a. If this process holds the key in another gateway session, save that session's acknowledgement withSave(it still holds the row). b.Take(consumer, holder = this gateway_session_id, head = ready.head_seq): authorizesagent.events.consume; creates the row withacked_seq = head_seqif absent (the first connection starts athead_seq, 04 15.1 point 2); otherwise writes the holder and keepsacked_seq.forbiddencloses with4018,unauthenticatedwith4012, a database failure with4000(04 6.4 table). c. Close the older connection of step a with4015, reasonconsumer_replaced, and remove its gateway session from the store, so a laterresumeof it gets4006. Drop a REST window of the key in this process. Log one INFO record with agent id, consumer and both session ids; no audit entry (05: Auditno). d. Start the runner withacked = acked_seq(note 5). Aresumekeeps the gateway session id and therefore the holder; it restarts the runner from the session's in-memory acknowledgement. If the resumed session's old connection is still open, it closes with4015(another process took over the same consumer), not4017, because agent connections have no device (M1-64 note 15). Ruling fordocs/DECISIONS.md: steps a to d, the session end after4015, and the ignored client cursor. - Delivery window (
gateway/domain, pure). State:acked(highest acknowledged),read(highestseqchecked by this window),inflight(dispatched durable events withseq > acked, each with the time it was queued),redeliverUpTo,highestSent, the phase (backlog or live) and the count of skipped numbers since the last frame that carried aseq.- Budget:
agent.max_inflight_eventsminus the number of inflight entries; at zero the runner reads nothing and waits (04 15.2 point 2). - Reading: a page after
readup to the bound of note 6. Of the admitted events at most the budget is dispatched; if the page was cut,readbecomes theseqof the last dispatched event, otherwise the last checkedseqof the page. - Ack:
seq > ackedsetsacked, drops inflight entries up toseqand raisesreadtoackedif it was lower; acknowledging is cumulative (04 15.2 point 1). - Timeout: the deadline is the queue time of the oldest inflight entry plus
agent.ack_timeout. When it passes,redeliverUpTo = max(redeliverUpTo, highestSent),read = acked, inflight is emptied, and reading restarts fromacked + 1(04 15.2 point 3). An event dispatched again withseq <= redeliverUpTocarriesredelivered: true; an event never dispatched before in this window does not. A newidentifyorresumestarts a fresh window without the flag. Ruling fordocs/DECISIONS.md: the flag marks exactly the ack-timeout redeliveries, asEventEnvelope.redelivereddescribes; the deadline runs from the oldest unacknowledged dispatch. - Frames: while the window is in the backlog phase it sends
cursor {seq: read}after every page whosereadis above the lastseqit sent, so an adapter can acknowledge past events it may not see (R-SY-03); whenreadfirst reacheshead_seqofreadyit sendscatchup.complete {head_seq}once and enters the live phase; live it sendscursorwhenever the skipped numbers reachsync.cursor_advance_every(04 7.1, 7.2 point 4, as M1-65 does for people). Ruling fordocs/DECISIONS.md: a consumer connection always catches up fromacked_seq + 1and ends that catch-up withcatchup.complete. - Resync: at start the runner calls
CatchUp.Checkwithafter = acked.retention(events afterackedwere removed) andcursor_ahead(only after a restore without reset) sendresync_required {reason, head_seq, sync_epoch}, set the cursor to thathead_seqwithRestart, and continue live from there withoutcatchup.complete, as M1-65 does afterresync_required.too_far_behinddoes not apply to consumers: the backlog is delivered through the window, because agent events are commands and the window already bounds the load;epoch_changedcannot occur, because the client epoch is ignored and the restore resets agent cursors (04 7.5 point 4, M7-04). Ruling fordocs/DECISIONS.md. - Purge: when the queue purge of M1-65 drops dispatch frames of a removed target, the window drops their numbers from inflight so they hold no budget; the next
cursorframe lets the adapter acknowledge past them.
- Budget:
- Runner (
gateway/app). One goroutine per consumer connection. The hub does not queue durabledispatchframes for agent sessions; after each event it wakes their runners with a non-blocking, coalescing signal. The runner reads pages with the sync port inauthorize.Delivery(ctx)mode (M1-65), page sizesync.catchup_page_size, upper bound the hub position: the highestseqthe process's log tail has passed throughAuthorizer.ObserveandHub.Dispatch. Reason: every admission then happens with a permission cache that has seen everyaccess.changedup to that event (04 8.1, R-SY-02), and a full window makes the runner wait instead of filling a buffer that would close the connection with4014. Envelopes carryDeliveryFields{IncludeTraceContext: true, Redelivered: ...}(M1-16, M1-63). The runner waits for queue space like the catch-up runner of M1-65 and retries a failing page read with backoff capped atdatabase.listen_reconnect_backoff_capwhile the connection is open (02 14.4). Theaccess.changedhandling of M1-65 (revalidation with4012, index update, purge) applies to agent sessions unchanged. Ruling fordocs/DECISIONS.md: consumer connections pull from the log up to the hub position. DeliveryHook(outgoing port ofgateway/app):Prepare(ctx context.Context, agentID ids.ID, ev sync.Envelope) (sync.Envelope, bool, error). Every dispatch to a consumer, on both transports, passes it;falsemeans "do not deliver, treat as checked". This ticket bindspassdelivery; M2-08 bindsruns.Deliverythere (02 10).- Acknowledgement rules (both transports;
agents/domain.CheckAck):seqbelow 0 or above the head of note 9 isvalidation_failed(pointer/seq);seqat or below the stored or held acknowledgement changes nothing and is no error (06 2 "No effect"; monotone, 06 7); otherwise the acknowledgement advances. Acknowledging beyond what was dispatched is allowed and skips those events, because an acknowledgement covers every number up toseq(04 15.2 point 1). Gateway opack {seq}: the dispatcher handler callsagents.Consumers.AcceptAck(authorizeagent.events.consume, thenCheckAck), then applies the acknowledgement to the session's window and marks it dirty; nothing is written yet; no reply frame (asyncapireceive.ackhas none). Errors answererror {op: "ack", code}and keep the connection (04 6.2):validation_failed,forbidden(people and apps,subject_kind_not_allowed),rate_limitedfor classrl.agent_runtime(06 9 point 3; if the merged dispatcher has no per-op rate limit yet, apply the class toackwithplatform/ratelimit, keyed by the agent). Ruling fordocs/DECISIONS.md: the upper bound and the "no change" rule. - Head and saving. The head that consumer operations report and check acknowledgements against is the hub position of the serving process (note 6); only a gateway connection creates a missing cursor at the
head_seqof its ownready(note 4 b), because that is the head the client was told. Ruling fordocs/DECISIONS.md: a consumer never acknowledges or is told about aseqthis process cannot yet deliver. Saving: a process-wide ticker with intervalagent.ack_persist_intervalcallsSavefor every consumer session whose held acknowledgement is above its last saved value.ErrConsumerReplacedcloses that connection with4015and ends the session (the takeover happened elsewhere, 04 15.1 point 3); other errors keep the session dirty for the next tick (log WARN). Flush withSavealso before a takeover in this process (note 4 a), when a connection detaches or closes for any reason (including4012and4014), and in step 3 of the shutdown beforeCloseAll(4013)within the remainingshutdown.total_timeoutbudget (02 15.3). Acknowledgements made after the last successful save of a connection that loses the row are lost; those events come again (at least once, 04 9; the adapter deduplicates by event ID, 04 15.2 point 4). - REST operations (
agents/app, each firstauthorize(actor, agent.events.consume, {kind: agent, id: actor, owner: actor}), classrl.agent_runtime):getConsumerState: the stored row;404 not_foundif this consumer never connected (reading does not create a cursor; 04 15.1 point 2 creates it at the first connection). AnswerConsumerState {consumer, acked_seq, head_seq}withhead_seqfromConsumerFeed.Head.pollAgentEvents: delegates toConsumerFeed.Poll. The engine keeps one REST window perConsumerKeyin the process with its own holder id (a UUIDv7 created with the window). Per call, under the per-key lock: flush and close a gateway session of the key in this process with4015(note 4 a and c);Take(consumer, holder = REST holder, head)(the first poll creates the cursor athead_seqand returns an empty page); reconcile the window with the storedacked_seq(drop inflight entries up to it); apply the timeout rule of note 5; resync check as note 5, whereretentionandcursor_aheadmove the cursor tohead_seqwithRestartand answer410 resync_requiredwithreason; otherwise read up to the budget, wherelimitdefaults toagent.max_inflight_eventsand larger values are capped to it (06 6 point 3). AnswerEventPage {events, next_cursor, head_seq, sync_epoch}withnext_cursor= the window'sread(the adapter acknowledges it after processing, also wheneventsis empty). Withinagent.ack_timeouta poll never repeats inflight events; it only continues after them while the budget allows. A REST window idle forsync.resume_windowis dropped by the sweeper; the next poll starts a fresh window fromacked_seq + 1. Ruling fordocs/DECISIONS.md: a REST poll is a consumer like anidentify(the newest wins, R-SY-09), the per-process window, the idle rule, and410forretentionandcursor_ahead.ackAgentEvents: head fromConsumerFeed.Head,CheckAck, thenAckConsumer(acked_seq = GREATEST(acked_seq, $seq)immediately, without holder condition,404 not_foundfor an unknown consumer), thenConsumerFeed.Ackedso a window of the key in this process sees it; answerConsumerState. NeithergetConsumerStatenorackAgentEventstakes the consumer over. Ruling fordocs/DECISIONS.md.
- Permissions. The descriptor of note 10 makes
AgentSelfdecide (M1-28): an agent passes for itself; a person or app token gets403 forbiddenwithreason: subject_kind_not_allowedand no row is created.TakeandAcceptAckuse the same descriptor. Denials are audited byauthz(R-AZ-04); admission of events in the runner and the REST poll runs in delivery mode and writes no audit entry (07 4.2, AZ-P13). No permission version, noaccess.changed, no event of any kind is written by this ticket (05: Permissionsno). - Errors (06 5; codes only from
x-problem-codes):
| Situation | REST | Gateway |
|---|---|---|
consumer does not match the pattern |
400 validation_failed, pointer #path/consumer |
identify closes with 4002 |
| caller is not an agent | 403 forbidden, reason: subject_kind_not_allowed |
ack answers error with forbidden; consumer ignored at identify |
| consumer never connected | getConsumerState, ackAgentEvents: 404 not_found |
not applicable (identify creates) |
seq below 0 or above the head |
400 validation_failed, pointer /seq |
error with validation_failed |
seq at or below the acknowledgement |
200 with the current state |
nothing |
limit below 1 |
400 validation_failed, pointer #query/limit |
not applicable |
retention, cursor_ahead |
410 resync_required with reason; cursor moved to head_seq |
resync_required {reason, head_seq, sync_epoch}; cursor moved |
class rl.agent_runtime exhausted |
429 rate_limited with Retry-After |
error with rate_limited |
| another consumer took over | not applicable | older connection closes with 4015 |
- Concurrency. One runner goroutine per consumer connection owns the window; the reader goroutine passes acknowledgements to it (channel or the window's mutex), and the save ticker reads the held value under the same lock. A per-process lock per
ConsumerKeyserializes takeovers byidentify,resumeand REST polls. Across processes the lastTakewins and the loser learns it at its next save (fencing); an acknowledgement throughackAgentEventsin another process is monotone and cannot loweracked_seq. Two consumer names of the same agent never affect each other. - Configuration keys used:
agent.ack_timeout,agent.ack_persist_interval,agent.max_inflight_events(registered by this ticket),sync.catchup_page_size,sync.cursor_advance_every,sync.resume_window,sync.outbound_queue_max,gateway.write_timeout,database.listen_reconnect_backoff_cap,shutdown.total_timeout,ratelimit.rl.agent_runtime.per_minute,ratelimit.rl.agent_runtime.burst. - Rulings for
docs/DECISIONS.md(one row each with reason and place): note 1 (one engine,ConsumerFeed); note 2 (fencing always on); note 3 (pattern, agents only); note 4 (takeover order, session end after4015, client cursor ignored,4015on resume); note 5 (flag and deadline, catch-up withcatchup.complete, resync reasons for consumers); note 6 (pull up to the hub position); note 8 (upper bound, no change at or below); note 9 (head of the serving process); note 10 (REST takeover, window, idle rule,410, no takeover by state and ack). The first connection athead_seq, the inflight limit, the redelivery fromacked_seq + 1and4015are in 04 and need no ruling.
Tests (write them first)¶
Unit (go test, fake clock from kernel/clock, fakes for ports):
internal/gateway/domain:TestConsumerWindow_BudgetBoundsInflight(no read at zero budget; a page with more admitted events than the budget is cut andreadis the last dispatchedseq);TestConsumerWindow_AckCumulativeAndMonotone(acknowledgingndrops every inflight entry up ton; a lower value changes nothing);TestConsumerWindow_TimeoutRestartsFromAcked(after the deadline reading restarts atacked + 1, exactly the previously dispatched numbers carry the flag, numbers never dispatched do not, the deadline follows the oldest inflight entry after a partial ack);TestConsumerWindow_PurgeReleasesBudget;TestConsumerWindow_CursorAndCatchupFrames(backlog:cursorafter each page with skipped numbers;catchup.completeexactly once whenreadreaches the ready head; live:cursoraftersync.cursor_advance_everyskipped numbers);TestConsumerWindow_ResyncReasons(retentionandcursor_aheadrestart at the head,too_far_behindis ignored).internal/agents/domain:TestValidConsumerName(default, allowed and rejected values);TestCheckAck(below 0, above head, equal, below, above).internal/agents/appwith fake repository and fake authorizer:TestTakeCreatesAtHead,TestTakeKeepsAckedAndWritesHolder,TestSaveFencedReturnsReplaced,TestRestPollDelegatesAfterAuthorize,TestAckEventsUnknownConsumerNotFound.internal/gateway/appwith fakes foragents.Consumers, the sync port, the hub position and a writer that blocks:TestConsumerRunner_WaitsAtMaxInflight,TestConsumerRunner_ReadsOnlyUpToHubPosition,TestConsumerRunner_RedeliversAfterAckTimeout,TestConsumerRunner_DeliveryHookCanDrop(an envelope the hook rejects is not sent and counts as checked),TestConsumerTakeover_FlushTakeCloseOrder(save of the old session, thenTake, then4015, then the old session is gone),TestConsumerSave_ReplacedCloses4015,TestConsumerFlush_OnCloseAndShutdown,TestRestWindow_NoRepeatWithinTimeout,TestRestWindow_IdleSweptAfterResumeWindow,TestAckOp_ErrorKeepsConnection.
Integration (PostgreSQL 18 via pgtest, the in-process server and the wstest client of M1-64 as in M1-65's tests, HTTP through the real chain; Owner Alex with step-up creates the agent support-agent through the agents port of M1-51 and issues its API token with a DPoP key from identitytest (M1-44); Alex grants the agent read on channel Allgemein of space Support with authz.Admin.CreateGrant (M1-29); Sam posts messages with createMessage (M1-56) in Allgemein (visible to the agent) and in a channel of space Marketing without a grant (invisible); updateAgent by Alex produces agent.updated about the agent):
TestConsumerFirstConnectionStartsAtHead(plan acceptance "the first connection starts athead_seq"): messages posted before the firstidentifyare never delivered; the row exists withacked_seqequal toready.head_seq; the firstdispatchaftercatchup.complete {head_seq}has aseqabove it; the same holds for the firstpollAgentEventsof another consumer name (empty page,next_cursorandacked_seqequalhead_seq).TestConsumerContinuesFromAckedSeq: acknowledge some events, close, post more,identifyagain: delivery starts atacked_seq + 1; acknowledged events do not come again; unacknowledged ones come withoutredelivered.TestConsumerMaxInflightEvents(plan acceptanceagent.max_inflight_events, key set low in the test configuration): post more visible messages than the key allows; exactly that manydispatchframes arrive and no more while nothing is acknowledged; acknowledgingkof them releases exactlykmore;pollAgentEventswith a largerlimitreturns at most that many events.TestConsumerRedeliveryAfterAckTimeout(plan acceptance "withoutack, delivered again afteragent.ack_timeoutwithredelivered", short timeout): without acknowledgement the same events come again fromacked_seq + 1withredelivered: true, the first delivery has no flag, events dispatched for the first time after the reset have no flag; after acknowledging nothing comes again; over REST a second poll inside the timeout returns no repeated event and a poll after it returns them with the flag. Every frame decodes into its generated type with unknown members rejected, and the storedevent_logrows contain noredeliveredmember.TestConsumerSecondIdentifyCloses4015(plan acceptance "a secondidentifyof the same consumer closes the old one with4015"): two connections with the same token and consumer; the first closes with4015and reasonconsumer_replaced; acknowledgements it made before are stored; later events reach the second;holder_session_idequals the second'sgateway_session_id; aresumeof the first session gets4006; a connection of the same agent with another consumer name stays open and keeps its own cursor.TestConsumerFencedSaveAcrossInstances: two server instances on one database (as AZ-P9 in M1-65); the agent connects to instance A, then to instance B with the same consumer; after the agent acknowledges on A, A's connection closes with4015withinagent.ack_persist_intervalplus test slack, andacked_seqis not changed by A's late save.TestConsumerAckPersistedWithinIntervalandTestConsumerShutdownFlushesAck: an acknowledgement is visible throughgetConsumerStatewithinagent.ack_persist_interval; an acknowledgement right before shutdown is stored and the connection closes with4013.TestConsumerStreamIsFilteredByRights(07 8.1, AZ-P9 for consumers): the agent receivesmessage.createdof Allgemein andagent.updatedabout itself, never messages of Marketing;cursorframes cover the skipped numbers; after Alex revokes the grant on Allgemein (authz.Admin.RevokeGrant) through server instance A while the agent is connected to instance B (two instances on one database, as AZ-P9 in M1-65), the agent receivesaccess.changed, no later message of Allgemein, and the redelivery afteragent.ack_timeoutdoes not repeat an unacknowledged Allgemein message.TestConsumerAckValidation:ackabove the head answerserrorwithvalidation_failedand the connection stays;ackbelow the acknowledgement changes nothing;ackAgentEventsabove the head gives400 validation_failedwith pointer/seq, a negativeseqgives400, an unknown consumer404, a valid one200 ConsumerStatewith the stored value.TestPollTakesOverGatewayConsumer: with a gateway connection holding consumerdefault,pollAgentEventsfordefaultcloses it with4015and delivers;getConsumerStateandackAgentEventsclose nothing.TestPollRetentionGivesGone: after partition maintenance of M1-16 removed the events after a consumer'sacked_seq(shortsync.event_retentionin the test),pollAgentEventsanswers410 resync_requiredwithreason: retention,acked_seqequals the reported head, and the next poll answers200; the same consumer over the gateway getsresync_requiredwithretentionand stays open.TestConsumerNameRules:identifywith an invalidconsumercloses with4002; a REST path with an invalid name gives400 validation_failed;identifywithoutconsumerusesdefault.TestPeopleAndAppsUnchanged: Sam's connection still receives live events as in M1-65 and has no cursor row; an appidentifywithconsumercreates no row; Sam'sackanswerserrorwithforbidden; Sam's token on the three REST operations gives403 forbiddenwithreason: subject_kind_not_allowed.TestConsumerRoutesAndOpRegistered(AZ-P1, AZ-P2): the three routes are registered withagent.events.consumeandrl.agent_runtime; the opackis registered by moduleagentsand is ingateway_ops.enabled; the start check of M1-25 passes.TestConsumerDenialsAudited(AZ-P13): a denied REST call writes one aggregated deny entry; the runner's delivery filtering writes none.TestConsumerAgentDisabledCloses4012:disableAgentcloses the consumer connection with4012after its acknowledgement was saved.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/agents/... ./internal/gateway/... ./internal/sync/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./migrations/...make migrations-lint,make codegen-check,make arch,make register-check,make api-lint,make api-checks,make config-doc-check,make docsmake check
Acceptance criteria¶
-
agent_consumer_cursorsexists exactly as 03 defines it; onlyagentsreads and writes it;make migrations-lintandmake register-checkare green. -
getConsumerState,pollAgentEvents,ackAgentEventsand the opackare enabled and registered withagent.events.consume; AZ-P1 and AZ-P2 pass. - The first connection of a consumer, over the gateway and over REST, begins at
head_seq; later ones atacked_seq + 1. - At most
agent.max_inflight_eventsunacknowledged events are on the way per consumer; acknowledging releases exactly as many. - Without
ackwithinagent.ack_timeoutthe events come again fromacked_seq + 1withredelivered: true; the flag never reaches the event log. - A second
identifyof the same consumer closes the older connection with4015; a save that finds another holder closes with4015, also across two server instances; other consumers of the agent are unaffected. -
acked_seqis saved withinagent.ack_persist_interval, on close and at shutdown before4013. - Every delivery and redelivery is admitted with the permissions at delivery time; delivery filtering writes no audit entry, denials of requests and ops do.
-
docs/api/openapi.yaml,docs/spec/09-agents.md,docs/spec/02-architecture.md, the READMEs anddocs/DECISIONS.mddescribe what was built. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
agentsandgatewayupdated (status, portsConsumersandConsumerFeed, table, action, opack, configuration keys, the window, takeover, save ticker and shutdown flush). - Rulings of Design note 15 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- If the implementation shows a threat that
docs/security/threat-model.md5.10 does not list (for example the takeover of a consumer with a stolen agent token), add the row with its countermeasures (DPoP binding, revocation closes with4012) and register T-TK-02. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example timing margins of the timeout and save tests, behaviour with
cluster.mode = multibeyond the two-instance test). - Follow-ups that could not be done in this scope, each with its reason (at least the metric
bothy.agent.consumer.lagwithout owner), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.