M3-23: Desktop hints over the gateway¶
Status: ready | Depends on: M3-19 | Milestone: M3
Goal¶
After this ticket a person who sits at a connected desktop is not disturbed twice. The gateway implements the presence port presence.View that M3-19 declared in internal/kernel/presence and wired as presence.None: for the people the worker consumer push asks about, it returns one entry per device with a live gateway connection, with the focus set and the idle flag of the last focus op that M1-66 stores (04 6.2, 13 4.4, 13 12 point 4). The pure decision of M3-17 then sees the facts it was built for: a device that focuses the thread and is not idle stops the hint on every device ("Active in the thread"), a connected device gets the ephemeral event notification.created instead of a push (13 4.6 point 1, 6.3, R-PN-01), and while a macos or windows device is connected and not idle, pushes to ios and android devices wait notify.mobile_delay_when_desktop_active and are dropped when the person has read the message in the meantime (13 4.4 "Desktop active", ReleaseDelayed of M3-17). The outgoing port LiveRoutes of M3-19 gets its real implementation in notify: a gateway route becomes one job notify.gateway_send per hint and recipient, which after the commit of the round checks the rights of that moment and publishes notification.created through kernel/eventlog.EphemeralPublisher to the live connections of the recipient (audience self, receive action push.deliver, or the recipient rule of R-AZ-30 for hints without a thread); a delayed route becomes one notify.push_send per registration scheduled at the end of the delay, carrying the thread sequence of the message, so the send job drops it with the skip reason read. The two plan cases hold end to end: a thread in focus on the desktop gives no push to the phone, and a message read on the active desktop before the delay ends gives no push to the phone. With server.role = all (level 0 of 02 16) everything runs in one process; with separate roles or several api nodes the worker sees no live connection and every device with an active push registration is reached by push (a device without one gets no route), as documented until M7-17 brings a shared presence state (RK-37).
Read first¶
docs/spec/13-push-notifications.md: 1 "Goals" (points 3 and 4); 2 "Kinds of notifications and recipients" (rowssecurityandadmin); 4.1 "Flow" (the flowchart, nodes L to P); 4.4 "Active on a device" (both bullets); 4.5 "Coalescing" (the flush hint also takes a route); 4.6 "Delivery path per device" (points 1 to 3); 5.3 "Windows and desktop through the gateway"; 6.3 "Gateway eventnotification.created"; 6.4 (only the table, to see which preview fields this ticket leaves to the preview builder, Design note 9); 9 "Delivery measurement" (rows t0 and t1); 12 "Execution in the server" (points 1, 4 and 5); 14 "Tests" (rows "Thread in focus on the desktop" and "Desktop active, message read there before the delay expires"); 15 R-PN-01, R-PN-05; 16 rows 3 and 4; 17notify.mobile_delay_when_desktop_active,push.max_age.docs/spec/04-sync-protocol.md(core contract, read only): 2.1 "Two kinds" (ephemeral events are pure acceleration); 2.2 "Envelope" (noseq, nopermission_versionon ephemeral events,audience); 6.1 "Endpoint and version" (a device holds exactly one connection); 6.2 "Ops" (rowfocuswithidle); 6.3 "Lifecycle" (when a connection becomes live); 7.2 "Flow" point 6 (no ephemeral event beforecatchup.complete, dropped at a full queue, never4014); 9 "Delivery guarantee" point 4 (at most once); 10 "Ordering" (row "ephemeral").docs/spec/05-actions.md(core contract, read only): 4.9 rowpush.deliver("before every push, every notification via the gateway"); 5 "Agent invocationagent.invoke", the paragraph "Check without a request" (the list itempush.deliverbefore each hint decision and each push send, and the recipient rule of R-AZ-30); section 6, table of ephemeral events, rownotification.created; R-AZ-11, R-AZ-29, R-AZ-30, R-AZ-31.docs/spec/03-data-model.md(core contract, read only):devices(platform,state);read_states(last_read_seqand the formula "Unread" withthreads.unread_floor_seq);threads.last_message_seq;messages.thread_seq;push_registrations(disabled_at).docs/spec/02-architecture.md: 3.2 "Roles of a process" (the gateway runs in roleapi, the consumerpushand job execution in roleworker,allunites both); 4.3 (rowgateway), 4.4 ("Tiers":notifyandgatewayon the same top layer), 4.5 (rowskernel/eventlogandkernel/presence, the latter added by M3-19); 10 "Gateway"; 11.4 "Hub and delivery" (index principal to connections); 11.5; 11.6 "Ephemeral events" (the last sentence namesnotification.created); 12.2 (queuedelivery, maintenance mode); 12.3 rownotify.push_send; 12.4; 16 "Scaling path" (levels 0 to 2).docs/api/openapi.yaml: schemasNotificationCreatedEvent,NotificationCreatedPayload,NotificationKind,EventEnvelope,EventResource,EventScope,GatewayFocus(idle); operationsputReadState,putNotificationPreference,createMessage,createThread.docs/api/asyncapi.yaml: messageevent.notification.created(x-event-kind: ephemeral,x-receive-action: push.deliver,x-audience: self),receive.focus.docs/operations/configuration.md:notify.mobile_delay_when_desktop_active,notify.coalesce_window,push.max_age,server.role,cluster.mode,sync.outbound_queue_max,sync.event_payload_max_bytes,sync.max_focused_threads.docs/observability/signals.md: 4.4bothy.push.skipped(the reasons as M3-19 rewrote them:active_in_thread,read,no_access,expired,no_registration), rowbothy.delivery.duration(bothy.event.durability = ephemeral, recorded by M1-68 when the gateway writes the frame).- ADRs 0008 (hexagonal modules), 0017 (one
authorize), 0031 (push without content), 0051 (worker consumers of the event log), 0053 (no JSONnull). docs/DECISIONS.md: D-PN-01, D-K-G10 (notification.created), D-K-G11 (focus.idle), D-K-A13 (ephemeral events across nodes), D-W-100, D-W-108 (points 2 and 6: R-AZ-30, R-AZ-31, the push block, the revised M3-19), section 10 "Rulings of the build milestones" (format of the rowsD-M3-23-<n>).docs/OPEN-QUESTIONS.md: OQ-06 (no presence of humans for other people: the presence of this ticket is an internal fact of the hint decision and is never shown or sent to anyone), OQ-07 (multi-node operation in M7).docs/RISKS.md: RK-37 (presence per api node, ephemeral events across nodes, M7-17).docs/security/threat-model.md: 5.5 "Gateway (WebSocket)" (rows "Delivery after access removal" and "Focus on foreign threads"), 5.12 "Push" (row "Push after access removal", T-AZ-03), T-PU-01.docs/PLAN.md: 3 "Way of working per ticket"; 5 "Test strategy"; 9 "M3 Platform services" ("Goal", "Exit criteria" points 1 and 2, "Red team" item "content in push payloads"); the rows M3-17, M3-19 (the ports this ticket fills), M3-22 (the preview builder, not in this ticket's closure, which depends on M3-23 and adds the preview fields ofnotification.created), M3-23 (this ticket, the contract), M3-24 (measurement); 7 rows M1-64 to M1-66; 10 row M4-30 (the Windows app that shows these hints); 13 row M7-17.- Dependencies: the header names M3-19; M1-66 (focus and
idle) is implied through it and left the plan row as an implied edge (D-W-108 point 8). The closure brings what this brief uses: M3-18 and M3-17 (internal/notify/domainwithDecide,Flush,ReleaseDelayed,Route,Via), M2-09, M2-01 (consumer frame), M1-71, M1-69, M1-68 (metric registry), M1-65 (hub,sync.Admit,authorize.Delivery(ctx), two server instances in one test binary), M1-64 (wstest), M1-63, M1-61 (putReadState,sync.ReadStates,chat.Threads.InfoForShare), M1-56, M1-55 (chat.Threads.Info), M1-45, M1-38 (pending devices), M1-33 (devices), M1-28, M1-16, M1-14 (platform/jobs), M1-13, M1-12 (pgtest), M1-07, M1-06, M1-05, M1-01 and the rest of their closure. Not in the closure and not needed: M3-20, M3-21 (providers; tests usefakepush.FakeProviderof M3-19), M3-22 (preview builder and registration writes; tests write registrations as fixture rows like M3-19 does), M1-67 (frame conformance), M1-70 (SQL visibility lint; Design note 12 says how to follow it where merged), M7-17. - Briefs (merged names win, D-W-100):
M1-66-gateway-focus-typing.md(all of it:FocusSet, theidleflag "no other code readsidlein M1 (M3-23 does)",EphemeralEvent,EphemeralPublisher, the publisher rules, focus pruning,resume),M1-65-gateway-delivery.md(hub indices,Hub.Register,sync.Admitas the single admission rule, personal events only to the audience, phases),M1-64-gateway-connection.md(phases,wstest, identify with DPoP from the identity test helpers), the draft brief of M3-19 (Design notes 7, 8, 9, 10, 11, 15;presence.View,presence.Connection,presence.None,LiveRouteswithGatewayandDelayed, theHintvalue, the argsPushSend,jobs.Inserter.InsertUniqueTx,sync.ReadStates.PositionsFor,TestLiveRoutes_DefaultFailsClosed,TestNotificationCarriesNoText),M3-17-notify-decision.md(Design notes 6, 8 and 9: activity, routes, the quiet flag of a delayed push,ReleaseDelayed),M1-14-background-jobs.md(jobs.Args,Register,Inserter.InsertTx,InsertOptions.ScheduledAt,ValidateArgsType),M1-61-sync-personal-data.md(read position andunread_floor_seq),M1-68-core-metrics.md(bothy.delivery.durationon the ephemeral path),M2-10-run-document-live-box.mdDesign note 8 (an ephemeral event is published only after the commit),M2-01-worker-event-consumers.md(note 2: the handler runs inside the round's transaction; note 7: a failure rolls the whole round back). - Code on the base branch (all under
services/server/):internal/gateway(hub, connections,FocusSet, the publisher of M1-66,adapters/wstest),internal/kernel/presence(M3-19),internal/kernel/eventlog,internal/notify(domain/of M3-17,app/andadapters/of M3-18 and M3-19),internal/sync(Admit,ReadStates),internal/chat(Threads.Info,Threads.InfoForShare),internal/platform/jobs,internal/wire,services/server/internal/gateway/README.md,services/server/internal/notify/README.md.
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, right 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. Everything this ticket adds to the repository is English, also new text in chapters and READMEs; existing text stays as it is.
Product requirements implemented here:
- Only the server decides about hints, also for desktop clients; a desktop or any other device with a live gateway connection gets
notification.createdover the gateway instead of a push to that device (13 1 point 4, 4.6 point 1, 6.3, R-PN-01). - "Active in the thread": a device of the recipient with a gateway connection whose
focuscontains the thread and that does not reportidlemeans no hint for that thread on any device (13 4.4; 04 6.2 rowfocus). The decision itself stays the pure function of M3-17; this ticket supplies the facts. - "Desktop active": a device with
platform IN ('macos','windows')that is connected and notidledelays pushes to mobile devices bynotify.mobile_delay_when_desktop_active; a delayed push is dropped when the read position of the recipient (read_states.last_read_seq, at leastthreads.unread_floor_seq) has reached the thread sequence of the message (13 4.4, 03read_states, M3-17ReleaseDelayed). notification.createdis ephemeral: noseq, nopermission_version, never stored, at most once, never beforecatchup.complete, dropped when the outbound queue is full and never a reason for4014(04 2.1, 2.2, 7.2 point 6, 9 point 4).- It goes only to connections of the recipient (
audience = self) and passespush.deliveron the thread with the rights of the moment it is delivered; a hint without a thread (security,admin) passes the recipient rule of R-AZ-30 instead (05 section 6 rownotification.created, 4.9 rowpush.deliver, R-AZ-29, R-AZ-30, R-AZ-31). - Its payload carries only
nid,kind,thread_id,message_id,quiet,countand, for hints without a thread, the target ID (R-AZ-30: "only the kind and the target ID"); no text and no name leaves this ticket's code in a hint (ADR 0031, T-PU-01, Design note 9). - Presence is an internal fact of the decision: it is never sent to another person and appears in no event, log or metric (OQ-06).
- An ephemeral event is published only after the commit of the transaction that decided it (M2-10 Design note 8; M2-01 note 2), and the round of the consumer
pushstays the only place where hints arise (13 12 point 1, ADR 0051).
Scope¶
All paths under services/server/ unless they start with docs/. Where M1-66, M3-17, M3-18 or M3-19 already created a file named below, extend it. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0 (make licenses).
Create:
internal/gateway/app/presence.go:PresenceView, the implementation ofpresence.Viewover the hub (Design note 2), andNewPresenceView(h *Hub) *PresenceView.internal/notify/app/liveroutes.go:liveRoutes, the implementation of the outgoing portLiveRoutesof M3-19 (Design notes 3 and 5).internal/notify/app/gatewaysend.go: the use case of the jobnotify.gateway_send(Design notes 4, 6, 7, 8).internal/notify/adapters/jobs/gatewaysend.go: argsGatewaySend, kindnotify.gateway_send, its handler (Design note 4).
Change:
internal/gateway/app(hub): an additive readHub.Presence(userIDs []ids.ID) map[ids.ID][]presence.Connectionthat copies, under the hub's own lock and without waiting on a connection, what Design note 2 needs from the principal index, the phase, the device of each connection and theFocusSetwith itsidleflag (M1-66 stored it and added no accessor; this ticket is its user).internal/sync(Admit, M1-65): the case of anotification.createdwithout a thread (Design note 6), with a unit test.internal/notify/app: theHintvalue of M3-19 getsMessageSeq int64if it has no field for thethread_seqof the message the hint points at, filled where M3-19 builds hints (Design note 5); the send use case ofnotify.push_sendgets the release check of Design note 5; the one routing function of M3-19 hands everygatewayandpush_delayedroute of a consumer send and of a flush send toLiveRoutes(verify that the flush path of M3-19 Design note 9 d uses it and route it through if it does not).internal/notify/adapters/jobs/jobs.go(M3-19): the argsPushSendgetReleaseSeq int64(Design note 5);TestNotificationCarriesNoTextof M3-19 also walksGatewaySend.internal/wire: role wiring of Design note 1 (PresenceViewandliveRoutesin roleall;presence.Noneand M3-19's fail-closed defaultLiveRoutesin roleworker); the job kindnotify.gateway_sendon queuedeliveryin every role that executes jobs (worker,all); thekernel/eventlog.EphemeralPublisherof the gateway for the gateway send use case in rolealland none in roleworker; regenerate the jobs golden file if one exists.docs/api/openapi.yaml:NotificationCreatedPayloadgets the optional propertytarget_idand descriptions (Design note 7);make codegen-check,make api-lint,make api-checks.- Docs (Design note 11; all new text in English, existing text stays as it is):
docs/spec/13-push-notifications.md4.4, 4.6, 6.3, 12 point 4;docs/spec/02-architecture.md4.3 rowgateway, 11.6, 12.3, 16;docs/RISKS.mdrow RK-37 (one sentence in the column "Mitigation");services/server/internal/gateway/README.mdandservices/server/internal/notify/README.md;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M3-23-1toD-M3-23-8(Design note 13).
This ticket adds no table, no migration, no operation, no action, no durable or ephemeral event type, no problem code, no configuration key, no metric name and no top-level Makefile target. It adds the job kind notify.gateway_send (02 12.3 in the same change), the optional payload property target_id (openapi.yaml in the same change) and the job argument fields ReleaseSeq and MessageSeq inside notify.
Out of scope¶
- The decision (
Decide,Flush,ReleaseDelayed, the routes and the skip reasons): M3-17. The consumerpush, candidates, facts, bundling windows, the jobnotify.push_sendwith its steps, result classes and skip reasons,presence.View,presence.Noneand the fail-closed defaultLiveRoutes, the badge of 13 8 and the source ofsecurityhints (device.pending, R-AZ-31): M3-19. This ticket fills the presence port and the live routes and changes M3-19's code only where the Scope names it. - The preview fields of 13 6.3 (
space_name,channel_name,thread_title,sender_display_name,excerpt,attachment_count,image_url): they come from the preview builder of 13 6.4, which M3-22 builds on M3-05, M3-06 and M3-07, none of which is in this ticket's closure; M3-22 depends on M3-23 and adds these fields tonotification.created(PLAN row M3-22, D-W-108 point 8; Design note 9). - APNs and FCM payloads and providers (M3-20, M3-21); registrations, the preview endpoint and its token (M3-22); the measurement of 13 9 and
perf.push_handoff_p95(M3-24). - A shared presence state and
notification.createdacross processes: withserver.role = workerbeside a separateapi, and withcluster.mode = multi, the worker sees no live connection (Design note 1). The cross-node path of 02 11.6 (NOTIFY ephemeral) and a shared presence state belong to M7-17 (PLAN row M7-17, D-W-108 point 8; RK-37, OQ-07). - Showing the hints on devices: the Windows tray app (M4-30), the desktop clients (M4), the apps (M5). Which window state a client reports as
idle: 13 4.4 leaves it to chapter 15, which does not define it yet; the server only reads the flag, so this is a client rule for M4. - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-23-desktop-notices; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(gateway): ...,feat(notify): ...) 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. Never log, print or persist a device token, an access token or a DPoP proof; logs of this ticket carry no person, device or thread ID. No network calls except package downloads for pinned tools and Go modules.
- No real push service in any test: providers are
fakepush.FakeProviderof M3-19; nothing in this ticket costs money. - Time only through
kernel/clock; tests move a fake clock and call job handlers directly, they never sleep for a delay or a window. - 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 or M2 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>. - The same holds for the merged code of M3-17, M3-18 and M3-19: follow their names (
presence.View,LiveRoutes,Hint,PushSend, the routing function, the send use case,InsertUniqueTx,PositionsFor) and list each difference.
Design notes¶
-
Place, dependencies and roles (02 3.2, 4.4, 4.5; 13 12 point 4; RK-37; Ruling for docs/DECISIONS.md).
notifyandgatewaysit on the same top layer of 02 4.4, so neither imports the other: the gateway implements the kernel portpresence.View(as it implementseventlog.EphemeralPublisher), andnotifyuses only kernel ports and its existing edges (chat,sync,identity). No module edge changes;make archandmake register-checkstay green without a change toarchitecture/modules.yaml. The gateway lives in roleapi, the consumerpushand job execution in roleworker(02 3.2), sointernal/wirewires by role:server.rolepresence.ViewLiveRoutespublisher for notify.gateway_sendallgateway/app.PresenceViewnotify/app.liveRoutesthe gateway's EphemeralPublisherworkerpresence.NoneM3-19's fail-closed default none apinot wired (no consumer push)not wired not wired (no job execution) With
Nonethe decision never yieldsgatewayorpush_delayed(M3-19TestLiveRoutes_DefaultFailsClosed), so in roleworkerevery device with a registration gets its push at once and a device without one gets nothing (13 4.6 point 3). Ruling: presence is memory of the process that holds the gateway (13 12 point 4), and only roleallholds both; separate roles (level 1 of 02 16) and several api nodes (level 2) reach desktops only by push until M7-17 brings a shared presence state and the cross-process path of 02 11.6 (PLAN row M7-17, D-W-108 point 8). A worker without presence logs one Info line at start (presence unavailable in this role: hints go by push only, attributebothy.server.role). Withcluster.mode = multiand roleallthe view sees the connections of its own node only (RK-37). Write the role table into thenotifyREADME and 13 12 point 4. -
PresenceView(13 4.4, 12 point 4; 04 6.1, 6.3, 7.2 point 6; Ruling for docs/DECISIONS.md).Connections(ctx, userIDs)returns, for each requested person that has at least one counted connection, onepresence.Connection{DeviceID, Focus, Idle}per device; people without one are absent from the map (not an empty list). A connection counts exactly when its principal is a human (kind = user), it has a device (every human session is device-bound, 08), it is in phaselive(aftercatchup.complete, 04 7.2 point 4) and it is not closing. Reason forlive:notification.createdis never delivered beforecatchup.complete(requirement 14), so a catching-up connection that counted as connected would get a gateway route whose frame is dropped, and the hint would be lost for that device; counted as not connected it gets its push, and the app replaces it by thread ID (13 6.1).Focusis a copy of the connection'sFocusSet(the readable threads M1-66 kept after its checks and pruning),Idlethe stored flag; a connection that never sentfocushas an empty focus andIdle = false, so a connected desktop that never reported idle counts as active (13 4.4 "connected and notidle"). Because a device holds exactly one connection (04 6.1,4017closes the older one), there is at most one entry per device; if the hub briefly holds two during the replacement, keep the newer one. The read takes the hub's lock only to copy, never waits on a connection queue, allocates new slices, and returns an error only for a cancelledctx. It reads no database. Agent and app connections are never returned. -
LiveRoutes(M3-19 Design note 10; 13 4.6; Ruling for docs/DECISIONS.md).notify/app.liveRoutesimplements both methods inside the round's unit of work (the consumer round and the flush job of M3-19); both insert jobs only, so the round keeps its rule "jobs and cursor commit together" (ADR 0051) and nothing leaves the process before the commit:a.
Gateway(ctx, h Hint, r domain.Route): insertGatewaySendfor the hint withjobs.Inserter.InsertUniqueTx(M3-19 Design note 9 a), so the second and later gateway routes of the same hint (one per connected device) add nothing: one job per hint and recipient. The job publishes once with audience the recipient, which reaches every live connection of that person; at decision time those were exactly the devices with a gateway route, becauseDecidegivesgatewayto every device with a connection (M3-17 Design note 8).b.
Delayed(ctx, h Hint, r domain.Route): insert onePushSendper ID inr.RegistrationswithInsertTx,ScheduledAt = r.NotBefore,DueAtUnixMicro = r.NotBefore(13 9 row t0: "with ... desktop delay the end of the waiting time") andReleaseSeq = h.MessageSeq; every other field as M3-19 fills it for an immediate push of the same hint (the sameNotificationID, kind code, quiet flag and count; M3-17 Design note 6 keeps the quiet flag). -
Job
notify.gateway_send(02 12.1 to 12.4; 13 6.3, 9; 05 4.9 rowpush.deliver, R-AZ-29 to R-AZ-31; Ruling for docs/DECISIONS.md). Modulenotify, trigger the consumerpush(and the flush job), queuedelivery(it is part of push, so it keeps running in maintenance mode likenotify.push_send, 02 12.2). Args, only IDs andint64(jobs.ValidateArgsType):GatewaySend{NotificationID, RecipientID, ThreadID, MessageID, TargetID ids.ID; Kind, Quiet, Count, DueAtUnixMicro int64}with the same meaning and kind codes asPushSendof M3-19 Design note 11 a. The handler:a. Step 1 in a unit of work:
now - DueAt > push.max_age: skipexpired. The recipient is no longer an active human: skipno_access. A kind with a thread:authorize(r, push.deliver, thread)with the principal of a human without a request, in filter mode, exactly as the send step of M3-19 Design note 11 b builds it (R-AZ-29, R-AZ-31; reuse that function, do not copy it): denied gives skipno_access.securityoradmin: the recipient rule of R-AZ-30 through the same function the send step of M3-19 uses for these kinds: false gives skipno_access. A kind with a thread also readschat.Threads.Info(ThreadID)for the currentspace_idandchannel_idof the scope (the thread may have moved since the decision). Commit.b. Step 2 outside any transaction: if this process has no publisher (role
worker, Design note 1), skipno_registrationand log one Warn line per process (gateway hint without a gateway in this process); this happens only in a deployment that mixes roleallwith separate workers, which 02 16 does not describe. Otherwise build the envelope of Design note 6 and callPublishEphemeralonce; an error is a programming fault (M1-66) and is returned, so River retries and the alert on failed jobs shows it.c. Exactly once is not promised: if the process dies after step 2 and before River marks the job complete, River runs it again and the same
nidreaches the client twice; the client shows one hint per thread (13 6.1 replacement by thread ID). Reason: an in-memory publish cannot join a transaction, and an ephemeral event is pure acceleration (04 2.1).d. Every skip counts
bothy.push.skippedwith its reason (the values M3-19 defined); a publish counts nothing new (the gateway recordsbothy.delivery.durationwithbothy.event.durability = ephemeralwhen it writes the frame, M1-68, which is t1 of 13 9 for this route). A connection that is not live or ended between decision and publish gets nothing and no fallback push (at most once, 04 9 point 4); its unread counters show the message on the next opening (13 4.6 point 3). -
Delayed push and its release (13 4.4 "Desktop active"; M3-17 Design note 9; 03
read_states; Ruling for docs/DECISIONS.md).a.
Hint.MessageSeqis thethread_seqof the message the hint points at: themessage.thread_seqof themessage.createdpayload, theThreadSeqof the last agent message of a run (M3-19 Design note 5 b), theThreadSeqof the newest message a flush hint points at (M3-19 Design note 9 d); zero for a hint without a message (security,admin, a run hint without an agent message).b.
PushSend.ReleaseSeqis zero for every immediate push andHint.MessageSeqfor a delayed one. In the send use case ofnotify.push_send, after the registration and age checks and thepush.deliver(or R-AZ-30) check of M3-19 Design note 11 b and before the provider lookup: ifReleaseSeq > 0, read the recipient's read positionreadSeq = max(read_states.last_read_seq, threads.unread_floor_seq)(sync.ReadStates.PositionsForwith an absent row read as 0, andchat.Threads.InfoForShareforUnreadFloorSeq, both of M3-19 Design note 9 d), and whendomain.ReleaseDelayed(ReleaseSeq, readSeq)is false skipread. An immediate push reads nothing extra.c. Nothing else is re-evaluated at the end of the delay: not whether the desktop is still active, not the focus, not whether the phone connected meanwhile (13 4.4 names only "read"). A delayed push of a quiet hint stays quiet.
-
Envelope and admission (04 2.2; 05 section 6 row
notification.created; R-AZ-30; M1-65sync.Admit; Ruling for docs/DECISIONS.md).EphemeralEventof M1-66:Typenotification.created,SchemaVersionfrom the event register, noActor(a job without principal),Audiencethe recipient. A kind with a thread:Resource {kind: thread, id: ThreadID},Scope {space_id, channel_id, thread_id}from step 1. A kind without a thread:Resource {kind: user, id: RecipientID}, emptyScope. Envelopeida new UUIDv7,occurred_atthe clock at publish. The hub's candidates are the connections of the audience (M1-65).sync.Admitstays the single admission rule: anotification.createdwith resourcethreadpasses the generic path (audience equals the connection principal, thenpush.deliveron the thread inauthorize.Delivery(ctx)), so a revocation that arrived in log order before the frame stops it (T-AZ-03); anotification.createdwith resourceuseris admitted exactly when the connection principal equalsAudienceandresource.id, withoutpush.deliver, because 05 section 6 replaces it with the recipient rule of R-AZ-30, which step 1 evaluated with the rights of that moment, and the connection itself proves an active session of that person. The resourceuserof a hint without a thread is the core contract's (05 section 6 rownotification.created: "for a hint without a thread the recipient (user)"; R-AZ-30; D-W-108 point 8). -
Payload (13 6.3; R-AZ-30; ADR 0053; Ruling for docs/DECISIONS.md).
NotificationCreatedPayload:nid(=NotificationID, the same ID as the pushes of the same hint),kind(from the kind code),thread_idandmessage_idwhen set,quiet,count, and the new optionaltarget_idforsecurity(the waiting device) andadmin(the grant or group of the reminder), because R-AZ-30 says the payload of such a hint names the kind and the target ID and the schema had no member for it. Absent members are omitted, nevernull. Add todocs/api/openapi.yaml:target_id($ref: '#/components/schemas/Uuid', description "Target of a hint without a thread: the waiting device forsecurity, the grant or group of the reminder foradmin(R-AZ-30)."); the descriptions ofthread_idandmessage_id("absent for hints without a thread", "absent when the hint points at no message") and ofcount("1, or the unread count of a bundled hint (13 4.5)"). The asyncapi message references the schema and needs no change;make codegen-checkregenerates the server and client types. -
Rights and timing (13 1 point 2, 4.2; 04 7.6; 05 4.9). A gateway hint is checked three times: at the decision (M3-19), in step 1 of the job with the principal of a human without a request, and in the hub with the connection's actor and the rights cache in log order. The second check catches a revocation committed after the round, the third one a revocation the tail has already applied. No check reads
idleor focus: presence changes the route, never the right. -
No preview fields (13 6.3, 6.4; Ruling for docs/DECISIONS.md). 13 6.3 lets the preview fields of 6.4 come with
notification.created. Their builder (preview mode, names, excerpt, attachment count, image ticket) is part of the preview endpoint of M3-22, which needs M3-05, M3-06 and M3-07; none of them is in this ticket's closure, and M3-22 depends on M3-23 and fills these fields innotification.createdwith its preview builder (PLAN row M3-22, D-W-108 point 8). This ticket therefore sends the payload of Design note 7 without them; a live client already holds the message and its names through the synced events of the same connection and renders the hint from them. Add to 13 6.3 (English): the preview fields come from the builder of 6.4, which M3-22 adds tonotification.created; until then they are absent and the client shows the hint from its synced data. -
Configuration and observability. Keys used, all present after M3-19:
notify.mobile_delay_when_desktop_active,notify.coalesce_window(through M3-19's routing),push.max_age,server.role,cluster.mode; read through the configuration loaded at start, never as literals. No new key, no new metric, no newbothy.reasonvalue. Logs carry onlybothy.job.kind,bothy.reason,bothy.server.roleanderror.type, never a person, device, thread, focus set ornid. -
Specification (rule 10; nothing in 00, 03, 04 or 05 changes; all new text in English, existing text stays as it is):
docs/spec/13-push-notifications.md4.4: after the first bullet, a connection counts fromcatchup.completeon and a connection that never sentfocusis not idle (Design note 2); after the second bullet, the delayed push is anotify.push_sendscheduled at the end of the delay with the thread sequence of the message and dropped when the read position reached it (Design note 5). 4.6 point 1: the jobnotify.gateway_sendpublishes once per hint and recipient after the commit to every live connection of the recipient; a connection that ended in between gets nothing (Design notes 3 and 4). 6.3: the envelope resource of a hint without a thread is the recipient, the payload membertarget_id, and the sentence of Design note 9. 12 point 4:presence.Viewis implemented by the gateway and wired only in roleall; the role table of Design note 1.docs/spec/02-architecture.md: 4.3 rowgateway, column "Implements sources": add kernelpresence.View; 11.6: a rownotification.created|notify(jobnotify.gateway_sendin the process of the gateway) | directly to the hub | until M7-17 none, thenNOTIFY ephemeralwith IDs; 12.3: a rownotify.gateway_send|notify| consumerpush(gateway route of a hint) |delivery| 13; 16, row of level 1: a sentence that the worker sees no live connection, so desktop hints over the gateway and the delay of mobile pushes need rolealluntil M7-17.docs/RISKS.mdrow RK-37, column "Mitigation": "Until M7-17 presence is wired only in roleall; separate roles and several api nodes deliver hints by push (13 12 point 4)."
-
Queries. The reads of step 1 (
chat.Threads.Info) and of the release check (PositionsFor,InfoForShare) are ports of M1-55, M1-61 and M3-19 used by a job without a request. Where the SQL visibility lint of M1-70 is merged and flags a new query of this ticket, add one exception entry with categorysystem_joband a reason; never widen an existing entry. This ticket adds no SQL of its own. -
Rulings for
docs/DECISIONS.md(section 10, one row each with date, decision, reason and place):D-M3-23-1role wiring of presence and live routes, local node only, the limitation for separate roles and several nodes (note 1);D-M3-23-2which connections count (human, device, phaselive, not closing; no focus means not idle) (note 2);D-M3-23-3gateway hints as one unique jobnotify.gateway_sendper hint and recipient, published after the commit to every live connection of the recipient, no fallback push (notes 3 a and 4);D-M3-23-4the checks of step 1 and their order, skip reasons, at least once with onenid(notes 4 and 8);D-M3-23-5the delayed push asnotify.push_sendwithReleaseSeq,DueAtat the end of the delay, nothing else re-evaluated (notes 3 b and 5);D-M3-23-6the self-only admission of hints without a thread, whose envelope resourceuser05 section 6 and R-AZ-30 give (note 6);D-M3-23-7the payload membertarget_id(note 7);D-M3-23-8no preview fields until M3-22 adds them with the preview builder (note 9). Rulings 1, 2, 3, 5, 6 and 8 also go into 13 (note 11).
Tests (write them first)¶
Test world: 07 10 and 11.1 (space Support with channels Allgemein and Eskalation; G-1 Alex trigger on Support; group Leser with Sam, G-4 read on Support/Allgemein; Robin without a grant on Support; the Owner). Alex's active devices, created with the identity test helpers, each with its own session and DPoP key: desktop (macos, no registration), laptop (windows, no registration) and phone (ios, one apns registration written as a fixture row through the pgtest connection, as M3-19 does). The provider is fakepush.FakeProvider registered as apns. Gateway connections use the wstest client of M1-64 against the real server on 127.0.0.1:0; "live" means the client received catchup.complete. Alex follows thread T in Support/Allgemein with level all (putNotificationPreference), and the Owner writes the messages. The server runs in role all; the consumer round of M2-01 is driven by the test, job handlers are called directly after moving the fake clock; durations are fixture values of the test configuration (notify.mobile_delay_when_desktop_active above zero, notify.coalesce_window above the test's steps). "No push to the phone" means: no notify.push_send with the phone's registration exists after the round, or every such job ends without a provider call.
Unit, internal/gateway/app (fake connections in the hub, no network):
TestPresence_CountsOnlyLiveHumanDeviceConnections: a live human connection is returned with its device; a connection in phasecatching_up, a closing one, an agent connection and an app connection are not; a person without a counted connection is absent from the map.TestPresence_FocusAndIdleAreCopies: focus andidleequal the last acceptedfocusop; a laterfocusop does not change a returned slice; a connection that never sentfocushas an empty focus andIdle = false; focus pruned by M1-66 (unreadable afteraccess.changed) is not returned.TestPresence_OneEntryPerDevice: during the replacement of an older connection of the same device only the newer one is returned.TestPresence_DoesNotBlock: with a connection whose queue is full and whose writer is blocked,Connectionsreturns at once; with a cancelled context it returns the context error.
Unit, internal/sync:
TestAdmit_NotificationCreatedWithoutThreadSelfOnly: resourceuserwith audience Alex is admitted for Alex's connection and refused for Sam's and for an agent connection; noauthorizecall happens for it (counting fake); resourcethreadstill runspush.deliverinDelivery(ctx)and is refused for a connection without read right.
Unit, internal/notify/app with fakes for every port (the clock, Scheduler, presence.View, chat, sync, the authorizer, the publisher, the job inserter):
TestLiveRoutes_GatewayOneJobPerHint: two gateway routes of one hint (desktop and laptop) insert oneGatewaySend; another hint inserts a second one; the args hold only IDs andint64.TestLiveRoutes_DelayedOneJobPerRegistration: apush_delayedroute with two registrations inserts twoPushSendwithScheduledAt = NotBefore,DueAtUnixMicro = NotBefore,ReleaseSeq = MessageSeq, the hint'sNotificationIDand quiet flag.TestGatewaySend_Steps: too old givesexpired; an inactive recipient and a deniedpush.delivergiveno_access;adminandsecurityuse the R-AZ-30 function and never callpush.deliver; no publisher givesno_registration; nothing is published in any skip; the publish happens after the unit of work returned (the fake unit of work asserts that no publish runs inside it); each skip incrementsbothy.push.skippedwith its reason.TestGatewaySend_Envelope: thread hint: resourcethread, scope from the currentThreads.Info(a moved thread gives the new channel), audience Alex, no actor, noseq, nopermission_version;adminhint: resourceuserwith Alex's ID, empty scope, payloadtarget_id= the grant ID, nothread_id; the encoded payload decodes strictly into the generatedNotificationCreatedPayloadand has no member outside Design note 7 and nonull.TestPushSend_ReleaseCheck:ReleaseSeq5 with read position 4 sends; withlast_read_seq5 skipsread; withlast_read_seq2 andunread_floor_seq6 skipsread;ReleaseSeq0 sends without reading the read position (counting fakes); a delayed push whose recipient lost access givesno_access, notread.TestHintMessageSeq: amessage.createdhint carries the payload'sthread_seq; arun_donehint theThreadSeqof the last agent message; anadminhint zero.TestNotificationCarriesNoText(M3-19, extended): walksGatewaySendand the changedPushSendwithreflect;jobs.ValidateArgsTypeaccepts both.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire in role all, HTTP and gateway through the real chain):
TestDesktopFocus_NoPushToPhone(PLAN row, 13 14 row "Thread in focus on the desktop"): Alex's desktop is live and sendsfocus {thread_ids: [T], idle: false}; the Owner writes in T; after the round: nonotify.push_sendand nonotify.gateway_sendfor Alex, no window job of (Alex, T),bothy.push.skippedwithactive_in_threadrose by one; the FakeProvider saw no call. Variants: the same with the phone live and focusing T (no hint either); the desktop sendsfocus {thread_ids: [T], idle: true}: the round inserts onenotify.gateway_sendand one immediatenotify.push_sendfor the phone (an idle desktop delays nothing), and running the gateway job deliversnotification.createdto the desktop; the desktop changes its focus to[]before the message: the hint arrives (gateway on the desktop, delayed push for the phone).TestDesktopActive_ReadBeforeDelay_NoPush(PLAN row, 13 14 row "Desktop active, message read there before the delay expires"): Alex's desktop is live, focus on another thread T2, not idle; the Owner writes message m in T; the round inserts onenotify.gateway_sendand onenotify.push_sendfor the phone scheduled at the fake clock plusnotify.mobile_delay_when_desktop_activewithReleaseSeq= thethread_seqof m. Running the gateway job delivers to the desktop exactly onedispatchwithnotification.created:nidequal to the push job'sNotificationID,kindmessage,thread_idT,message_idm,quietfalse,count1, noseq, nopermission_version. Alex then marks T read up to m throughputReadState(as the desktop would); the clock passes the delay; the push job ends without a provider call andbothy.push.skippedwithreadrose by one. Variants: no read: exactly one provider call with thenid; read only up to an earlier message: one provider call;notify.mobile_delay_when_desktop_activezero in the test configuration: the phone's push is immediate.TestDesktopHint_LaptopWithoutRegistration: only Alex's laptop is live (focus empty) and the phone is offline: the laptop getsnotification.created, the phone's push is delayed (the laptop iswindows, so the desktop is active); with neither laptop nor desktop live the phone's push is immediate and nothing is published.TestDesktopHint_CatchingUpCountsAsOffline: the desktop has identified and its catch-up is held open by the test (catch-up pages blocked at the hub): the phone's push is immediate and nonotify.gateway_sendis inserted.TestGatewayHint_RevocationBeforePublish(13 14 row "Revocation between event and sending" for the gateway route): Sam follows T withalland has a live desktop of her own; the round inserts hernotify.gateway_send; the Owner removes Sam from group Leser; running the job publishes nothing and countsno_access. Variant: the job publishes, and the hub refuses the frame because the tail applied theaccess.changedfirst (the publish is driven after the tail'sObserve): Sam's connection receives nonotification.created.TestGatewayHint_WithoutThread: anaccess.expiringappended through thesyncappender to Alex (M3-19 test pattern): Alex's live desktop receives onenotification.createdwithkindadmin,target_id= the grant, nothread_id, resourceuser; Sam's live connection receives nothing. A pending device registered for Alex through the identity test helper (which writesdevice.pending, R-AZ-31, built by M3-19) gives Alex's live desktop onenotification.createdwithkindsecurityandtarget_id= the pending device.TestFlushHintOverGateway: ten messages of the Owner in T within onenotify.coalesce_windowwhile Alex's desktop is live with focus elsewhere: the first gives one gateway hint and one delayed phone push; the flush at the window end gives a second gateway hint withcountequal to the unread count and a second delayed phone push; marking T read before the flush leaves no flush hint (M3-19 ruleread).TestRoleWorker_PushOnly(Design note 1): two server instances in one test binary, one in roleapiwith Alex's desktop live and focusing T, one in roleworkerdriving the round: the phone gets an immediate push, nonotify.gateway_sendexists, and the worker logged the Info line of Design note 1 once.internal/wire: roleallwiresPresenceViewandliveRoutes, roleworkerwirespresence.Noneand the default; the job kindnotify.gateway_sendis registered on queuedeliveryin rolesworkerandall;TestLiveRoutes_DefaultFailsClosedof M3-19 still passes;make register-checkpasses without a change toarchitecture/modules.yaml.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/gateway/... ./internal/notify/... ./internal/sync/... ./internal/kernel/... ./internal/wire/...make codegen-check,make api-lint,make api-checks,make arch,make register-check,make signal-checkmake diagrams(only if a diagram source changed),make docs,make linkcheckmake check
Acceptance criteria¶
- The gateway implements
presence.View: one entry per device with a live human connection, with a copy of its focus andidle; catching-up, closing, agent and app connections never count; the read never blocks the hub. - A thread in focus on the desktop, not idle, gives no push to the phone and no hint at all (
TestDesktopFocus_NoPushToPhone). - A message read on the active desktop before
notify.mobile_delay_when_desktop_activeends gives no push to the phone, and an unread one gives exactly one (TestDesktopActive_ReadBeforeDelay_NoPush). - A connected device gets
notification.createdinstead of a push, published once per hint and recipient after the commit bynotify.gateway_send, with only the members of Design note 7, noseq, nopermission_versionand the samenidas the pushes of the hint. - A revocation between decision and publish stops the gateway hint; hints without a thread reach only the recipient's connections with
target_id. - In role
workerthe decision falls back to push without error; the role table is documented in 13 12 point 4 and thenotifyREADME. - 13 4.4, 4.6, 6.3 and 12, 02 4.3, 11.6, 12.3 and 16, RK-37,
openapi.yaml(target_id), the READMEs ofgatewayandnotify, the CHANGELOG and the rowsD-M3-23-1toD-M3-23-8are updated;make codegen-check,make api-checks,make archandmake register-checkare green. -
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.
- README of
gatewayupdated in English (the presence read, which connections count, that presence is never sent to anyone, the role wiring) and README ofnotifyupdated in English (the live routes, the jobnotify.gateway_send, the release check of delayed pushes, the role table, the missing preview fields); existing text stays as it is. - Rulings of Design note 13 recorded in
docs/DECISIONS.md(one row each with reason); rulings 1, 2, 3, 5, 6 and 8 also indocs/spec/13-push-notifications.md. services/server/CHANGELOG.mdentry under 'Unreleased'.- 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.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.