Skip to content

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" (rows security and admin); 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 event notification.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; 17 notify.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" (no seq, no permission_version on ephemeral events, audience); 6.1 "Endpoint and version" (a device holds exactly one connection); 6.2 "Ops" (row focus with idle); 6.3 "Lifecycle" (when a connection becomes live); 7.2 "Flow" point 6 (no ephemeral event before catchup.complete, dropped at a full queue, never 4014); 9 "Delivery guarantee" point 4 (at most once); 10 "Ordering" (row "ephemeral").
  • docs/spec/05-actions.md (core contract, read only): 4.9 row push.deliver ("before every push, every notification via the gateway"); 5 "Agent invocation agent.invoke", the paragraph "Check without a request" (the list item push.deliver before each hint decision and each push send, and the recipient rule of R-AZ-30); section 6, table of ephemeral events, row notification.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_seq and the formula "Unread" with threads.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 role api, the consumer push and job execution in role worker, all unites both); 4.3 (row gateway), 4.4 ("Tiers": notify and gateway on the same top layer), 4.5 (rows kernel/eventlog and kernel/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 names notification.created); 12.2 (queue delivery, maintenance mode); 12.3 row notify.push_send; 12.4; 16 "Scaling path" (levels 0 to 2).
  • docs/api/openapi.yaml: schemas NotificationCreatedEvent, NotificationCreatedPayload, NotificationKind, EventEnvelope, EventResource, EventScope, GatewayFocus (idle); operations putReadState, putNotificationPreference, createMessage, createThread. docs/api/asyncapi.yaml: message event.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.4 bothy.push.skipped (the reasons as M3-19 rewrote them: active_in_thread, read, no_access, expired, no_registration), row bothy.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 JSON null).
  • 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 rows D-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 of notification.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/domain with Decide, 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 use fakepush.FakeProvider of 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, the idle flag "no other code reads idle in M1 (M3-23 does)", EphemeralEvent, EphemeralPublisher, the publisher rules, focus pruning, resume), M1-65-gateway-delivery.md (hub indices, Hub.Register, sync.Admit as 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, LiveRoutes with Gateway and Delayed, the Hint value, the args PushSend, 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 and unread_floor_seq), M1-68-core-metrics.md (bothy.delivery.duration on the ephemeral path), M2-10-run-document-live-box.md Design 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/ and adapters/ 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):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. 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).
  4. 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.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. 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.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/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:

  1. Only the server decides about hints, also for desktop clients; a desktop or any other device with a live gateway connection gets notification.created over the gateway instead of a push to that device (13 1 point 4, 4.6 point 1, 6.3, R-PN-01).
  2. "Active in the thread": a device of the recipient with a gateway connection whose focus contains the thread and that does not report idle means no hint for that thread on any device (13 4.4; 04 6.2 row focus). The decision itself stays the pure function of M3-17; this ticket supplies the facts.
  3. "Desktop active": a device with platform IN ('macos','windows') that is connected and not idle delays pushes to mobile devices by notify.mobile_delay_when_desktop_active; a delayed push is dropped when the read position of the recipient (read_states.last_read_seq, at least threads.unread_floor_seq) has reached the thread sequence of the message (13 4.4, 03 read_states, M3-17 ReleaseDelayed).
  4. notification.created is ephemeral: no seq, no permission_version, never stored, at most once, never before catchup.complete, dropped when the outbound queue is full and never a reason for 4014 (04 2.1, 2.2, 7.2 point 6, 9 point 4).
  5. It goes only to connections of the recipient (audience = self) and passes push.deliver on 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 row notification.created, 4.9 row push.deliver, R-AZ-29, R-AZ-30, R-AZ-31).
  6. Its payload carries only nid, kind, thread_id, message_id, quiet, count and, 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).
  7. 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).
  8. 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 push stays 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 of presence.View over the hub (Design note 2), and NewPresenceView(h *Hub) *PresenceView.
  • internal/notify/app/liveroutes.go: liveRoutes, the implementation of the outgoing port LiveRoutes of M3-19 (Design notes 3 and 5).
  • internal/notify/app/gatewaysend.go: the use case of the job notify.gateway_send (Design notes 4, 6, 7, 8).
  • internal/notify/adapters/jobs/gatewaysend.go: args GatewaySend, kind notify.gateway_send, its handler (Design note 4).

Change:

  • internal/gateway/app (hub): an additive read Hub.Presence(userIDs []ids.ID) map[ids.ID][]presence.Connection that 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 the FocusSet with its idle flag (M1-66 stored it and added no accessor; this ticket is its user).
  • internal/sync (Admit, M1-65): the case of a notification.created without a thread (Design note 6), with a unit test.
  • internal/notify/app: the Hint value of M3-19 gets MessageSeq int64 if it has no field for the thread_seq of the message the hint points at, filled where M3-19 builds hints (Design note 5); the send use case of notify.push_send gets the release check of Design note 5; the one routing function of M3-19 hands every gateway and push_delayed route of a consumer send and of a flush send to LiveRoutes (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 args PushSend get ReleaseSeq int64 (Design note 5); TestNotificationCarriesNoText of M3-19 also walks GatewaySend.
  • internal/wire: role wiring of Design note 1 (PresenceView and liveRoutes in role all; presence.None and M3-19's fail-closed default LiveRoutes in role worker); the job kind notify.gateway_send on queue delivery in every role that executes jobs (worker, all); the kernel/eventlog.EphemeralPublisher of the gateway for the gateway send use case in role all and none in role worker; regenerate the jobs golden file if one exists.
  • docs/api/openapi.yaml: NotificationCreatedPayload gets the optional property target_id and 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.md 4.4, 4.6, 6.3, 12 point 4; docs/spec/02-architecture.md 4.3 row gateway, 11.6, 12.3, 16; docs/RISKS.md row RK-37 (one sentence in the column "Mitigation"); services/server/internal/gateway/README.md and services/server/internal/notify/README.md; services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M3-23-1 to D-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 consumer push, candidates, facts, bundling windows, the job notify.push_send with its steps, result classes and skip reasons, presence.View, presence.None and the fail-closed default LiveRoutes, the badge of 13 8 and the source of security hints (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 to notification.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.created across processes: with server.role = worker beside a separate api, and with cluster.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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (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.FakeProvider of 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

  1. Place, dependencies and roles (02 3.2, 4.4, 4.5; 13 12 point 4; RK-37; Ruling for docs/DECISIONS.md). notify and gateway sit on the same top layer of 02 4.4, so neither imports the other: the gateway implements the kernel port presence.View (as it implements eventlog.EphemeralPublisher), and notify uses only kernel ports and its existing edges (chat, sync, identity). No module edge changes; make arch and make register-check stay green without a change to architecture/modules.yaml. The gateway lives in role api, the consumer push and job execution in role worker (02 3.2), so internal/wire wires by role:

    server.role presence.View LiveRoutes publisher for notify.gateway_send
    all gateway/app.PresenceView notify/app.liveRoutes the gateway's EphemeralPublisher
    worker presence.None M3-19's fail-closed default none
    api not wired (no consumer push) not wired not wired (no job execution)

    With None the decision never yields gateway or push_delayed (M3-19 TestLiveRoutes_DefaultFailsClosed), so in role worker every 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 role all holds 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, attribute bothy.server.role). With cluster.mode = multi and role all the view sees the connections of its own node only (RK-37). Write the role table into the notify README and 13 12 point 4.

  2. 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, one presence.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 phase live (after catchup.complete, 04 7.2 point 4) and it is not closing. Reason for live: notification.created is never delivered before catchup.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). Focus is a copy of the connection's FocusSet (the readable threads M1-66 kept after its checks and pruning), Idle the stored flag; a connection that never sent focus has an empty focus and Idle = false, so a connected desktop that never reported idle counts as active (13 4.4 "connected and not idle"). Because a device holds exactly one connection (04 6.1, 4017 closes 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 cancelled ctx. It reads no database. Agent and app connections are never returned.

  3. LiveRoutes (M3-19 Design note 10; 13 4.6; Ruling for docs/DECISIONS.md). notify/app.liveRoutes implements 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): insert GatewaySend for the hint with jobs.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, because Decide gives gateway to every device with a connection (M3-17 Design note 8).

    b. Delayed(ctx, h Hint, r domain.Route): insert one PushSend per ID in r.Registrations with InsertTx, ScheduledAt = r.NotBefore, DueAtUnixMicro = r.NotBefore (13 9 row t0: "with ... desktop delay the end of the waiting time") and ReleaseSeq = h.MessageSeq; every other field as M3-19 fills it for an immediate push of the same hint (the same NotificationID, kind code, quiet flag and count; M3-17 Design note 6 keeps the quiet flag).

  4. Job notify.gateway_send (02 12.1 to 12.4; 13 6.3, 9; 05 4.9 row push.deliver, R-AZ-29 to R-AZ-31; Ruling for docs/DECISIONS.md). Module notify, trigger the consumer push (and the flush job), queue delivery (it is part of push, so it keeps running in maintenance mode like notify.push_send, 02 12.2). Args, only IDs and int64 (jobs.ValidateArgsType): GatewaySend{NotificationID, RecipientID, ThreadID, MessageID, TargetID ids.ID; Kind, Quiet, Count, DueAtUnixMicro int64} with the same meaning and kind codes as PushSend of M3-19 Design note 11 a. The handler:

    a. Step 1 in a unit of work: now - DueAt > push.max_age: skip expired. The recipient is no longer an active human: skip no_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 skip no_access. security or admin: the recipient rule of R-AZ-30 through the same function the send step of M3-19 uses for these kinds: false gives skip no_access. A kind with a thread also reads chat.Threads.Info(ThreadID) for the current space_id and channel_id of 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), skip no_registration and log one Warn line per process (gateway hint without a gateway in this process); this happens only in a deployment that mixes role all with separate workers, which 02 16 does not describe. Otherwise build the envelope of Design note 6 and call PublishEphemeral once; 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 nid reaches 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.skipped with its reason (the values M3-19 defined); a publish counts nothing new (the gateway records bothy.delivery.duration with bothy.event.durability = ephemeral when 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).

  5. 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.MessageSeq is the thread_seq of the message the hint points at: the message.thread_seq of the message.created payload, the ThreadSeq of the last agent message of a run (M3-19 Design note 5 b), the ThreadSeq of 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.ReleaseSeq is zero for every immediate push and Hint.MessageSeq for a delayed one. In the send use case of notify.push_send, after the registration and age checks and the push.deliver (or R-AZ-30) check of M3-19 Design note 11 b and before the provider lookup: if ReleaseSeq > 0, read the recipient's read position readSeq = max(read_states.last_read_seq, threads.unread_floor_seq) (sync.ReadStates.PositionsFor with an absent row read as 0, and chat.Threads.InfoForShare for UnreadFloorSeq, both of M3-19 Design note 9 d), and when domain.ReleaseDelayed(ReleaseSeq, readSeq) is false skip read. 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.

  6. Envelope and admission (04 2.2; 05 section 6 row notification.created; R-AZ-30; M1-65 sync.Admit; Ruling for docs/DECISIONS.md). EphemeralEvent of M1-66: Type notification.created, SchemaVersion from the event register, no Actor (a job without principal), Audience the 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}, empty Scope. Envelope id a new UUIDv7, occurred_at the clock at publish. The hub's candidates are the connections of the audience (M1-65). sync.Admit stays the single admission rule: a notification.created with resource thread passes the generic path (audience equals the connection principal, then push.deliver on the thread in authorize.Delivery(ctx)), so a revocation that arrived in log order before the frame stops it (T-AZ-03); a notification.created with resource user is admitted exactly when the connection principal equals Audience and resource.id, without push.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 resource user of a hint without a thread is the core contract's (05 section 6 row notification.created: "for a hint without a thread the recipient (user)"; R-AZ-30; D-W-108 point 8).

  7. 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_id and message_id when set, quiet, count, and the new optional target_id for security (the waiting device) and admin (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, never null. Add to docs/api/openapi.yaml: target_id ($ref: '#/components/schemas/Uuid', description "Target of a hint without a thread: the waiting device for security, the grant or group of the reminder for admin (R-AZ-30)."); the descriptions of thread_id and message_id ("absent for hints without a thread", "absent when the hint points at no message") and of count ("1, or the unread count of a bundled hint (13 4.5)"). The asyncapi message references the schema and needs no change; make codegen-check regenerates the server and client types.

  8. 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 idle or focus: presence changes the route, never the right.

  9. 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 in notification.created with 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 to notification.created; until then they are absent and the client shows the hint from its synced data.

  10. 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 new bothy.reason value. Logs carry only bothy.job.kind, bothy.reason, bothy.server.role and error.type, never a person, device, thread, focus set or nid.

  11. 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.md 4.4: after the first bullet, a connection counts from catchup.complete on and a connection that never sent focus is not idle (Design note 2); after the second bullet, the delayed push is a notify.push_send scheduled 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 job notify.gateway_send publishes 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 member target_id, and the sentence of Design note 9. 12 point 4: presence.View is implemented by the gateway and wired only in role all; the role table of Design note 1.
    • docs/spec/02-architecture.md: 4.3 row gateway, column "Implements sources": add kernel presence.View; 11.6: a row notification.created | notify (job notify.gateway_send in the process of the gateway) | directly to the hub | until M7-17 none, then NOTIFY ephemeral with IDs; 12.3: a row notify.gateway_send | notify | consumer push (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 role all until M7-17.
    • docs/RISKS.md row RK-37, column "Mitigation": "Until M7-17 presence is wired only in role all; separate roles and several api nodes deliver hints by push (13 12 point 4)."
  12. 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 category system_job and a reason; never widen an existing entry. This ticket adds no SQL of its own.

  13. Rulings for docs/DECISIONS.md (section 10, one row each with date, decision, reason and place): D-M3-23-1 role wiring of presence and live routes, local node only, the limitation for separate roles and several nodes (note 1); D-M3-23-2 which connections count (human, device, phase live, not closing; no focus means not idle) (note 2); D-M3-23-3 gateway hints as one unique job notify.gateway_send per 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-4 the checks of step 1 and their order, skip reasons, at least once with one nid (notes 4 and 8); D-M3-23-5 the delayed push as notify.push_send with ReleaseSeq, DueAt at the end of the delay, nothing else re-evaluated (notes 3 b and 5); D-M3-23-6 the self-only admission of hints without a thread, whose envelope resource user 05 section 6 and R-AZ-30 give (note 6); D-M3-23-7 the payload member target_id (note 7); D-M3-23-8 no 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 phase catching_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 and idle equal the last accepted focus op; a later focus op does not change a returned slice; a connection that never sent focus has an empty focus and Idle = false; focus pruned by M1-66 (unreadable after access.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, Connections returns at once; with a cancelled context it returns the context error.

Unit, internal/sync:

  • TestAdmit_NotificationCreatedWithoutThreadSelfOnly: resource user with audience Alex is admitted for Alex's connection and refused for Sam's and for an agent connection; no authorize call happens for it (counting fake); resource thread still runs push.deliver in Delivery(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 one GatewaySend; another hint inserts a second one; the args hold only IDs and int64.
  • TestLiveRoutes_DelayedOneJobPerRegistration: a push_delayed route with two registrations inserts two PushSend with ScheduledAt = NotBefore, DueAtUnixMicro = NotBefore, ReleaseSeq = MessageSeq, the hint's NotificationID and quiet flag.
  • TestGatewaySend_Steps: too old gives expired; an inactive recipient and a denied push.deliver give no_access; admin and security use the R-AZ-30 function and never call push.deliver; no publisher gives no_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 increments bothy.push.skipped with its reason.
  • TestGatewaySend_Envelope: thread hint: resource thread, scope from the current Threads.Info (a moved thread gives the new channel), audience Alex, no actor, no seq, no permission_version; admin hint: resource user with Alex's ID, empty scope, payload target_id = the grant ID, no thread_id; the encoded payload decodes strictly into the generated NotificationCreatedPayload and has no member outside Design note 7 and no null.
  • TestPushSend_ReleaseCheck: ReleaseSeq 5 with read position 4 sends; with last_read_seq 5 skips read; with last_read_seq 2 and unread_floor_seq 6 skips read; ReleaseSeq 0 sends without reading the read position (counting fakes); a delayed push whose recipient lost access gives no_access, not read.
  • TestHintMessageSeq: a message.created hint carries the payload's thread_seq; a run_done hint the ThreadSeq of the last agent message; an admin hint zero.
  • TestNotificationCarriesNoText (M3-19, extended): walks GatewaySend and the changed PushSend with reflect; jobs.ValidateArgsType accepts 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 sends focus {thread_ids: [T], idle: false}; the Owner writes in T; after the round: no notify.push_send and no notify.gateway_send for Alex, no window job of (Alex, T), bothy.push.skipped with active_in_thread rose by one; the FakeProvider saw no call. Variants: the same with the phone live and focusing T (no hint either); the desktop sends focus {thread_ids: [T], idle: true}: the round inserts one notify.gateway_send and one immediate notify.push_send for the phone (an idle desktop delays nothing), and running the gateway job delivers notification.created to 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 one notify.gateway_send and one notify.push_send for the phone scheduled at the fake clock plus notify.mobile_delay_when_desktop_active with ReleaseSeq = the thread_seq of m. Running the gateway job delivers to the desktop exactly one dispatch with notification.created: nid equal to the push job's NotificationID, kind message, thread_id T, message_id m, quiet false, count 1, no seq, no permission_version. Alex then marks T read up to m through putReadState (as the desktop would); the clock passes the delay; the push job ends without a provider call and bothy.push.skipped with read rose by one. Variants: no read: exactly one provider call with the nid; read only up to an earlier message: one provider call; notify.mobile_delay_when_desktop_active zero 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 gets notification.created, the phone's push is delayed (the laptop is windows, 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 no notify.gateway_send is inserted.
  • TestGatewayHint_RevocationBeforePublish (13 14 row "Revocation between event and sending" for the gateway route): Sam follows T with all and has a live desktop of her own; the round inserts her notify.gateway_send; the Owner removes Sam from group Leser; running the job publishes nothing and counts no_access. Variant: the job publishes, and the hub refuses the frame because the tail applied the access.changed first (the publish is driven after the tail's Observe): Sam's connection receives no notification.created.
  • TestGatewayHint_WithoutThread: an access.expiring appended through the sync appender to Alex (M3-19 test pattern): Alex's live desktop receives one notification.created with kind admin, target_id = the grant, no thread_id, resource user; Sam's live connection receives nothing. A pending device registered for Alex through the identity test helper (which writes device.pending, R-AZ-31, built by M3-19) gives Alex's live desktop one notification.created with kind security and target_id = the pending device.
  • TestFlushHintOverGateway: ten messages of the Owner in T within one notify.coalesce_window while 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 with count equal to the unread count and a second delayed phone push; marking T read before the flush leaves no flush hint (M3-19 rule read).
  • TestRoleWorker_PushOnly (Design note 1): two server instances in one test binary, one in role api with Alex's desktop live and focusing T, one in role worker driving the round: the phone gets an immediate push, no notify.gateway_send exists, and the worker logged the Info line of Design note 1 once.
  • internal/wire: role all wires PresenceView and liveRoutes, role worker wires presence.None and the default; the job kind notify.gateway_send is registered on queue delivery in roles worker and all; TestLiveRoutes_DefaultFailsClosed of M3-19 still passes; make register-check passes without a change to architecture/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-check
  • make diagrams (only if a diagram source changed), make docs, make linkcheck
  • make check

Acceptance criteria

  • The gateway implements presence.View: one entry per device with a live human connection, with a copy of its focus and idle; 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_active ends gives no push to the phone, and an unread one gives exactly one (TestDesktopActive_ReadBeforeDelay_NoPush).
  • A connected device gets notification.created instead of a push, published once per hint and recipient after the commit by notify.gateway_send, with only the members of Design note 7, no seq, no permission_version and the same nid as 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 worker the decision falls back to push without error; the role table is documented in 13 12 point 4 and the notify README.
  • 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 of gateway and notify, the CHANGELOG and the rows D-M3-23-1 to D-M3-23-8 are updated; make codegen-check, make api-checks, make arch and make register-check are green.
  • make check is 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 gateway updated in English (the presence read, which connections count, that presence is never sent to anyone, the role wiring) and README of notify updated in English (the live routes, the job notify.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 in docs/spec/13-push-notifications.md.
  • services/server/CHANGELOG.md entry 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 check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. 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.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. 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>.