Skip to content

M1-64 Gateway connection and session

No Date Ruling Reason
M1-64/0 2026-10-16 Base int/m1-wave28 at c83c68a, branch feat/M1-64-gateway-connection in its own worktree; the branch is not merged locally and not pushed, the integration lane ports it. The brief, spec and contract were read from origin/main. The gateway part of the contract change L-28 of the planning line was missing on the base and comes in as its own commit: close code 4018 forbidden (04 6.4, asyncapi x-close-codes, 15, signals), the _ms names of hello in 04, and heartbeat and reauth not consuming rl.gateway_connect (04, 05, asyncapi, actions.yaml). Instructions of the launcher, which override the brief's merge step; the brief maps "valid credentials, other forbidden" to 4018, which the base did not define.
M1-64/1 2026-10-16 Heartbeat deadline: the server closes with 4009 when no heartbeat arrived for sync.heartbeat_interval times (sync.heartbeat_miss_limit + 1) since ready, resumed or the last heartbeat, counted from the instant ready or resumed is sent. The client delays its first heartbeat by a random share of the interval (04 6.3 step 5); the extra interval absorbs that delay, and counting from the frame instead of the start of identify keeps the snapshot build out of the client's budget.
M1-64/2 2026-10-16 Reauth for people: reauth_required {expires_at, dpop_nonce} goes out once, sync.reauth_lead_time before the expiry of the current access token. reauth is authenticated like identify; a rejected token or proof answers error {op: reauth, code} and keeps the connection; a token of another principal, device or auth session, or a credential that is not a person's access token, closes with 4004; then gateway.connect is authorized. Without a valid reauth the connection closes with 4011 at the expiry (exp, without auth.clock_leeway). Agents and apps get no reauth_required and are closed with 4011 at the expires_at of their API token. 04 6.3 step 6; a failed refresh of the client must not cost the connection while the old token is valid, a token of another session must not take over a live connection.
M1-64/3 2026-10-16 Resume: an unknown session, a session detached for sync.resume_window or longer, and a session of another principal or device all close with the same 4006. Credentials are checked, rate limited and authorized as for identify. When the session's old connection is still open it is closed with 4017 first. A session stays resumable after the ends 0 (client gone), 1000, 1001, 4000, 4001, 4002, 4009, 4011, 4013 and 4014; after 4004, 4012, 4016, 4017, 4018 and the other ends it is removed. Identical answers leak nothing about foreign sessions; 4006 for a still open old connection would make that client reconnect and push the new one out; the final ends tell the client not to resume.
M1-64/4 2026-10-16 The sweeper of the session store runs every sync.resume_window; a detached session is removed at the latest two windows after its connection ended. resume checks the window itself, so the sweep only frees memory and needs no key of its own.
M1-64/5 2026-10-16 Every body of a client frame of the channel gateway is closed with additionalProperties: false, the stats ops of M6 included (12 schemas with GatewayClientInfo); the generator check walks the bodies of all client ops of asyncapi.yaml, not only of the enabled ones. The Python SDK models are regenerated accordingly. W7 holds for every client frame; a check over the whole channel keeps later tickets from enabling an op whose body is still open.
M1-64/6 2026-10-16 Frames pass the JSON guard of SEC-IN-2 with http.max_json_depth, then the envelope {op, d} with no other member, then the schema of the op (httpserver.SchemaValidator) and the strict decoder. One nesting limit for every JSON the server reads; the brief names no gateway-specific key.
M1-64/7 2026-10-16 The route GET /api/v1/gateway is bound in internal/wire (GatewayOperation, GatewayRouteBinding), not in the generated route table, because it is no operation of openapi.yaml; the start checks its binding with the REST bindings (AZ-P1). The marker httpserver.Operation.AuthorizedInBand makes Chain.Wrap skip every per-route stage for it; only the outer steps 1 to 4 run. 02 "Chain in the api node" steps 1 to 3 for the upgrade; authentication and authorize happen on the connection (R-SY-01), so the proof stage would refuse the 101.
M1-64/8 2026-10-16 A missed gateway.write_timeout ends the connection with 4014 as its recorded close code, but coder/websocket tears the connection down when the context of a write ends, so the client sees the end without the close frame. A full outbound queue closes with a regular 4014 close frame. The library offers no write without teardown; a peer that does not read cannot receive the close frame either.
M1-64/9 2026-10-16 After a binary frame or a frame above sync.ws_max_frame_bytes the reader stops reading; the close handshake of the library reads until the peer's close frame. Reading on would parse the rest of the refused frame as the next frame and turn the 4002 into a protocol error 1002.
M1-64/10 2026-10-16 heartbeat revalidates the credential through actor.Authenticator.Revalidate (identity reads token or session, device and key again, one query) and then authorizes gateway.connect; an unauthenticated answer of either closes with 4012, another error of the revalidation answers error with its problem code and keeps the connection. The immediate end of open connections on session.revoked and access.changed comes with M1-65. Red team round 1: the actor of identify carries the credential state of that time, which authorize trusts, so a revocation without a new rights version went unnoticed; brief note 14 requires 4012 at the heartbeat.
M1-64/11 2026-10-16 An error while building ready or resumed maps like the table of note 10: device_pending 4016, any other forbidden 4018, session_revoked 4012, another unauthenticated 4004, the rest 4000. Spec gap: an app with events:subscribe passes gateway.connect, but sync.snapshot.read has no app_scope in the catalogue (05, actions.yaml, also on origin/main), so the snapshot of ready is refused and the app is closed with 4018; brief note 22 ("apps get ready like people") needs a contract change that this ticket may not make. 4000 would make the app reconnect with backoff for ever; 4018 tells it not to. The core contract is not edited by this ticket.
M1-64/12 2026-10-16 The WebSocket handler puts the transport of the upgrade request (TCP peer, header fields, action gateway.connect) into the context of the connection, as the authenticate stage does for REST. The session binding of trusted header logins (M1-43) reads it on every authentication; without it every identify of such a session revoked the session (red team round 1).
M1-64/13 2026-10-18 Port onto main: code and tests as in the ticket range (c83c68a..24dad8b) except the points named here. The spec commit 37bebaf of M1-64/0 is not ported, because main already has its content (close code 4018, the _ms names of hello, heartbeat and reauth outside rl.gateway_connect) from the port of M4-13 (D-W-24). The section gateway_ops of api/operations.yaml and its generator name the field milestone (the German etappe, as M1-17/32), also in the messages and the negative fixture bad milestone. the constant sixtySeconds, also the default of sync.reauth_lead_time, is the one of configkeys_terminal.go (M2-39, M2-40); M2-24 is not on main, so the comment of actor.Actor.Scopes stays as on main; gatewayConfigKeys follows syncConfigKeys in configKeyGroups. The constant defaultTenSeconds of configchapters.go, which the two 10s database keys use, comes with this ticket, as M2-26 brought it on the integration branch; with the two gateway keys 10s would otherwise stand four times and goconst reports it. The subtest TestClose4018AppWithoutEventsScope of wire/serve_gateway_test.go is not ported: it installs an app through the tables apps and app_installations of M2-24, which main does not have, and the Authenticator of main reads no installation, so no app reaches gateway.connect yet; the mapping of a refused snapshot to 4018 (M1-64/11) stays covered by TestSnapshotDenialCloses4018 and the setup table of gateway/app, and the subtest returns with the port of M2-24. The test world of TestGateway runs its statements through one pool of the schema owner instead of opening a pool per statement. The gateway README keeps main's section "Tables" (gateway_presence, M7-17) and the English lead-ins make register-check reads. The wave 31 integration (INT-W31/1) only resolved text conflicts with the same 4018 rows, so no integration fix changes this ticket. The Python SDK models, openapi.gen.yaml, the route metadata, config-schema.json and the configuration reference are generated again on main. The specification and the enable list are English on main (D-W-94), and the tickets that M1-64's base held beyond main are ported later; each change keeps the behaviour of the approved ticket. A pool per statement kept one owner connection per statement until the suite ended (more than 47 in one run); next to the other tests of internal/wire the database reached max_connections and the suite failed in three of four full runs of the package, with the database is not reachable wherever the message was recorded.
M1-64/14 2026-10-20 A heartbeat whose revalidation refuses the credential as unauthenticated closes with 4011 like the tick of the expiry, not with 4012, when the credential has reached its expiry; the clock is read after the revalidation, so an expiry during its query counts. session_revoked, and any refusal before the expiry, stay 4012. The revalidation reports an expired API token as unauthenticated, and 4012 would make the client delete its local data; found as a flaky TestClose4011AgentTokenExpired in CI of the port, TestHeartbeatAtExpiryCloses4011 failed before the fix (internal/gateway/app/heartbeat.go, 04 section 6.4).
M1-64/15 2026-10-20 A deadline that passes while a frame is being handled closes nothing: while Gateway.Handle runs, Tick skips the closes 4003 (identify timeout) and 4009 (heartbeat timeout), and the adapter ticks again after the frame, so the frame decides (an identify that succeeds ends ready, a heartbeat moves the deadline). The expiry 4011 still applies. The deadline goroutine and the reader run side by side; an identify or heartbeat that arrived in time but whose database queries outlasted the deadline was closed (in CI of the port TestClose4012RevokedAtHeartbeat closed with 4003 during identify, TestFirstHeartbeatJitterTolerated with 4009); TestDeadlineWaitsForAFrameInFlight failed before the fix.