Skip to content

M1-65 Gateway hub, delivery, catch-up and revocation

No Date Ruling Reason
M1-65/0 2026-10-16 Base int/m1-wave31 at f63a8aa, branch feat/M1-65-gateway-delivery in its own worktree; the branch is not merged locally and not pushed, the integration lane ports it. Brief, spec and contract were read from origin/main; every core rule the brief cites (R-SY-02, R-SY-03, R-SY-04, R-SY-14 to R-SY-16, R-AZ-04, R-AR-17, R-RE-02) is already on the base, so no contract commit was brought in. Instructions of the launcher, which override the brief's merge step.
M1-65/1 2026-10-16 Structure events (channel.created, channel.updated, channel.archived, space.archived, thread.moved) invalidate every cached delivery snapshot through a process-wide generation counter. These events are rare, and a global generation is correct without knowing every affected principal (brief, "Permission cache in delivery mode").
M1-65/2 2026-10-16 A delivery snapshot is valid while its rights version is at least the highest rights version any event of the log has carried so far (and its generation is current and valid_until lies ahead), not the floor of the principal's last access.changed that the brief sketches. A rights change without a difference of visible targets writes no access.changed (04 8.2), for example token.revoke of an agent whose grants stay; with a floor per principal the hub kept delivering to the revoked token until the next heartbeat (reproduced in TestGateway_RevocationCloses4012/revokeToken), against AZ-P9. Every later event carries the new version, so the next delivery check rebuilds the snapshot, the revoked credential answers unauthenticated and the hub closes with 4012 before anything is delivered. A rights change rebuilds every delivery snapshot once, as REST does per request (02 11.3: the cache is keyed by permission_version).
M1-65/3 2026-10-16 A revocation that writes neither access.changed nor session.revoked and is followed by no event of the log closes the connection at its next heartbeat (Revalidate, M1-64/10) instead of at once; nothing is delivered to it meanwhile (M1-65/2). The log is the only cross-process signal of the gateway (02 "Log tail per api process"); an extra signal for such revocations would be a change of 04 or 05, which this ticket may not make (P-11).
M1-65/4 2026-10-16 AZ-P9 runs as an integration test with a second api process (wire.Serve) on the same database in the same test binary; the Owner revokes through the REST API of the first process. 07 "Required tests"; two real processes with their own log tails and rights caches are the case the rule guards.
M1-65/5 2026-10-16 The sequence diagram "Access removal during an open connection" runs without holding the server's writer: frames queued before access.changed are purged by Purge (unit tests TestQueue_BoundedAndPurgeByScope, TestQueue_ControlFramesSurvivePurge), and the integration test asserts that no event of the removed channel reaches the connection after access.changed while events of the other channel still do. The in-process server offers no hook to stall its writer; the purge itself is a pure function of the queue.
M1-65/6 2026-10-16 A delivery admission check that fails with an error other than unauthenticated (database briefly unavailable while a snapshot is rebuilt) closes that connection with 4000, which stays resumable (M1-64/3); the hub does not skip the event. Skipping it would let the next frame with a seq (a later dispatch or a live cursor) move the client's cursor past the event, which then is never delivered, against at-least-once (04 9 item 1); the client resumes with its own cursor and the catch-up runner retries its reads with backoff. Found by the code review, reproduced in TestHub_AdmitErrorClosesResumable.
M1-65/7 2026-10-16 The flush of the live buffer after catchup.complete waits for queue space like the catch-up pages; only a live buffer above sync.outbound_queue_max closes with 4014. 04 7.2 step 5 bounds the live buffer, not the moment after a catch-up that filled the queue; without waiting a single buffered event closed a client that caught up more than the queue holds. Found by the code review, reproduced in TestCatchUp_FlushWaitsForQueueSpace.
M1-65/8 2026-10-16 The catch-up runner reads its pages in filter mode with a request scope per page (the rights version is read once per page), not in delivery mode; only the live path and the flush of the live buffer use delivery mode. The events up to H can lie ahead of the log tail of this process, whose Observe has not yet seen a revocation committed before them, so a delivery snapshot could admit an event of a revoked target (red team round 1, blocker 1; AZ-P9). The flushed events came through the tail and passed Observe first. Test TestCatchUp_PagesReadCurrentRights.
M1-65/9 2026-10-16 sync.Admit checks an own personal event against the credential with gateway.connect on the instance; an unauthenticated answer is its error (the hub closes with 4012), any other answer leaves the event admitted. A personal event needs no receive action, so a revoked API token without access.changed kept receiving its own events until the heartbeat (red team round 1, blocker 2). REST listEvents is unchanged. Test TestAdmit_SingleRuleForGateway.
M1-65/10 2026-10-16 A target that access.changed adds is taken out of the targets a connection filters while it catches up. Replaced by M1-65/15 (removals bound to the events before them); the test still holds. A channel removed and granted again during one catch-up stayed filtered, and the next frame moved the cursor past its events (red team round 1, major 1). Test TestCatchUp_RegrantedTargetDelivered.
M1-65/11 2026-10-16 The catch-up runner and the flush queue only while the outbound queue holds fewer than sync.outbound_queue_max minus domain.ControlReserve (4) frames; control frames of the client's ops (heartbeat_ack, reauth_required, error) use the reserve. The live path keeps the full bound. Otherwise a heartbeat during a long catch-up on a slow link overflowed the queue the runner kept full and closed with 4014 (red team round 1, major 2). Tests TestQueue_RoomLeavesControlReserve, TestCatchUp_LeavesControlReserve.
M1-65/12 2026-10-17 A context that outlives the request or op it comes from drops its request memo (authorize.WithoutRequestScope): the WebSocket handler for the connection and Hub.Attach for the catch-up runner. Ops and catch-up pages then get a fresh memo each. WithRequestScope keeps a memo that is already there, so the per-page scope of M1-65/8 inherited the memo of the identify op and the HTTP upgrade request, and a token revoked during catch-up received every page until the heartbeat (delta red team, blocker). Tests TestGateway_TokenRevokedDuringCatchUpCloses4012 (real database), TestCatchUp_PagesReadCurrentRights, TestWithoutRequestScope.
M1-65/13 2026-10-17 channel.created is a candidate of every connection, and an event of a channel admitted for a connection (live, flush or catch-up page) adds its channel and space to the hub indices of that connection. The default operator agent of a space reads every inheriting channel of the space without a rights change and, in a space without a channel, without a visible target there, so the indices from connection setup never listed the new channel and its events missed the live connection while cursor frames moved past them (red team round 3, major). Test TestGateway_SpaceDefaultOperatorGetsNewChannelLive.
M1-65/14 2026-10-17 The log tail sink observes the committed rights version, read once per batch after the batch, before it delivers the batch. An event carries the rights version its transaction read before commit; a rights change without an event (API token revocation) committing in between left the delivery snapshot valid, and the message committed after the revocation reached the revoked token (red team round 3, major). Test TestGateway_TokenRevokeRacingAMessageCommit.
M1-65/15 2026-10-17 One rule for delivery across the phases of a connection: (a) from Register until it goes live a connection is a candidate of every event of a space or channel, so its live buffer holds everything it may be entitled to and the flush decides with the rights of that moment; (b) an event with a seq at most the head of the connection is not sent again (the snapshot or the catch-up covers it), but a channel.created among them still adds its channel to the hub indices; (c) a removal of access.changed at seq N drops only queued, buffered or paged events before N. The live buffer stays bounded by sync.outbound_queue_max. The rounds before fixed one instance of the same class each: events lost, sent twice or out of order across registering, catching up, live and revoked when space, channel and rights changes ran concurrently. Red team round 4 reproduced a default operator agent that lost the first message of a channel created while it caught up; the model test also found a lagging tail sending events up to the head twice, and a removal processed by the tail after the pages had read a later re-grant dropping entitled events. Class test TestHub_DeliveryModel (seeded random runs of the real hub with fakes, red on d498e61); regressions TestHub_OperatorNewChannelWhileCatchingUp, TestHub_NewChannelUpToHeadIsLearned, TestHub_LiveSkipsTailEventsUpToHead, TestHub_StaleRemovalKeepsLaterPageEvents, TestHub_StaleRemovalKeepsQueuedLaterEvents, TestQueue_PurgeOnlyBeforeTheRemoval, TestGateway_OperatorNewChannelWhileCatchingUp (real database).
M1-65/16 2026-10-18 Port onto main: code and tests as in the ticket range (f63a8aa..8294aa7), which entered the integration as one squash without integration rulings, except the points named here. The test world of serve_gateway_test.go keeps the owner pool of main, opened once in newGatewayWorld, instead of the lazily opened pool of the ticket, and only gains the field env; allowAll in identity/app/devices_read_test.go, a fake of main that the base did not have, gains Observe like the other fakes. The status line of the authz README names only M1-65, since the target host and terminal rights (M2-39, M2-40) of the base are not on main, and the field list of server in wire/serve.go gets only gateway (the terminal and apps fields of M2-39 and M2-24 are not on main). Comments keep the heading names of main (02 "Start and graceful shutdown", 04 "Envelope"), and architecture/config-schema.json is regenerated, which names 04-sync-protocol.md for sync.cursor_advance_every. No migration. The base int/m1-wave31 held tickets that are not on main; the port keeps the behaviour of the ticket and adapts only to what main has.
M1-65/17 2026-10-20 The two catch-up tests with a backlog written to the log (TestGateway_OperatorNewChannelWhileCatchingUp, TestGateway_TokenRevokedDuringCatchUpCloses4012) use a backlog of 2000 copies instead of 20000. The client does not read during the race, so the catch-up runner already waits for queue space after sync.outbound_queue_max (50) frames; 2000 keeps it running through the race. On the hosted runners the 20000 frames of the watching connection and of the reconnect took longer than testkit.Patience, and the reconnect without heartbeat hit 4009 after 8150 dispatches; the property under test does not depend on the size beyond the queue.
M1-65/18 2026-10-21 When access.changed revalidates a connection and gateway.connect is now forbidden, the connection closes like at identify: 4016 for a pending device or principal, 4018 for any other forbidden (forbiddenClose, shared with authorizeConnect). Before, only an unauthenticated decision closed; a forbidden one kept the connection open until the next heartbeat, with only the per-event admission in the way (CodeRabbit finding on the port; TestHub_ForbiddenConnectAtRevalidationCloses failed before the fix).