Skip to content

M3-19 Hint jobs, automatic following and bundling

No Date Ruling Reason
D-M3-19-0 2026-10-22 Built on the old integration line and ported to main after its dependencies landed: only the ticket's own commits were brought over. The two spec commits that ported R-AZ-30, R-AZ-31 and device.pending into the old base are dropped, because main already carries them; the additions to the German chapter files of the old base go to their English files on main; the wiring of the push path moves into internal/wire/module_notify.go (PL/28); the changelog entry and these rulings are fragments (PL/24). The rules of the landing replace the integration branches; main is the reference.
D-M3-19-1 2026-10-21 notify may use authz (02 4.4, architecture/modules.yaml, the edge notify --> authz of the component diagram, the dependency line of the notify README). The candidate readers of a channel come from the new port authz.Recipients.Readers: owners (not for DM channels), grants on the instance, the space or the channel to the human or to a valid group of the human, and the participants of a DM channel, ascending; a superset that authorize(r, push.deliver, thread) in filter mode decides exactly, so a candidate the guards exclude gets no hint. Mentioned people and run starters are candidates by name and get the same check. Place: internal/authz/recipients.go, internal/authz/app/recipients.go, internal/notify/app/gather.go. 13 12 point 2 names authz as the source of the candidates; the edge points down (02 4.4 "Ebenen"), and reusing the explanation candidate queries of M1-49 avoids a second rights reading.
D-M3-19-2 2026-10-21 The consumer push reads five event types and ignores every other (the cursor still moves): message.created (dm in a DM channel, else mention to the mentioned people and message to the other readers; never the author; nothing for silent, origin IN ('import','system') and kind IN ('run','system')), run.state_changed (D-M3-19-3), access.expiring (admin to the audience with the grant or group as target), device.pending (security) and read_state.updated (only the badge sync, D-M3-19-15). One hint per person and event, precedence dm, mention, message; only active humans are recipients. The writer of device.pending is insertDevice of identity/app, the one helper through which every device row is inserted (Devices.Register, pairing, login completion, the console web device): it appends one event in the same unit of work exactly when the device is inserted as pending, resource device, audience the device's human, payload only device_id and platform; the consumer maps it to one security hint to that human, routed only to the other active devices, and nobody else is a candidate. Place: internal/notify/app/consumer.go, internal/notify/app/sources.go, internal/identity/app/devicepending.go. 13 2 and 12 point 1 name the sources; R-AZ-31 settles the source of security hints, and every device insert goes through insertDevice, so every pending device gets exactly one event and a veto removes both.
D-M3-19-3 2026-10-21 Run end hints: waiting_input gives input_needed, completed run_done and failed run_failed to runs.triggered_by; at completed, failed and cancelled the other readers of the channel get the run's single message hint, excluding the starter at completed and failed and including the starter at cancelled; queued and running give nothing. All point at the last agent message of the run; a run without agent message gives no message hint, and the starter's kinds then point at the thread only. In a DM channel the run's message hint goes with the kind dm, because a DM channel knows no message hint (13 2). Place: internal/notify/app/sources.go (onRun), 13 2.1. 13 2.1 says "at the end of the run" without naming the end states; 13 2 gives no run kind for cancelled, so the starter is one of the others there.
D-M3-19-4 2026-10-21 An agent message is during an active run when the newest anchor before it (messaging.Messages.LastAnchorBefore) belongs to a run whose last run.state_changed with a smaller log seq than the message (sync.History.ResourceEvents) is queued, running or waiting_input (domain.RunActive) and names the same agent; no anchor, no retained event or another agent: not during a run. The last agent message of a run is the newest message of its agent after the anchor (LastAgentMessageBefore) whose message.created was committed before the end event, stepping back past messages written after the end. Neither read filters by rights. Place: internal/notify/app/runs.go, internal/notify/domain/runstate.go, 13 2.1. 03 stores no run on an agent answer and the consumer may process a message after the run ended; the log order is the commit order (04 3.2) and at most one run is active per thread (R-DM-14).
D-M3-19-5 2026-10-21 push.deliver is decided for a human without a request of that human, at decision time and again at send time, with the re-check principal of R-AZ-29 (actor.Recheck) as runs.Delivery.Prepare builds it: no session, device or token, blocks only from the person's status, owner status from the current owner list; authz accepts that principal for agent.invoke and push.deliver only. For security and admin the fact Allowed is the recipient rule of R-AZ-30: the recipient is the audience of the event and an active human, for security on a registration whose device is active and not the pending device. Place: internal/notify/app/gather.go, internal/notify/app/send.go, internal/authz/app/authorizer.go. 05 section 5 lists push.deliver before each hint decision and each send as a check without a request (R-AZ-31); the ruling adds no right, it is the read right of a session of the same moment.
D-M3-19-6 2026-10-21 Facts are gathered per event and kind for all candidates at once: one call each to the level facts, the quiet-hour rules, sync.Settings.ValuesFor (switches notify.admin_enabled and notify.input_needed_breaks_quiet_hours with the defaults notify.default_admin_enabled and notify.default_input_needed_breaks_quiet_hours), identity.Devices.ActiveForUsers, PushRegistrations.Active (kept only for active devices) and presence.View.Connections; one clock reading per event and kind. A stored switch that is no boolean counts as unset and logs one Warn line without person data. An error of Decide rolls the round back. Place: internal/notify/app/gather.go. 13 12 points 2 to 4 ask for batched reads; a bad fact is a bug, so the round must not commit around it.
D-M3-19-7 2026-10-21 The bundling window lives only in two unique scheduled jobs of (recipient, thread) on the queue delivery: notify.coalesce_window marks an open window, scheduled at its end, and does nothing; notify.coalesce_flush exists exactly when a message hint was collected, scheduled at the same end. platform/jobs gains InsertUniqueTx (insert unless a job of the same kind with equal args is not finalized, else return its ScheduledAt), PendingTx (read that job) and LatestPendingTx (the latest such job compared on named fields only), all only inside a unit of work. The marker carries its window end in its args beyond the brief's two IDs (unique per recipient, thread and end), because River fetches the marker and the flush of the same end together and may still run the old marker when the flush opens the next window; the window end is the ScheduledAt of the latest pending marker of (recipient, thread) when it is after now; a send that opens a window inserts the marker of the new end; a collect inserts the flush, a duplicate means the window already collected. Place: internal/platform/jobs/unique.go, internal/notify/adapters/jobs/jobs.go, internal/notify/app/window.go, 13 4.5. 13 4.5 asks for one unique scheduled job per (recipient, thread) [13 Q1]; no own window table is needed, and River stays inside platform/jobs.
D-M3-19-8 2026-10-21 The flush runs at the window end in one unit of work: the facts of the person for the thread, the unread count threads.last_message_seq - max(read_states.last_read_seq, threads.unread_floor_seq) (absent row 0) and domain.Flush; a send carries Count = the unread count, points at the newest message of the thread (messaging.Messages.Newest, no message ID if none), has t0 = the window end and opens the next window; nothing unread gives read. Place: internal/notify/app/flush.go, 13 4.5. 03 "Unread" is the one formula of the unread count; the hint replaces the earlier one on the device, so it points at the newest message.
D-M3-19-9 2026-10-21 The live connections come through the new kernel port presence.View (Connections per person and device with focus and idle) with presence.None, which reports no connection and which the push path falls back to until M3-23, because internal/wire passes no view. The routes gateway and push_delayed go to the outgoing port LiveRoutes, whose default FailClosedRoutes returns an internal error, so the round rolls back; with presence.None no decision yields those routes. Place: internal/kernel/presence/presence.go, internal/notify/app/consumer.go, 02 4.5, 13 12 point 4. notify and gateway are on the same top layer of 02 4.4, so neither may import the other; failing closed loses no hint before M3-23 builds the routes.
D-M3-19-10 2026-10-21 The job notify.push_send (queue delivery): arguments only IDs and int64, the kind as domain.KindCode (1 to 8 in the order of NotificationKind, 9 for the badge sync, never renumbered), one NotificationID (the nid, UUIDv7) per hint and recipient shared by the jobs of its registrations, t0 of 13 9 as DueAt. Step 1 in a unit of work: the registration (gone, deactivated or on a device no longer active: no_registration), the age (push.max_age: expired), the rights of now (no_access), the provider (no_provider) and the badge; step 2 outside any transaction under a process-wide semaphore of push.max_concurrent_requests: PushProvider.Send; step 3 in a unit of work: the result class on the row locked FOR UPDATE and, for a retry, the same args with the next attempt at domain.RetryDelay. Delivery is at least once with one nid. Place: internal/notify/app/send.go, internal/notify/domain/codes.go, 13 7.3 and 12 point 5. A provider call cannot join a transaction (02 8.5); a crash between steps 2 and 3 sends again with the same nid, and the app replaces by thread (13 6.1, 5.2).
D-M3-19-11 2026-10-21 The provider port PushProvider (Name, Send(ctx, Registration, Notification)) is an outgoing port of notify/app that adapters/apns (M3-20), adapters/fcm (M3-21) and adapters/fakepush implement; Notification holds only IDs, the kind, the quiet flag, the count and the badge (Badge, HasBadge), and InstanceID comes from internal/wire. A Go error of Send counts as a transient failure. internal/wire builds the provider set, one per Name, and registers none in production in this ticket; a registration without a provider ends its job with no_provider and without error. Place: internal/notify/app/ports.go, 13 5.4 and 12 point 5. These are the names M3-20 and M3-21 use; a missing provider must not retry or fail a job forever.
D-M3-19-12 2026-10-21 Result classes and domain.ApplyResult: accepted sets last_success_at and resets failure_count; disable deactivates with token_invalid, topic_mismatch or sender_mismatch, unchanged when the provider names the time since which the token is invalid and the row was created after it (13 7.1 row 410); retry raises failure_count and deactivates with repeated_failures at push.max_consecutive_failures; throttled changes nothing and retries without counting; config_error and program_error change nothing and drop the hint. RetryDelay is min(push.retry_cap, push.retry_base * 2^(attempt-1)) times a jitter in [0.5, 1], at least Retry-After; a retry past t0 plus push.max_age is dropped as expired. Place: internal/notify/domain/result.go, 13 7.3. throttled limits the server, not the token; a newer registration of the same token must survive an old 410.
D-M3-19-13 2026-10-21 bothy.push.skipped counts one per recipient and hint decided none and one per job that ends without sending, with bothy.reason no_access, preference, active_in_thread, no_registration, read, expired and no_provider; not_recipient and nothing_collected are not counted, and quiet_hours is removed from signals.md 4.4. The reads of the consumer and its jobs have no visibility predicate by design; where the SQL visibility lint flags them they get exception entries with category log_tail (the sync history reads) or system_job (the messaging reads and the read position reads of sync), each with its reason, and no existing entry is widened. Place: internal/notify/app/gather.go, internal/platform/otel/metrics/catalogue.go, docs/observability/signals.md 4.4, tools/lint/sql_visibility.yaml. Quiet hours make a hint quiet and never drop it (R-PN-05), so that value could never occur; every hint is checked with push.deliver, so the reads need no predicate (07 7.4 rule 1).
D-M3-19-14 2026-10-21 Automatic following: Subscribers.OnMessagePosted of notify/app, an in-transaction subscriber of messaging.MessagePosted registered after those of runs and sync, returns unless Origin = human and ThreadKind = thread, then inserts the author's row with level all for the thread through insertPreferenceIfAbsent (INSERT ... ON CONFLICT DO NOTHING); only an inserted row appends notification_pref.updated with version 1. upsertPreference and insertPreferenceIfAbsent are the only two write paths of notification_preferences. Place: internal/notify/app/follow.go, internal/notify/app/preferences.go, 13 3.2. An upsert would overwrite an own mute or mentions, which 13 3.2 forbids ("no own setting for this thread yet"); a veto later in the unit removes row and event, and a different answer to OQ-05 changes only this function.
D-M3-19-15 2026-10-21 Badge: BadgeCounter.For counts the threads the person can read (visible channels through authz Scope for push.deliver, each counted thread confirmed with authorize in filter mode) that are unread and hold an unread mention, have the effective level all or lie in a DM channel, from chat.Threads.UnreadCandidates, sync.ReadPositions.PositionsOfUser, messaging.Mentions.CountUnread and Facts.LevelFactsOfUser; notify.push_send sets it on every Notification. Badge sync: a read_state.updated lowers the number when its thread counts at the previous position (the newest earlier read_state.updated of the person for that thread, else 0) and not at the new one; it schedules notify.badge_sync (UserID, AfterSeq and its time at, unique per person and time), unless a sync that is not yet due is pending (it carries the read), at the end of a pending notify.badge_sync_hold or now; when the pending sync is already due it may run and have read its events, so the follow-up goes no earlier than now plus notify.badge_sync_min_interval, and a sync that finds a running hold of an earlier sync moves itself to the hold end. The sync job inserts the hold at now plus notify.badge_sync_min_interval (args UserID and the end, unique per end, so a hold that River still runs at the same moment never stops the next one; HoldEnd reads the latest pending hold) and one notify.push_send of kind KindSync (sync) with a new nid per active apns or fcm registration on an active ios or android device, carrying the badge and the complete Cleared list of threads read since AfterSeq that no longer count. Place: internal/notify/app/badge.go, internal/notify/adapters/jobs/jobs.go, 13 8. 13 8 asks for a badge over visible threads and at most one sync per person per interval to mobile devices; Cleared is never a prefix, M3-21 leaves it out when it does not fit (D-M3-21-6).
D-M3-19-16 2026-10-21 Stacked build: the branch merges feat/M2-08-invocation-delivery and feat/M2-09-run-lifecycle before the ticket commits. Place: this branch. Steering of the build: the consumer reads run.state_changed and the re-check principal of M2-08, which the base lacked.
D-M3-19-17 2026-10-21 The core rules R-AZ-30 and R-AZ-31 and the event device.pending were ported from main into 05, actions.yaml, openapi.yaml and asyncapi.yaml as their own commits. The resource of notification.created stays thread on this branch. Place: docs/spec/05-actions.md, docs/spec/actions.yaml, docs/api/openapi.yaml, docs/api/asyncapi.yaml. The brief relies on these rules; the catalogue generator of this branch accepts only resource kinds, and notification.created without a thread is M3-23.
D-M3-19-18 2026-10-21 The field of the arguments PushSend is KindCode, not Kind (JSON name kind). Place: internal/notify/adapters/jobs/jobs.go. Kind is the method of jobs.Args.
D-M3-19-19 2026-10-21 CoalesceFlush carries the window end (EndUnixMicro) besides UserID and ThreadID, so the flush of the next window is a different job while the previous flush still runs, and t0 of the flush hint is the window end without another read. CoalesceWindow and BadgeSync compare only their fields tagged river:"unique" (CoalesceWindow tags both of its fields). Place: internal/notify/adapters/jobs/jobs.go. Without the end in the args a message collected while the previous flush runs would find that flush and be lost.
D-M3-19-20 2026-10-21 messaging.AnchorRef gains RunID (additive; the existing type of CreateRunAnchor). Place: internal/messaging/messaging.go. LastAnchorBefore and AnchorOf must name the run of the anchor.
D-M3-19-21 2026-10-21 The span push.send is opened by a wrapper of the jobs adapter around each provider (adapters/jobs.Traced), so notify/app stays free of OpenTelemetry; the counters bothy.push.sent and bothy.push.skipped are recorded after the commit of step 3. Place: internal/notify/adapters/jobs/jobs.go, internal/notify/app/send.go. app packages use no platform code (02 4.4); a retried unit of work must not count twice.
D-M3-19-22 2026-10-21 The brief's "merge locally" and "pull request" steps are replaced by the build steering: the branch is verified on GitHub through build/M3-19 and integrated by the integration lane. Place: this branch. The steering of the build names the verification and integration lane.
D-M3-19-23 2026-10-21 Failure class of the red team findings on the device.pending port: a durable event type entered docs/spec/actions.yaml while a hand-kept list of the event types stayed behind (KNOWN_EVENT_TYPES of the TypeScript SDK, the appliers and skippedDurable of packages/sync_engine, the skipped list of its README). Each list had only a test of its own component, which a change of another component does not run. One check now reads all of them against the catalogue: tools/catalog/event_copies.py, run by make codegen-check in its Python part, which runs for every component selection; its tests remove every durable type from every copy and add a type to the catalogue only. Generated copies keep their generator gates (api-checks, api-clients, codegen-check). Place: tools/catalog/event_copies.py, Makefile codegen-check. Owner rule of 2026-10-17 on a repeated finding class: fix the class at one place with a test, not the next instance. The check is red on both commits the red teams judged (d0fabc97c, 39a00730b) and green on the fixed head.