| D-M2-07-0 |
2026-10-17 |
Base int/m1-wave38 at 297ab0c, branch feat/M2-07-agent-consumers in its own worktree; the branch is not merged locally and not pushed, the PR lane ports it. Brief, spec and contract were read from origin/main; the rules the brief relies on (table agent_consumer_cursors with holder_session_id, R-SY-09, R-SY-23, the close code 4015) are on the base in the German chapters, so no contract change is ported. Text this ticket adds to the German chapters 02 and 09 is English. The migration takes the next free number of the base, 00046. |
The task names the base and the lane; the merged names win (D-W-100). |
| D-M2-07-1 |
2026-10-17 |
One delivery engine for both transports: gateway/app.ConsumerEngine runs the window of every consumer connection and every REST window, and implements agents.ConsumerFeed itself; internal/wire binds it into the REST use cases with the late setter ConsumerService.SetFeed. There is no separate package gateway/adapters/consumerfeed. |
The window rules and the delivery hook of M2-08 exist once (brief note 1); a forwarding package around the engine would add a type without behaviour. |
| D-M2-07-2 |
2026-10-17 |
Fencing is always on: every takeover writes holder_session_id, every save of acked_seq is GREATEST with WHERE holder_session_id = <own holder>, and a save without a row closes the connection with 4015, in both values of cluster.mode. M7-17 adds only the faster signal between nodes. The statements touch one row, append no event and run on the pool outside a unit of work. |
03 states the conditional save without restriction and OQ-07 keeps the code node-neutral; the save ticker has no request, and a single-row statement needs no transaction. |
| D-M2-07-3 |
2026-10-17 |
A consumer name matches ^[a-z0-9][a-z0-9_-]*$ (ConsumerPath, now also on GatewayIdentify.consumer, so a wrong name closes identify with 4002); consumers exist only for agents, the member consumer of people and apps is accepted and ignored. |
04 6.2 and 6.4 name consumers for agents only; one pattern for both transports. |
| D-M2-07-4 |
2026-10-17 |
Takeover order under a per-process lock per consumer: save the acknowledgement of the older connection of this process, Take with the new gateway session as holder (creating the row at ready.head_seq), close the older connection with 4015 consumer_replaced, drop a REST window of the key, log one INFO line with agent, consumer and both session ids. 4015 is not resumable, so the older session leaves the session store and its resume gets 4006. The gateway ignores cursor and sync_epoch of an agent's identify and resume; a resume first proves with a fenced save that the session still holds the row (otherwise it closes with 4015 and the newer holder stays, also when the takeover happened while the session was detached, through a REST poll or in another process), then continues from the acknowledgement held in the session and closes a still open older connection of the session with 4015 instead of 4017. |
Brief note 4; 04 15.1 items 3 and 4 (the durable cursor decides), agent connections have no device. |
| D-M2-07-5 |
2026-10-17 |
The window (gateway/domain.ConsumerWindow) marks exactly the ack-timeout redeliveries with redelivered: true; the deadline runs from the oldest unacknowledged dispatch. A consumer connection always ends its backlog with catchup.complete {head_seq} when the window reaches the head of ready; retention and cursor_ahead send resync_required, move the cursor to the head with Restart and continue live without catchup.complete; too_far_behind and epoch_changed do not apply. |
Brief note 5; EventEnvelope.redelivered; the window bounds the backlog, and the client epoch is ignored. |
| D-M2-07-6 |
2026-10-17 |
Consumer connections pull from the log up to the hub position (Hub.Position: the highest seq the log tail of the process has passed through Observe and Dispatch, seeded with the head the tail started at); the hub queues no durable dispatch for them and wakes their runners after each event. The pages are read in filter mode with a request scope of their own per page (the rights version is read once per page), not in delivery mode. |
Brief note 6 for the bound. A revocation without a log event (a revoked API token) is not seen by the delivery cache until the next event, so delivery mode kept paging a revoked token's backlog; the per-page version refuses the next page with unauthenticated and 4012 (TestGateway_TokenRevokedDuringCatchUpCloses4012, as M1-65/8 for people). |
| D-M2-07-7 |
2026-10-17 |
Acknowledgement rules on both transports: seq below 0 or above the head is validation_failed on /seq; seq at or below the acknowledgement changes nothing and is no error; a higher seq covers every number up to it, also beyond what was dispatched. The gateway's ack checks against the hub position or the window's own position when that lies above it (a fresh consumer starts at ready.head_seq). |
Brief note 8; 06 2 "No effect"; the cursor a client was told must be acknowledgeable. |
| D-M2-07-8 |
2026-10-17 |
The head that consumer operations report and check against is the hub position of the serving process; only a gateway connection creates a missing cursor at the head_seq of its own ready. One goroutine per process (ConsumerEngine.Run, every agent.ack_persist_interval) saves every consumer connection whose held acknowledgement is above its saved one and drops the REST windows idle for sync.resume_window; a connection that detaches or closes for any reason and step 3 of the shutdown (before 4013) save at once. |
Brief note 9; a consumer is never told about a seq this process cannot deliver yet. |
| D-M2-07-9 |
2026-10-17 |
A REST poll is a consumer like an identify: per call, under the per-key lock, a gateway connection of the key in this process is saved and closed with 4015, Take writes the holder of the REST window (a UUIDv7 created with the window; the first poll creates the cursor at the hub position and answers an empty page), the window is reconciled with the stored acknowledgement, the ack timeout applied and the resync checked; retention and cursor_ahead answer 410 resync_required with the reason after moving the cursor to the head. next_cursor is the window's position but never covers an event an earlier poll handed out that is still unacknowledged (it stays below the oldest one), so a lost response cannot acknowledge events away; they come again after agent.ack_timeout. getConsumerState and ackAgentEvents take nothing over; ackAgentEvents writes at once without holder condition and tells a window of the key in this process. |
Brief note 10; R-SY-09 holds for both transports. |
| D-M2-07-10 |
2026-10-17 |
A caller that is not an agent: the descriptor of agent.events.consume is the agent itself ({kind agent, id actor, owner actor}), and authz decides visibility before the requirement, so a person or app that is no owner gets 404 not_found and an owner gets 403 forbidden with reason: subject_kind_not_allowed; the gateway's ack of a person answers error with the same code. The brief's table expected 403 for every caller. |
Follows the merged authz (M1-28) and R-AZ-01 (404 before 403, ADR 0017); the code does not deviate from the specification. |
| D-M2-07-11 |
2026-10-17 |
The gateway applies rl.agent_runtime to ack itself with platform/ratelimit keyed by the agent (KeyForAgent); an exhausted bucket answers error with rate_limited. An ack whose credential was revoked since identify closes with 4012; other errors close with 4000. |
The dispatcher has no per-op rate limit (06 9 item 3); a revoked credential must not keep an open connection. |
| D-M2-07-12 |
2026-10-17 |
Tests of M1-64 and M1-65 follow the consumer semantics: every agent principal of the gateway tests gets its agents record (the cursor refers to it), the agent catch-up tests of M1-65 acknowledge as they read (with a larger agent.max_inflight_events), and the transport tests of adapters/ws register every enabled op except ack, whose handler needs the engine. |
Without an acknowledgement a consumer stops at agent.max_inflight_events by design (04 15.2 item 2). |
| D-M2-07-13 |
2026-10-17 |
Differences from the names of the brief (D-W-100): the REST use cases are the port agents.ConsumerAPI beside agents.Consumers; no sync port change was needed (CatchUp.PageEvents returns events, the hub position is Hub.Position); the integration tests create agents and tokens as SQL fixtures like the world of M1-65 and let the Owner post; retention is simulated by deleting the events the log tail has passed; the app part of TestPeopleAndAppsUnchanged is not built (people only). The metric bothy.agent.consumer.lag and its alert are not registered (out of scope). |
The merged code and test harness win; the remaining gaps are listed in the report. |
| D-M2-07-14 |
2026-10-18 |
docs/api/openapi.yaml gets only the contract changes of the brief (pattern of GatewayIdentify.consumer, minimum: 0 on both seq, 410 on pollAgentEvents); the descriptions of the three operations that the brief asks for stand in 09 4.3 "REST fetch" instead. |
The file was 146 bytes below repo.max_file_kb (the large-file hook of make lint); the descriptions would exceed it, and raising the limit is not this ticket's to decide. |
| D-M2-07-15 |
2026-10-21 |
Port to main: only the ticket's own commits on top of M1-65 to M1-68. The migration takes the next free number on main, 00045 (gap-free, migrations_lint), not 00046 of the integration base; the text this ticket added to the German chapters 02 and 09 moves to 02-architecture.md and 09-agents.md, and references to German chapter names are translated. The generated client api/restclient of the base is not on main, so its regeneration is dropped; every other generated file is regenerated on main. M1-66, M1-67 and M1-68 are on main, so the port takes the conflict fixes INT-W41/1 to /3 and INT-W42/1 to /5 with their rulings; the parts of INT-W42 for the test harness of M1-70 and M1-72 (test/harness) stay out, because neither ticket is on main. |
Tickets land one by one from main; a fix for a ticket that is not merged would add code without its counterpart. |
| D-M2-07-16 |
2026-10-21 |
The port takes the halving of objects.gen.go into objects.gen.go and objects2.gen.go by the route generator unchanged from the port of M6-01 (routes/gen halveObjects, its test and the fixture). |
On main the file was just below repo.max_file_kb; the three consumer operations push it above the limit, and the same generator change in the M6-01 port merges cleanly with this one. |
| D-M2-07-17 |
2026-10-21 |
Three SonarQube Cloud findings of the port are kept with NOSONAR: the single-method interfaces gateway/app.DeliveryHook and gateway/app.Position keep the names of the brief (notes 6 and 7), and the cancel function of StartConsumerSaver is not deferred, because the returned stop step calls it at shutdown. The two other findings (a duplicated branch in ConsumerWindow.Frames, an unneeded variable in the panic guard of the runner) are fixed. |
The rule -er naming and the deferred cancel do not fit a port named by the brief and a cancel that must outlive the function. |