M3-19: Hint jobs, automatic following and bundling¶
Status: ready | Depends on: M1-38, M2-01, M2-09, M3-18 | Milestone: M3
Goal¶
After this ticket hints arise in the worker, never in the write path: the worker consumer push (frame of M2-01, ADR 0051, 13 12 point 1) reads message.created, run.state_changed, access.expiring, device.pending and read_state.updated from the event log in seq order, determines the candidates of each event (readers of the channel through a new authz port, mentioned people, participants of a DM channel, the person who started a run, the audience of a reminder, the owner of a pending device), gathers the facts of each candidate in batches (the level facts and quiet-hour rules of M3-18, the personal switches, active devices, active push registrations, live connections through a presence port that M3-23 fills, the bundling window) and lets the pure decision of M3-17 (Decide) choose: no hint with a skip reason, collect into the open window, or send now. A send inserts one job notify.push_send per active push registration of the device (13 12 point 5) in the same transaction that moves the cursor, so a worker restart in the middle of a round neither doubles nor loses a hint. Agent messages written during an active run give no message hint; the end of the run gives exactly one hint per person: run_done (or run_failed) to the person who started it and message to the others with level all, both pointing at the last agent message of the run (13 2.1). A mention of a person without read right gives nothing and leaves no trace in jobs or logs (AZ-M48). Bundling per recipient and thread (13 4.5) keeps one unique scheduled River job per window: ten messages in one window give at most two hints, the second one with the unread count. The job notify.push_send checks push.deliver again with the rights of that moment (04 7.6), drops hints older than push.max_age, sends through a provider port PushProvider that M3-20 (APNs) and M3-21 (FCM) implement, applies the result classes of 13 7.3 to the registration and ends with a skip reason when no provider is registered for the registration. The ticket creates the table push_registrations exactly as 03 with a read port of the active registrations (D-W-108), and it builds automatic following (13 3.2): a person who writes in a thread, not in the main thread, follows it with level all in the transaction of the message, unless an own setting for that thread exists. It builds the source of security hints (R-AZ-31): identity writes the durable personal event device.pending in the transaction that registers a device with state pending, and the consumer maps it to one security hint to the other active devices of that human. Every push carries the badge number of 13 8, computed per recipient at send time, and a read that lowers the number gives at most one badge sync per person per notify.badge_sync_min_interval to the person's mobile registrations (D-W-108).
Read first¶
docs/spec/13-push-notifications.md: 1 "Goals"; 2 "Kinds of notifications and recipients" (the table and "No notification is created for"), 2.1 "Agent messages during a run", 2.2 "Report channels"; 3.2 "Automatic following"; 3.3 "Personal switches"; 4 "Decision per recipient" (4.1 to 4.6); 5.4 "Later" (the portPushProvider); 6.1 and 6.2 (which IDs a push carries; the payload itself is M3-20 and M3-21); 7 "Error handling" (7.1 to 7.3); 8 "Badge and sync" (the badge number and the badge sync, Design note 16); 9 "Delivery measurement" (row t0); 12 "Execution in the server" (all five points); 14 "Tests" (rows "Revocation between event and sending", "Mention of a person without read permission", "Ten messages in one thread within one window", "Run with three agent messages", "Automatic following"); 15 R-PN-02, R-PN-04, R-PN-06, R-PN-09; 17 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only):push_registrations(all columns, checks, unique keys, indexes, retentionsecurity);notification_preferences(PK, needed for following);event_log(columns, the index(resource_kind, resource_id));event_consumer_cursors;messages(origin,kind,silent,run_idonly forkind = 'run',thread_seq);runs(triggered_by,state, the partial unique index "at most one active run per thread", R-DM-14);read_statesandthreads(last_message_seq,unread_floor_seq, the formula "Unread");mentions;devices(platform,state); theevent_logindex(audience_principal_id, seq); 6.3 "Lock order"; 9push.max_consecutive_failures; R-DM-27.docs/spec/04-sync-protocol.md(core contract, read only): 3.2 "Pattern with a counter row" (a reader that seesseq = ksees every smaller number: commit order is the log order); 7.6 "Consumers in the worker (push, webhooks)"; 10 "Ordering".docs/spec/05-actions.md(core contract, read only): 4.9 rowpush.deliver; 5 "Check without a request" and R-AZ-29 (Design note 7); 6 rowsmessage.created,run.state_changed,access.expiring,device.pending,read_state.updated,notification_pref.updated,notification.created; R-AZ-11; R-AZ-30; R-AZ-31.docs/spec/07-permissions.md: 7.4 "Query pattern" rule 1 (exception list for system reads); 9.4 "Expiry reminder" (who receivesaccess.expiring); 10 and 11.1 (the test world); AZ-M48 and AZ-P5 in 11.docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowsnotify,authz,messaging,sync,identity,gateway); 4.4 "Allowed dependencies" (rownotify, "Tiers"); 4.5 "Cross-cutting ports (internal/kernel)"; 5 "Layers within a module"; 8.1 to 8.5; 11.5 "Worker consumers (push, webhooks)"; 12.1 to 12.4 (job arguments only IDs, queuedelivery, maintenance mode, job catalogue rownotify.push_send, rules for jobs); 13 item 5.docs/spec/09-agents.md5.4 "Live box content" (the anchor message is the only message withrun_id, silent) and 3.2 "Origin (messages.origin)".docs/api/openapi.yaml(read only): schemasNotificationKind,MessagePayload,Message(thread_seq,origin,kind,silent,mention_ids,run_id),RunStateChangedPayload,RunSummary(triggered_by,agent_id,thread_id,channel_id,state),RunState,AccessExpiringPayload,DevicePendingEvent,DevicePendingPayload,DevicePlatform,ReadStatePayload,ReadState,PushRegistration,PushProvider,PushEnvironment,NotificationCreatedPayload.docs/api/asyncapi.yaml: messagesevent.message.created,event.run.state_changed,event.access.expiring,event.device.pending,event.read_state.updated.docs/operations/configuration.md:notify.coalesce_window,notify.mobile_delay_when_desktop_active,notify.user_default_level,notify.default_admin_enabled,notify.default_input_needed_breaks_quiet_hours,notify.badge_sync_min_interval,push.max_age,push.retry_base,push.retry_cap,push.max_concurrent_requests,push.max_consecutive_failures,consumer.batch_size,consumer.poll_interval.docs/observability/signals.md: 4.4 rowsbothy.push.sent,bothy.push.send.duration,bothy.push.skipped,bothy.push.registrations.active; 5 spanpush.send; 5.3 attributesbothy.push.provider,bothy.outcome,bothy.reason,bothy.job.kind; 7 "Logs".- ADRs 0008 (hexagonal modules), 0017 (one
authorize), 0031 (push without content), 0049 (action catalogue), 0050 (module register), 0051 (worker consumers of the event log). docs/DECISIONS.md: D-PN-01 to D-PN-04, D-PN-07, D-W-100, D-W-104 (R-AZ-25 to R-AZ-29), D-W-108 (this ticket's table, read port and provider port; R-AZ-30; point 6: R-AZ-31, the writer and mapping ofdevice.pending, the badge and the badge sync, M7-06 for privacy deadline reminders), section 10 "Rulings of the build milestones" (format of the rowsD-M3-19-<n>).docs/OPEN-QUESTIONS.mdOQ-05 (automatic following and quiet hours, default "yes, as in chapter 13", due before this ticket).docs/RISKS.mdRK-37 (presence per api node).docs/security/threat-model.md5.12 "Push", T-PU-01, T-AZ-02, T-AZ-03.docs/PLAN.md: 3 "Way of working per ticket"; 5 "Test strategy"; 9 "M3 Platform services" ("Goal", "Exit criteria" points 1 and 2 with AZ-M48 and AZ-P5 for push, "Red team" item "content in push payloads", "Dependencies"); the rows M3-17 and M3-18 (inputs), M3-19 (this ticket, the contract), M3-20, M3-21 (provider port), M7-06 (reminder of a privacy request deadline), M3-22 (writes ofpush_registrations), M3-23 (presence,notification.created, delayed push), M3-24 (measurement); 8 rows M2-01 and M2-09; 19 P-11, P-13.- Dependencies: the header names M1-38, M2-01, M2-09 and M3-18 (D-W-108: M1-38 because the source of
securityhints is written where a device registers aspending). Their closure brings what this brief uses: M1-38 (pending devices; tests create them throughCompleteLoginof M1-37 withTrustUnconfirmed), M3-17 (internal/notify/domain), M2-08, M2-07 (pollAgentEvents), M2-05 (runs, anchor messages,run.state_changed), M1-71, M1-69, M1-68 (metric registryinternal/platform/otel/metrics,make signal-check), M1-66 (focus withidle), M1-65, M1-63, M1-61 (read_states,sync.Settings), M1-60 (mentions), M1-56 (createMessage,MessagePosted), M1-55 (chat.Threads.Info), M1-53, M1-52, M1-49 (explanation candidates inauthz), M1-48, M1-45, M1-44, M1-37, M1-34 (auth_sessions), M1-33 (devices,identity.Devices), M1-31, M1-28 (authorize), M1-26 (identity.Directory), M1-25 (catalogue), M1-16 (event_log,sync.Event), M1-14 (platform/jobs), M1-13 (unit of work,txn.Subscribe), M1-12 (pgtest, migrations), M1-07 and M1-08 (configuration), M1-06, M1-05, M1-01. Not in the closure and not needed: M1-30 (the job that writesaccess.expiring; tests append that event through the appender), M2-19 (agent answers namingrun_id; agents post through M1-56 withorigin = agent), M1-57, M1-70 (SQL visibility lint; Design note 15 says how to follow it where merged), M2-11. - Briefs (merged names win, D-W-100):
M3-17-notify-decision.md(all of it:Decide,Flush,RecipientFacts,Device,Connection,Window,Policy,Switches,Decision,Route, theViaandSkipReasonconstants,TestDomainPurity, its Out of scope list naming this ticket),M3-18-notify-preferences.md(Facts.LevelFacts,Facts.QuietRules,upsertPreference, the event builder ofnotification_pref.updated,sync.Settings.ValuesFor,identity.Directory.Users,chat.Structure.NotificationPath),M2-01-worker-event-consumers.md(sync.ConsumerPush,sync.ConsumerHandler,HandleEvent,Consumers.Register, the round, note 6 "no durable events in a round",TestConsumerCrashMidRoundNoDuplicates),M2-09-run-lifecycle.md(startRun,awaitRunInput,finishRun,cancelRun, the one end-of-run function),M2-05-runs-state-machine.md(CreateRunAnchor: anchorkind = run,origin = agent,silent = true; every state change writesrun.state_changed,queuedincluded),M2-08-invocation-delivery.md(howruns.Delivery.Preparebuilds the human without request as principal, R-AZ-29),M1-56-messaging-send.md(MessagePostedwithThreadKind,Origin,MentionedIDs,Silent,CreatedThread),M1-14-background-jobs.md(jobs.Args,Register,HandlerOptions,Inserter.InsertTx,InsertOptions.ScheduledAt,ValidateArgsType: onlyids.ID,[]ids.IDandint64fields),M1-28-authz-core.md(Authorizer,Resource,Filter(ctx),Scope),M1-49-authz-explain.md(the explanation candidate queries and the random world generator of AZ-P8),M1-33-identity-devices-dpop.md(identity.Devices,RegisterwithRegisterDevice.Statependingoractive,DeviceState),M1-37-identity-bootstrap.md(CompleteLoginwithTrustUnconfirmed: the only path that registers apendingdevice),M1-38-identity-device-approval.md(pending devices; pairing creates active devices),M1-60-messaging-mentions-templates.md(Mentions.CountUnread,UnreadMentionQuery,ThreadMentionCount),M3-20-apns-adapter.mdandM3-21-fcm-adapter.md(Design note 5: badge, sync form,cleared),M1-61-sync-personal-data.md(sync.ReadStates, the unread formula),M1-66-gateway-focus-typing.md,M1-68-core-metrics.md,M1-12-postgres-migrations.md,M1-13-unit-of-work.md,M1-07-config-schema-loader.md. - Code on the base branch (all under
services/server/):internal/notify(domain/of M3-17,app/andadapters/of M3-18),internal/sync(consumer frame of M2-01, appender,Settings,ReadStates),internal/authz,internal/kernel/authorize,internal/messaging,internal/chat,internal/identity,internal/platform/jobs,internal/platform/otel/metrics,internal/wire,migrations/,architecture/modules.yaml,tools/archgen.
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.
Product requirements implemented here:
- Hints arise in the worker consumer
pushfrom the event log, never in the write path; producers do not knownotify; one round inserts the jobs withInsertTxand moves the cursor in the same transaction, so every hint arises exactly once (13 12 point 1, R-PN-09, 02 11.5, 04 7.6, ADR 0051). - Candidates per event (13 2, 13 12 point 2):
messageto every active human with read right on the channel except the author;mentionto every mentioned active human except the author;dmto the participants of a DM channel except the author;run_done,run_failedandinput_neededonly toruns.triggered_by;securityonly to the human who owns the device of adevice.pending, routed only to the other active devices of that human;adminonly to the people 07 9.4 names for the reminder. No hint for edits, deletions, reactions, pins, typing, live-box patches,silentmessages and messages withorigin IN ('import','system')(13 2 "No notification is created for", R-PN-11). - Agent messages of the agent of an active run in its thread give no
messagehint; at the end of the run exactly one hint per person:run_doneto the starter andmessageto the others with levelall, both pointing at the last agent message of the run; a mention by the agent goes at once (13 2.1, R-PN-02). - Every decision uses the pure functions of M3-17 with facts of the decision time:
push.deliverwith current rights for kinds with a thread, the recipient rule of R-AZ-30 forsecurityandadmin; a mention without read right gives no hint and leaves no trace (13 4.2, AZ-M48, R-AZ-30). - Bundling per recipient and thread with
notify.coalesce_windowas a unique scheduled River job: the first hint goes at once and opens the window, furthermessagehints are collected, one hint with the unread count goes at the window end; the urgent kinds go at once (13 4.5, R-PN-06). - One job
notify.push_sendper active push registration of a routed device (13 12 point 5); before sending it checkspush.deliver(or R-AZ-30) again with current rights (04 7.6, R-AZ-11); a hint older thanpush.max_ageis dropped (13 7.3); at mostpush.max_concurrent_requestsprovider requests at a time per process (13 12 point 5). - Result classes of 13 7.1 to 7.3 change the registration: success resets
failure_countand setslast_success_at; invalid tokens deactivate with theirdisabled_reason; transient failures raisefailure_countand deactivate withrepeated_failuresatpush.max_consecutive_failures; retries followpush.retry_base,push.retry_capand a provider'sRetry-After(13 7.2, 7.3). - A push carries only IDs, the kind, flags, a count and the badge number, never text; device tokens never appear in logs (ADR 0031, T-PU-01, 13 7.3 last point).
push_registrationsexactly as 03; this ticket reads it and writes only the delivery columns (last_success_at,failure_count,disabled_at,disabled_reason); creation, removal, the preview token and the deactivation on logout, device revocation and user disable are M3-22 (D-W-108).- Automatic following: a human who writes in a thread with
kind = 'thread'or creates it, and has no own setting for that thread, getsnotification_preferences(user, 'thread', id, level = 'all')and onenotification_pref.updatedin the transaction of the message; never in the main thread (13 3.2, R-PN-04, OQ-05 default). identitywrites the durable personal eventdevice.pending(resourcedevice, receive action self, audience the human who owns the device, payload onlydevice_idandplatform) in the transaction that registers a device with statepending(R-AZ-31, 05 6).- Badge (13 8): every push carries the badge number of its recipient, computed at send time from
mentions,read_states.last_read_seq, the effective level and the thread's channel kind through read ports of the owning modules, counting only threads the recipient can read; when a read lowers the number, the person's mobile registrations get at most one badge sync pernotify.badge_sync_min_interval, without text (13 8, D-W-108).
Scope¶
All paths under services/server/ unless they start with docs/ or infra/. Where an earlier ticket already created a file named below, extend it. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0 (make licenses).
Create:
migrations/<next>_notify_push_registrations.sql(Design note 2) and its line inmigrations.sha256throughmake migrations-lock.internal/notify/registrations.go(port):PushRegistrationswithActiveand the DTOPushRegistration(Design note 3).internal/kernel/presence/presence.go:View,Connection,None(Design note 10).internal/notify/domain/codes.go(KindCode,KindFromCode),internal/notify/domain/result.go(ResultClass,SendResult,RegistrationState,ApplyResult,RetryDelay),internal/notify/domain/runstate.go(RunActive) (Design notes 6, 12, 13); standard library andkernel-coreonly, soTestDomainPurityof M3-17 passes unchanged.internal/notify/app/consumer.go(PushConsumer, the handler of the consumerpush),internal/notify/app/sources.go(classification of the five event types, Design note 4),internal/notify/app/runs.go(active run and last agent message, Design note 5),internal/notify/app/gather.go(facts per batch of candidates, Design note 8),internal/notify/app/window.go(Design note 9),internal/notify/app/flush.go(the flush use case),internal/notify/app/send.go(the send use case ofnotify.push_send, Design notes 11 to 14),internal/notify/app/follow.go(OnMessagePosted, Design note 17),internal/notify/app/badge.go(BadgeCounterand the badge sync, Design note 16),internal/notify/app/ports.go(outgoing portsScheduler,PushProvider,LiveRoutes,RegistrationStore, Design notes 9, 10, 11).internal/notify/adapters/postgres/queries/push_registrations.sql, the sqlc output and the repository implementingPushRegistrationsandRegistrationStore.internal/notify/adapters/jobs/jobs.go: the argsPushSend,CoalesceWindow,CoalesceFlush,BadgeSync,BadgeSyncHold, their handlers and theScheduleroverplatform/jobs(Design notes 9, 11 and 16).internal/notify/adapters/fakepush/fakepush.go:FakeProvider, a test double ofPushProviderwith programmable results per registration and a record of every call (Design note 12); used only by tests.- No
internal/notify/adapters/otelmetrics: M1-68 is in the closure, so the metricSpecs go into its registryinternal/platform/otel/metrics(Design note 15).
Change:
internal/platform/jobs(additive):Inserter.InsertUniqueTx,InsertResult,Inserter.PendingTx(Design notes 9 and 16), with tests against PostgreSQL.internal/authzport and adapter (additive):Recipients.Readers(Design note 6).internal/syncport and adapter (additive):History.ResourceEvents,History.PersonalEvents,ReadStates.PositionsForandReadStates.PositionsOfUser(Design notes 5, 9 and 16).internal/chatport and adapter (additive):Threads.UnreadCandidates(Design note 16).internal/messagingport and adapter (additive):Messages.LastAnchorBefore,Messages.AnchorOf,Messages.LastAgentMessageBefore,Messages.Newest(Design notes 5 and 9).internal/identityport and adapter (additive):Devices.ActiveForUsers(Design note 8), unless the merged port already returns the active devices of many people; the writer ofdevice.pendingin the use caseRegisterofinternal/identity/app/devices.go(Design note 4 d).internal/notify/app/preferences.goof M3-18: addinsertPreferenceIfAbsentbesideupsertPreference(Design note 17); theFactsadapter of M3-18: addLevelFactsOfUser(Design note 16).internal/wire: register the consumerpushwithsync.Consumers.Register(sync.ConsumerPush, ...)(replace the comment M2-01 left); register the five job kinds on queuedelivery; the in-transaction subscriptionmessaging.MessagePosted -> notify.OnMessagePostedafter the subscribers ofrunsandsync; wirepresence.Noneaspresence.View, the defaultLiveRoutesand an empty provider set; the configuration keys of Design note 14; regeneratetestdata/txn_registrations.goldenand the jobs golden file if one exists.architecture/modules.yaml:authzinnotify.depends_on;internal/kernel/presencein the kernel port component; regenerate.go-arch-lint.yml(cd services/server && go generate ./tools/archgen).- Docs (Design note 18; new text in English, like the existing text):
docs/spec/13-push-notifications.md,docs/spec/02-architecture.md4.3, 4.4, 4.5, 12.3,docs/architecture/c4-3-server-components.mmdand its.svg(make diagrams),docs/observability/signals.md4.4,docs/operations/configuration.mdregenerated (make config-reference). - READMEs of
notify(keep the lineMay use the ports of these modules: ...in its form and addauthz, because M1-06 compares it witharchitecture/modules.yaml),authz,sync,messaging,chat,identity,internal/platform/jobs,internal/kernel(if it lists the packages);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M3-19-1toD-M3-19-15(Design note 19).
This ticket adds no operation, no action, no durable or ephemeral event type, no problem code, no metric name and no top-level Makefile target. It writes the durable event device.pending that the core contract defines (R-AZ-31) and adds the job kinds notify.coalesce_window, notify.coalesce_flush, notify.badge_sync and notify.badge_sync_hold (02 12.3 in the same change) and the bothy.reason values of Design note 15 (signals.md in the same change).
Out of scope¶
- The decision itself (
Decide,Flush,ReleaseDelayed,ResolveLevel,InQuietHours): M3-17; preferences, quiet hours and their reads: M3-18. This ticket calls them and changes neither, except the additions named in the Scope. - APNs and FCM payloads, headers, priorities,
interruption-level,push.ttl, the provider JWT and OAuth tokens, the pause of a provider after a configuration error and its alert: M3-20 and M3-21. They implementPushProvider(Design note 12); this ticket registers no production provider. registerPush,unregisterPush, the preview token, the preview endpoint,notify.preview_mode, the subscriptionsidentity.DeviceRevokedandidentity.SessionEndedand the deactivation on logout, device revocation and user disable: M3-22 (on the table of this ticket).notification.createdover the gateway, the gateway's implementation ofpresence.View, the delayed push to mobile devices while a desktop is active and its read check withReleaseDelayed: M3-23 (it replacespresence.Noneand the defaultLiveRoutes, Design note 10). RK-37 (presence per api node) stays with M3-23 and M7.- The measurement of 13 9 and
perf.push_handoff_p95: M3-24.bothy.push.fetch: M3-22. - Hints of kind
adminfor privacy request deadlines: M7-06 emits that reminder as a personal durable event and maps it in the consumerpush(PLAN row M7-06, D-W-108); this ticket buildsadminonly fromaccess.expiring. - The
badgeof the preview endpoint (13 6.4): M3-22, which callsBadgeCounter.Forof this ticket (Design note 16). - Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M3-19-notify-jobs; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(notify): ...,feat(jobs): ...) 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 outside
push_registrations; logs carry at most a short hash prefix of it. No network calls except package downloads for pinned tools and Go modules. - No real push service in any test: providers are
fakepush.FakeProvider; nothing in this ticket costs money. - Never lower a threshold, disable a gate or delete a test.
- Time only through
kernel/clock; tests move a fake clock and call job handlers directly, they never sleep for a window. - Never move the cursor of the consumer
pushby hand outside a test database (M2-01). - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). The principal forpush.deliver(Design note 7) and the source ofsecurityhints (Design note 4 d) are settled by R-AZ-31 and are not a reason to stop. - 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 and M3-18: follow their names in
internal/notifyand list each difference.
Design notes¶
-
Place and dependencies (02 4.3, 4.4, 5; ADR 0008). Everything of this ticket lives in
notify, except the additive port methods ofauthz,sync,messagingandidentity, the unique insert ofplatform/jobsand the new kernel portpresence.notifyreads candidates throughauthz(13 12 point 2: "fromauthz"), which 02 4.4 does not list fornotifyyet. Ruling for docs/DECISIONS.md: addauthzto "May use" ofnotifyin 02 4.4 (reason: "candidates with read permission for notifications", English text), tonotify.depends_oninarchitecture/modules.yaml, the edgenotify --> authztodocs/architecture/c4-3-server-components.mmdand to the dependency line of thenotifyREADME; the edge points down (02 4.4 "Tiers"), andmake register-checkkeeps the four equal.notifynever importsrunsorgateway: run facts come from the event payloads and the log (Design note 5), live connections throughpresence.View(Design note 10). -
Migration (goose; the next free number when you rebase before the merge, renumber if a parallel ticket took it).
<next>_notify_push_registrations.sqlcreatespush_registrationsexactly as 03:id uuid NOT NULL(PK),user_id uuid NOT NULL,device_id uuid NOT NULL,auth_session_id uuid NOT NULL,provider text NOT NULL CHECK (provider IN ('apns','fcm','unifiedpush','wns')),token text NOT NULL,environment text NOT NULL DEFAULT 'production' CHECK (environment IN ('production','sandbox')),app_id text NOT NULL,preview_token_hash bytea,created_at timestamptz NOT NULL DEFAULT now(),last_success_at timestamptz,failure_count integer NOT NULL DEFAULT 0,disabled_at timestamptz,disabled_reason text CHECK (disabled_reason IN ('token_invalid','device_revoked','logout','topic_mismatch','sender_mismatch','repeated_failures')); check(disabled_at IS NULL) = (disabled_reason IS NULL); FKuser_id -> users(principal_id) ON DELETE CASCADE,device_id -> devices(id) ON DELETE CASCADE,auth_session_id -> auth_sessions(id) ON DELETE CASCADE; UNIQUE(provider, token); unique partial index onpreview_token_hash WHERE preview_token_hash IS NOT NULL; indexes(user_id) WHERE disabled_at IS NULLand(auth_session_id). The table is new and empty, so constraints and indexes are created with it. Privileges for the application role follow the existing migrations. No column, check or index that 03 does not name. -
Read port of the active registrations (02 4.3 row
notify:PushRegistrations; D-W-108). In the root package, withoutauthorize(callers select the people; the doc comment says so) and without the token:// PushRegistrations reads the push targets of people (03 push_registrations). // M3-22 adds the writes on the same port. type PushRegistrations interface { // Active returns the registrations with disabled_at IS NULL of the given people, // grouped by person, each list in ascending id. Active(ctx context.Context, userIDs []ids.ID) (map[ids.ID][]PushRegistration, error) } type PushRegistration struct { ID, UserID, DeviceID ids.ID Provider, Environment, AppID string CreatedAt, LastSuccessAt time.Time // LastSuccessAt zero when NULL FailureCount int }The token and
preview_token_hashnever leave thenotifyadapters; the send use case reads the token through the outgoing portRegistrationStore(Design note 11).Activeuses the index(user_id) WHERE disabled_at IS NULL. -
Event sources (13 2, 12 point 1; Ruling for docs/DECISIONS.md).
PushConsumer.HandleEvent(thesync.ConsumerHandlerof M2-01) looks only at the event type and ignores every other type (the cursor still moves):a.
message.created(payloadMessagePayload): nothing forsilent,origin IN ('import','system'),kind IN ('run','system'). In a DM channel (ChannelKind = dmfromchat.Threads.Info): kinddmto every participant except the author. Otherwise: kindmentionto every person ofmention_idsexcept the author; kindmessageto every reader except the author and the people who gotmentionfor this event, unless the message is an agent message during an active run (Design note 5). One hint per person and event; precedencedm, thenmention, thenmessage. Only active humans are recipients (identity.Directory; agents, apps and inactive people are dropped before any fact is gathered).ThreadIDand the message ID come from the payload, the channel from the envelope scope.b.
run.state_changed(payloadRunStateChangedPayload):waiting_inputgivesinput_neededtorun.triggered_by;completedgivesrun_donetorun.triggered_by;failedgivesrun_failedtorun.triggered_by;queuedandrunninggive nothing. Atcompleted,failedandcancelledthe readers of the run's channel with the kindmessageget the run's singlemessagehint (13 2.1), excluding the starter atcompletedandfailed(who gotrun_doneorrun_failed) and including the starter atcancelled(13 2 gives no run kind forcancelled, so the starter is one of "the others"). Both point at the last agent message of the run; if the run wrote no agent message,run_done,run_failedandinput_neededpoint at the thread only (no message ID) and nomessagehint arises. Ruling for docs/DECISIONS.md: the treatment offailedandcancelledfor the others, because 13 2.1 says "At the end of the run" without naming the end states.c.
access.expiring(payloadAccessExpiringPayload, personal event): kindadmintoaudience_principal_id, if that principal is an active human; target IDgrant_idforkind = grant,group_idforkind = group_membership. 07 9.4 names exactly the receivers of this event (subject,granted_by, the active owners,added_by), which is the recipient rule of R-AZ-30 for these reminders.d.
device.pending(payloadDevicePendingPayload, personal event, R-AZ-31): kindsecuritytoaudience_principal_id, if that principal is an active human; target IDdevice_id, no thread, no message. The routes are the other active devices of that human:Devices.ActiveForUsersreturns onlydevices.state = 'active', so the pending device itself never gets the hint; no other person is a candidate. The recipient rule of R-AZ-30 forsecurityat decision and send time: the recipient is an active human and the audience of the event, and the registration's device is active and is not thedevice_idof the payload. The writer (05 6 rowdevice.pending): the use caseRegisterofidentity.Devices(M1-33,internal/identity/app/devices.go) appends onedevice.pendingthroughkernel/eventlog.Appenderin its ambient unit of work exactly whenRegisterDevice.Stateispending, with resource{kind: device, id: device_id}, audience the device'sUserID, receive action self, payload onlydevice_idandplatform, and type and actions from the generated catalogue constants.Registeris the only insert of a device row, and the only caller withpendingisCompleteLoginof M1-37 withTrustUnconfirmed(pairing of M1-38 creates active devices), so every pending device gets exactly one event and an active one none. A veto later in the unit of work removes the device and the event. Ruling for docs/DECISIONS.md.e.
read_state.updated(payloadReadStatePayload, personal event): no hint; it is the trigger of the badge sync of Design note 16. -
Agent messages during a run, by commit order (13 2.1; 04 3.2; Ruling for docs/DECISIONS.md). 03 stores no run on an agent answer (
messages.run_idis set only for the anchor, M2-19), and the consumer may process a message after the run already ended, so the run state of the moment of processing is not the answer. The log order is the commit order, and per threadthread_seqfollows it (the thread row lock ofAppendToThread), so:a. An agent message M (
origin = agent, author A, thread T,thread_seqs, logseqm) is during an active run exactly whenmessaging.Messages.LastAnchorBefore(T, s)returns an anchor of run R and the lastrun.state_changedof R withseq < m(sync.History.ResourceEventswith resourcerunR, typerun.state_changed, before m) has a state inqueued,running,waiting_input(domain.RunActive) andrun.agent_id = A. At most one run is active per thread (R-DM-14), so the newest anchor before M is the only candidate. No anchor, no retained event or another agent: not during a run, normalmessagehints.b. The last agent message of run R, ended by the event e (
seqe):messaging.Messages.AnchorOf(R)gives the anchor'sthread_seqa; thenmessaging.Messages.LastAgentMessageBefore(T, A, a, before)withbeforestarting above every sequence returns the newest message of A in T witha < thread_seq < before,origin = agent,kind IN ('text','voice','card')and not deleted; it is the answer ifsync.History.ResourceEvents(resourcemessage, typemessage.created, before e) finds its creation event, else repeat withbefore= itsthread_seq. Messages written after the end are skipped this way; the loop ends at the anchor.c. Both reads use the index
(resource_kind, resource_id)ofevent_logand the thread index ofmessages; none filters by rights, because the consumer reads like the log tail and every hint is checked withpush.deliver(07 7.4 rule 1, Design note 15).New port methods (additive, never
authorize, doc comments say so):// sync: History reads retained events of one resource in commit order for worker consumers. type History interface { ResourceEvents(ctx context.Context, q ResourceQuery) ([]Event, error) // ascending seq } type ResourceQuery struct { ResourceKind string ResourceID ids.ID Types []string Before int64 // only seq < Before } // messaging.Messages additions LastAnchorBefore(ctx context.Context, threadID ids.ID, beforeThreadSeq int64) (AnchorRef, bool, error) AnchorOf(ctx context.Context, runID ids.ID) (AnchorRef, bool, error) LastAgentMessageBefore(ctx context.Context, threadID, agentID ids.ID, afterThreadSeq, beforeThreadSeq int64) (MessageRef, bool, error) Newest(ctx context.Context, threadID ids.ID) (MessageRef, bool, error) // newest not deleted, kind not run or system type AnchorRef struct { MessageID, RunID ids.ID; ThreadSeq int64 } type MessageRef struct { ID ids.ID; ThreadSeq int64 } -
Candidates through
authz(13 12 point 2; Ruling for docs/DECISIONS.md). New port in theauthzroot package:// Recipients answers who may be a recipient of a hint in a channel (13 12 point 2). type Recipients interface { // Readers returns the active humans that a source of 07 makes able to read the // channel: owners (not for DM channels, SR6), 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 decides exactly. Readers(ctx context.Context, channelID ids.ID) ([]ids.ID, error) }It reuses the explanation candidate queries of M1-49 in
internal/authz/adapters/postgres(no copy). The exact answer per candidate isauthorize(r, push.deliver, thread)in filter mode (authorize.Filter(ctx), no denial audit), so a superset never leaks: a candidate the guards exclude getsAllowed = false. Mentions and run starters do not go throughReaders; they are candidates by name and get the sameauthorize. -
The principal of a recipient (05 5 "Check without a request", R-AZ-29, R-AZ-14, R-AZ-30, R-AZ-31; Ruling for docs/DECISIONS.md).
push.deliverevaluates the rights of a human without a request of that human, at decision time and again at send time. Build that principal exactly asruns.Delivery.Prepareof M2-08 builds the human without request: no session, device or token, blocks only from the person's status, owner status from the current owner list. 05 section 5 lists "push.deliverbefore each hint decision and each push send, and the recipient rule of R-AZ-30 for hints without a thread" as checks without a request, so R-AZ-29 applies (R-AZ-31); an owner who reads a channel only as owner gets hints like any reader. The ruling adds nothing to anyone's rights: it is the read right the person has in a session of the same moment. Forsecurityandadminthe factAllowedis: the recipient is the audience of the event and an active human at that moment (R-AZ-30), forsecuritywith the device rule of Design note 4 d. -
Gathering facts (13 12 points 2 to 4; M3-17 Design note 2). Per event and kind, for all candidates at once:
Facts.LevelFacts(threadID, userIDs)andFacts.QuietRules(userIDs)of M3-18; the switchesnotify.admin_enabledandnotify.input_needed_breaks_quiet_hoursthroughsync.Settings.ValuesForwith the defaultsnotify.default_admin_enabledandnotify.default_input_needed_breaks_quiet_hours(a stored value that is not a boolean counts as unset and logs one Warn line without person data); active devices throughidentity.Devices.ActiveForUsers(ctx, userIDs) (map[ids.ID][]DeviceRef, error)withDeviceRef{ID ids.ID; Platform string}(devices.state = 'active'); registrations throughPushRegistrations.Active, kept only for active devices; live connections throughpresence.View.Connections; the window throughScheduler.WindowEnd(Design note 9);Policyfromnotify.coalesce_windowandnotify.mobile_delay_when_desktop_active;Nowonekernel/clockreading per event.Authoris always false (the author is no candidate) andRunStarteris true exactly for the run kinds.Decideof M3-17 then runs per candidate; an error ofDecideis an internal error that rolls the round back (M2-01 note 7), because a bad fact is a bug. -
Window state as River jobs (13 4.5 "unique, scheduled job per (recipient, thread)"; M3-17 Design note 7; Ruling for docs/DECISIONS.md).
a.
platform/jobsgets two additive functions, River stays inside the package:Inserter.InsertUniqueTx(ctx, args Args, opts *InsertOptions) (InsertResult, error)withInsertResult{Inserted bool; ScheduledAt time.Time}inserts unless a job of the same kind with equal args is in a not finalized state (available,pending,retryable,running,scheduled), and returns the standing job'sScheduledAtwhen it does not insert (River unique jobs by args and state [13 Q1]);Inserter.PendingTx(ctx, args Args) (scheduledAt time.Time, ok bool, err error)reads the same not finalized job by kind and args in the ambient transaction. Both refuse to run outside a unit of work likeInsertTx.b. Two job kinds of
notifyon queuedelivery(02 12.2: they are part of push, so they keep running in maintenance mode likenotify.push_send), args onlyUserID, ThreadID ids.ID:notify.coalesce_windowmarks an open window and is scheduled at its end (its handler does nothing);notify.coalesce_flushexists exactly when amessagehint was collected in that window and is scheduled at the same end.c. In the consumer:
Window{End}is theScheduledAtof a pendingnotify.coalesce_windowof (r, T) when it is afterNow, else zero. AsendwithOpensWindowUntilinserts the window job unique at that time before its push jobs; if a late marker of the previous window still stands (Insertedfalse with a time not afterNow), the hint goes without a new window. Acollectinsertsnotify.coalesce_flushunique atWindow.End; a duplicate means it was already collected.d. The flush handler, one unit of work at its scheduled time: gather the facts of r for T (Design note 8),
FlushState{Collected: 1, Unread: u}withu = threads.last_message_seq - max(read_states.last_read_seq, threads.unread_floor_seq)(03 "Unread";chat.Threads.InfoForShareof M1-61 forLastMessageSeqandUnreadFloorSeq, and the newsync.ReadStates.PositionsFor(ctx, threadID, userIDs) (map[ids.ID]int64, error), absent row read as 0),domain.Flush; asendcarriesCount = u, points atmessaging.Messages.Newest(T)(no message ID if none), hasDueAt= the window end, inserts its push jobs and the next window job as in c. -
Presence and live routes (13 12 point 4, 4.4, 4.6 point 1; RK-37; Ruling for docs/DECISIONS.md).
notifyandgatewayare on the same top layer of 02 4.4, so the presence port is a cross-cutting kernel port, likeeventlog.EphemeralPublisherthat the gateway implements:// Package presence: live gateway connections of people (13 12 point 4). // The gateway implements View (M3-23); until then internal/wire wires None. type View interface { // Connections returns, per person, one entry per device with a live connection. Connections(ctx context.Context, userIDs []ids.ID) (map[ids.ID][]Connection, error) } type Connection struct { DeviceID ids.ID Focus []ids.ID // threads of the last focus op (04 6.2) Idle bool // idle of the last focus op } // None reports no connection for anyone: nobody is active in a thread, no desktop is active. type None struct{}gather.gomaps eachConnectiononto thedomain.Devicewith the same ID (a connection of an unknown or inactive device is ignored). WithNoneno decision yields the routesgatewayorpush_delayed. For those routes the outgoing portLiveRoutes(Gateway(ctx, Hint, domain.Route) error,Delayed(ctx, Hint, domain.Route) error) receives them; the default implementation of this ticket returns an internal error (fail closed, the round rolls back) and M3-23 replaces it withnotification.createdand the delayed push. Add the package to 02 4.5 (implemented bygateway, used bynotify). -
The job
notify.push_send(13 12 point 5, 7.3, 9; 04 7.6; 02 8.5, 12.1, 12.4; Ruling for docs/DECISIONS.md).a. Args
PushSend{NotificationID, RegistrationID, RecipientID, ThreadID, MessageID, TargetID ids.ID; Cleared []ids.ID; Kind, Quiet, Count, DueAtUnixMicro, Attempt int64}:Kindisdomain.KindCode(fixed codes 1 to 8 in the order ofNotificationKindand 9 for the badge syncapp.KindSync, never renumbered),Clearedonly for the badge sync (Design note 16),Quiet0 or 1,DueAtis t0 of 13 9 (theoccurred_atof the triggering event, or the window end of a flush). OneNotificationID(UUIDv7, thenid) per hint and recipient, shared by the jobs of that hint's registrations. Queuedelivery.b. Handler, step 1 in a unit of work: load the registration through
RegistrationStore(with token); not found,disabled_atset or its device no longer active: skipno_registration;now - DueAt > push.max_age: skipexpired;push.deliverfor kinds with thread or the R-AZ-30 rule forsecurityandadminwith the rights of now (Design note 7), and for the badge sync only that the recipient is an active human: denied gives skipno_access; no provider registered forprovider: skipno_provider(the job ends without error); the badge number ofRecipientIDthroughBadgeCounter.For(Design note 16). Commit.c. Step 2 outside any transaction (02 8.5), under a process-wide semaphore of
push.max_concurrent_requests:PushProvider.Sendwith a deadline from the provider adapter; spanpush.send,bothy.push.send.duration, thenbothy.push.sentwith the outcome of Design note 13.d. Step 3 in a unit of work:
domain.ApplyResulton the registration row lockedFOR UPDATE, and for a retryInsertTxof the same args withAttempt + 1at the time ofdomain.RetryDelay; the handler then returns nil. A crash between step 2 and step 3 makes River run the job again, so a push may reach a device twice with the samenid; the app replaces by thread ID (13 6.1, 5.2). Ruling: at least once with onenid, because a provider call cannot join a transaction. -
Provider port (13 5.4; D-W-108). An outgoing port of
notify/app, implemented byadapters/apns(M3-20),adapters/fcm(M3-21) andadapters/fakepush:// PushProvider sends one hint to one registration (13 5.4). type PushProvider interface { Name() string // value of push_registrations.provider it serves Send(ctx context.Context, reg Registration, n Notification) (domain.SendResult, error) } type Registration struct { ID, UserID, DeviceID ids.ID Provider, Environment, AppID string Token string // never logged (13 7.3) CreatedAt time.Time } // Notification holds only IDs, the kind, flags, a count and the badge (ADR 0031). type Notification struct { ID, InstanceID, ThreadID, MessageID, TargetID ids.ID // zero when absent Kind string // NotificationKind, or KindSync for the badge sync of 13 8 Quiet bool Count int64 Badge int64 // badge number of 13 8 at send time HasBadge bool // true for every hint and every sync this ticket sends Cleared []ids.ID // KindSync only: threads read since the last sync, complete or nil } // KindSync marks the badge sync of 13 8: no thread, no message, no text. const KindSync = "sync"These are the final names that M3-20 and M3-21 use (their Design note 5): a hint carries
BadgewithHasBadge(aps.badge,data.badge); the sync form isKind == KindSyncwithID,InstanceID,Badge,HasBadgeandCleared, and zeroThreadID,MessageID,TargetID,QuietandCount. APNs sends it as{"aps":{"badge":<n>}}and ignoresCleared; FCM sendsk = "sync",badgeandcleared(the IDs comma-separated, in the given order) with prioritynormal.Clearedis never a prefix: this ticket passes the complete list, and M3-21 leaves it out entirely when it does not fit the FCM data limit (D-M3-21-6).InstanceIDis the instance record's ID, passed in byinternal/wireat startup (notifymay not useinstance). A Go error fromSendcounts as a transient failure.internal/wirebuilds the provider set from the registered adapters, one perName; this ticket registers none in production. -
Result classes (13 7.1 to 7.3; Ruling for docs/DECISIONS.md).
domain.SendResult{Class ResultClass; DisableReason string; InvalidSince time.Time; RetryAfter time.Duration}and pureApplyResult(state RegistrationState, res SendResult, now time.Time, maxFailures int) RegistrationChange:Class Registration Hint bothy.outcomeacceptedlast_success_at = now,failure_count = 0done accepteddisabledisabled_at = now,disabled_reason=token_invalid,topic_mismatchorsender_mismatch; unchanged whenInvalidSinceis set and the row was created after it (13 7.1 row410)done rejected_tokenretryfailure_count + 1; atpush.max_consecutive_failuresdisabled_at = nowwithrepeated_failuresretried unless deactivated or past push.max_ageprovider_errorthrottledunchanged (the limit is the server's, not the token's) retried throttledconfig_error,program_errorunchanged dropped (a retry cannot help; the adapter raises its alert, M3-20, M3-21) rejected_otherRetryDelay(attempt int64, base, cap, retryAfter time.Duration, jitter float64) time.Durationismin(cap, base * 2^(attempt-1))scaled byjitterin[0.5, 1](the caller draws it), at leastretryAfter; basepush.retry_base, cappush.retry_cap. A retry whose time is pastDueAt + push.max_ageis dropped asexpired. -
Configuration. In the schema (if absent) with the defaults of 13 17 and 03 9:
notify.coalesce_window,notify.mobile_delay_when_desktop_active,notify.default_admin_enabled,notify.default_input_needed_breaks_quiet_hours,notify.badge_sync_min_interval(ininternal/wire/configkeys_notify.goof M3-18),push.max_age,push.retry_base,push.retry_cap,push.max_concurrent_requests,push.max_consecutive_failures(ininternal/wire/configkeys_push.go, created if absent);TestConfigSchemaBuildslists them;make config-referenceandmake config-doc-checkshow no diff. The provider keyspush.ttl,push.apns.*andpush.fcm.*stay with M3-20 and M3-21. -
Observability and queries (signals.md; 17 rules; Ruling for docs/DECISIONS.md). Declare
bothy.push.sent,bothy.push.send.duration,bothy.push.skippedandbothy.push.registrations.activeasSpecs in the registry of M1-68 (make signal-check).bothy.push.skippedcounts one per recipient and hint decidednoneand one per job that ends without sending, withbothy.reason:no_access(not_allowed, or denied at send),preference(level,admin_disabled),active_in_thread,no_registration(no_route, or the registration gone at send),read(a flush with nothing unread),expired,no_provider;not_recipientandnothing_collectedare not counted (no hint existed for anyone). In signals.md 4.4 replace the list ofbothy.push.skippedby these seven values, which removesquiet_hours: quiet hours make a hint quiet and never drop it (R-PN-05), so that value could never occur.bothy.push.registrations.activeis an observable gauge perbothy.push.providerover the active rows. Logs carry onlybothy.push.provider,bothy.job.kind,bothy.reasonanderror.type, never a token, a person, a thread or a text. The reads of Design notes 5 and 6 and the registration reads have no visibility predicate by design; where the SQL visibility lint of M1-70 is merged and flags them, add exception entries with categorylog_tail(thesynchistory read),system_job(themessagingreads, theauthzcandidate read and the registration reads of the jobs), each with its reason; never widen an existing entry. -
Badge and badge sync (13 8; D-W-108; Ruling for docs/DECISIONS.md).
a.
BadgeCounter.For(ctx, userID ids.ID) (int64, error)ininternal/notify/app/badge.gocounts the threads T for r that r can read and that hold an unread mention of r, or are unread with effective levelall, or are unread in a DM channel (13 8). Unread at position p:LastMessageSeq > max(p, UnreadFloorSeq)(03 "Unread"); p isread_states.last_read_seq, 0 without a row. Reads, never foreign tables: the visible channels throughauthzScope(M1-28) for the principal of Design note 7 and the actionpush.deliver; the threads of these channels that can be unread through the newchat.Threads.UnreadCandidates(ctx, channelIDs []ids.ID) ([]ThreadPosition, error)withThreadPosition{ThreadID, ChannelID ids.ID; ChannelKind string; LastMessageSeq, UnreadFloorSeq int64}(only threads withlast_message_seq > unread_floor_seq, neverauthorize, doc comment says so); the positions through the newsync.ReadStates.PositionsOfUser(ctx, userID ids.ID, threadIDs []ids.ID) (map[ids.ID]int64, error); unread mentions throughmessaging.Mentions.CountUnreadof M1-60 withPrincipalIDr, the visible channels and the positions; effective levels through the newFacts.LevelFactsOfUser(ctx, userID ids.ID, threadIDs []ids.ID) (map[ids.ID]domain.LevelFacts, error)besideLevelFactsof M3-18 anddomain.ResolveLevel. Every thread that counts is confirmed withauthorize(r, push.deliver, thread)in filter mode, so a thread r cannot read never counts.notify.push_sendcallsForin step 1 (Design note 11 b) and setsBadgeandHasBadgeof everyNotification; M3-22 calls the same function for the preview.b. The badge sync. The consumer
pushhandlesread_state.updatedof r for thread T (Design note 4 e): the read lowers the number exactly when T counts for r at the previous position and does not count at the newread_state.last_read_seq. The previous position is thelast_read_seqof the newest earlierread_state.updatedof r for T (sync.History.ResourceEventswith resourcethreadT, typeread_state.updated, before the event, keeping only events with audience r), else 0. A mark as unread never lowers it. When it falls, the consumer schedules one sync of r: ifPendingTx(BadgeSync{UserID: r})finds one, nothing (it will carry this read); elseInsertUniqueTx(BadgeSync{UserID: r, AfterSeq: seq - 1})at theScheduledAtof a pendingnotify.badge_sync_holdof r when that is afterNow, else atNow.c. Two job kinds of
notifyon queuedelivery:notify.badge_syncwith argsBadgeSync{UserID ids.ID; AfterSeq int64}andnotify.badge_sync_holdwith argsBadgeSyncHold{UserID ids.ID}(handler does nothing).BadgeSynctagsUserIDwithriver:"unique", andInsertUniqueTxandPendingTxcompare only tagged fields when an args type tags any (River unique by args), else all fields, so a person has at most one pending sync. Thenotify.badge_synchandler, one unit of work:BadgeCounter.For(r);Cleared= the threads of theread_state.updatedevents of r withseq > AfterSeq(newsync.History.PersonalEvents(ctx, PersonalQuery{AudienceID ids.ID; Types []string; After int64}) ([]Event, error)on the index(audience_principal_id, seq), neverauthorize) that do not count now, distinct, in event order; insertBadgeSyncHold{UserID: r}unique atNow + notify.badge_sync_min_interval; then onenotify.push_sendwithKindKindSync,Clearedand a newNotificationIDper active registration of r with providerapnsorfcmon an active device with platformiosorandroid(13 8: mobile devices only). No registration gives no job. So at most one sync per person pernotify.badge_sync_min_interval, and a number that does not fall gives none. -
Automatic following (13 3.2, R-PN-04, OQ-05; Ruling for docs/DECISIONS.md).
notify.OnMessagePosted(ctx, messaging.MessagePosted), an in-transaction subscriber registered after those ofrunsandsync: return unlessOrigin = humanandThreadKind = thread; theninsertPreferenceIfAbsent(ctx, AuthorID, thread ThreadID, level all):INSERT ... ON CONFLICT (user_id, target_kind, target_id) DO NOTHING RETURNING ...; only an inserted row appendsnotification_pref.updatedwith the builder of M3-18 (version = 1). M3-18 Design note 6 namesupsertPreferencefor this; an upsert would overwrite an existingmuteormentionsof the person, which 13 3.2 forbids ("no own setting for this thread yet"), so this ticket adds the insert-if-absent sibling in the same file and documents both as the only write paths. The subscriber runs in the message's unit of work: a veto later in the unit removes the row and the event. Creating a thread with a first message is covered, becausecreateThreadpublishesMessagePostedfor that message in the new thread. A different answer to OQ-05 would change only this function. -
Specification (rule 10; nothing in 00, 03, 04 or 05 changes; all new text in English, like the existing text):
docs/spec/13-push-notifications.md: 2.1, a paragraph: an agent message is during a run when the newest anchor before it belongs to a run that was active at its commit (Design note 5); atfailedandcancelledthe others getmessageas atcompleted, atcancelledincluding the starter; a run without agent message gives nomessagehint. 3.2, a sentence: following inserts only when no own row exists. 4.5, a paragraph: the window as the two unique jobsnotify.coalesce_windowandnotify.coalesce_flush(Design note 9), the flush hint points at the newest message of the thread. 7.3, a paragraph with the table of Design note 13 and the at-least-once rule with onenid. 12 point 1: the five source events,device.pendingas the source ofsecurityhints (R-AZ-31) andread_state.updatedas the trigger of the badge sync; 8, a paragraph: the badge computation and the sync jobs of Design note 16; point 2: candidates throughauthzRecipients.Readers; point 4:presence.Viewininternal/kernel/presence; point 5: the jobs of Design note 11 andno_provider.docs/spec/02-architecture.md: 4.3 rowsnotify(subscribesmessaging.MessagePostedfor following),authz(portRecipients),sync(portHistory),messaging,chatandidentity(the read additions),identity(writesdevice.pending); 4.4 rownotify(Design note 1); 4.5 rowkernel/presence; 12.3 rowsnotify.coalesce_window,notify.coalesce_flush,notify.badge_syncandnotify.badge_sync_hold(modulenotify, trigger consumerpushor the jobnotify.badge_sync, queuedelivery, rule in 13).docs/observability/signals.md4.4 rowbothy.push.skipped(Design note 15).
-
Rulings for
docs/DECISIONS.md(section 10, one row each with date, decision, reason and place):D-M3-19-1the edgenotify -> authzandRecipients.Readersas a superset decided byauthorize(notes 1, 6);D-M3-19-2the five source events, precedencedm,mention,message, one hint per person and event, the writer ofdevice.pendinginRegisterand thesecuritymapping (note 4);D-M3-19-3run end hints forcompleted,failedandcancelled(note 4 b);D-M3-19-4agent messages during a run and the last agent message by commit order (note 5);D-M3-19-5the principal of R-AZ-29 forpush.deliver(note 7);D-M3-19-6facts per batch, invalid stored switches count as unset (note 8);D-M3-19-7window state as two unique River jobs,InsertUniqueTxandPendingTx, the late marker rule (note 9 a to c);D-M3-19-8the flush hint: unread formula, newest message, t0 at the window end (note 9 d);D-M3-19-9presence.Viewin the kernel withNoneandLiveRoutesfailing closed (note 10);D-M3-19-10the job steps, kind codes, onenidper hint, at least once (note 11);D-M3-19-11the provider port andno_provider(note 12);D-M3-19-12the result classes,throttledwithout failure count, the410timestamp rule (note 13);D-M3-19-13the skip reasons ofbothy.push.skippedand the query exceptions (note 15);D-M3-19-14insert-if-absent for following (note 17);D-M3-19-15the badge count, the badge sync with its two unique jobs,KindSyncand the completeClearedlist (notes 12, 16). Rulings 3, 4, 7, 8, 10, 11, 12 and 15 also go into 13 (note 18).
Tests (write them first)¶
Test world: 07 10 and 11.1 (space Support with channels Allgemein and Eskalation, operator Lotse; G-1 Alex trigger on Support; group Leser with Sam, G-4 read on Support/Allgemein; G-5 Lotse write and upload on Support; Marketing with G-2 Sam trigger and operator Texter; Robin without a grant on Support; the DM between Alex and Sam; the Owner). Devices are created with the identity test helpers; registrations are fixture rows written through the pgtest connection, because registerPush comes with M3-22: Alex phone (ios, provider apns), Sam phone (android, provider fcm), the Owner phone (ios, provider apns). Providers are fakepush.FakeProvider registered as apns and fcm. Events are produced by the real use cases through the HTTP chain, except access.expiring, which the test appends through the sync appender (M1-30 is not in the closure). The consumer runs through the M2-01 runner in role worker with the jobs runtime in role api (insert only), as M2-01 note 15 does; job handlers are called directly after moving the fake clock. Durations and limits are fixture values of the test configuration. A job is identified by its args; "hints for X" counts the distinct NotificationIDs of jobs with RecipientID = X.
Unit, internal/notify/domain (M3-17's TestDomainPurity passes over the new files):
TestKindCodesStable:KindCodeof the eight kinds in the order ofNotificationKindgives 1 to 8 andKindSyncgives 9;KindFromCodeinverts it; 0 and 10 are errors.TestApplyResult: one row per line of the table of Design note 13:acceptedresetsfailure_countfrom 3 to 0 and setslast_success_at;disablewith each reason;disabletoken_invalidwithInvalidSincebeforeCreatedAtchanges nothing;retryatfailure_countpush.max_consecutive_failures - 1deactivates withrepeated_failures;throttledkeeps the count;config_errorandprogram_errorchange nothing and do not retry.TestRetryDelay: attempts 1 to 10 with base and cap from the fixture: doubling, capped, never belowretryAfter, jitter bounds.TestRunActive:queued,running,waiting_inputactive;completed,failed,cancellednot.
Unit, internal/notify/app with fakes for every port (authz, sync, messaging, chat, identity, presence.View, Scheduler, PushProvider, RegistrationStore, the clock):
TestClassify_MessageCreated: precedencedmovermentionovermessage; author never a candidate; agents, apps and inactive people dropped;silent,import,system, anchor (kind = run) andkind = systemgive nothing; amessage.updatedgives nothing (13 2, R-PN-11).TestClassify_RunStateChanged: each state against Design note 4 b, including the starter atcancelledand nomessagehint for a run without agent message.TestClassify_AccessExpiring: audience a human givesadminwithgrant_idorgroup_id; audience an agent or an inactive human gives nothing.TestClassify_DevicePending: audience an active human givessecuritywithdevice_idas target and no thread; an inactive human gives nothing; the pending device is never a route.TestAgentMessageDuringRun: with fake anchors and fake history: active state before the message'sseqsuppresses; a terminal state before it does not; a run of another agent does not; no anchor does not; a run that ended after the message'sseqstill suppresses (the processing-time state is ignored).TestLastAgentMessage: newest message before the end event wins; a message created after the end is skipped; none gives no message ID.TestGather_OneCallPerPortPerEvent: 50 readers give one call each toLevelFacts,QuietRules,ValuesFor,ActiveForUsers,Active,Connections; a stored switch"yes"counts as unset.TestLiveRoutes_DefaultFailsClosed: with a fakepresence.Viewthat reports a connection, the defaultLiveRoutesreturns an internal error and the round rolls back; withpresence.Noneno decision of the golden combinations yieldsgatewayorpush_delayed.TestSend_Steps: registration disabled, device revoked, too old,push.deliverdenied, no provider: noSend, the right skip reason;Sendnever runs inside a transaction (the fake unit of work asserts it); the semaphore never lets more thanpush.max_concurrent_requestscalls run at once (100 parallel jobs with a blocking fake).TestNotificationCarriesNoText: walksNotificationand the argsPushSend,CoalesceWindow,CoalesceFlush,BadgeSync,BadgeSyncHoldwithreflect: onlyids.ID,[]ids.ID,int64,booland the kind string ofNotification;jobs.ValidateArgsTypeaccepts the five args types.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire):
TestMigrationPushRegistrations: columns, types, defaults, both checks, the three FKs withON DELETE CASCADE, UNIQUE(provider, token), the unique partial index onpreview_token_hash, the two indexes; a secondmigrate upchanges nothing; deleting a device removes its rows.TestPushRegistrationsActive: only rows withdisabled_at IS NULL, grouped and ordered; the DTO has no token.TestJobsUniqueInsertAndPending(platform/jobs): a second unique insert of equal args returnsInserted = falseand the firstScheduledAt; other args insert; with a field taggedriver:"unique"args that differ only in untagged fields do not insert;PendingTxfinds the job until it completes and not after; outside a unit of work both return an internal error.TestTenMessagesInOneWindow_AtMostTwoHints(PLAN row, 13 14): Alex creates thread T in Support/Allgemein (so he follows it withall) and marks T read up to his own first message (putReadStatewithlast_read_seq1, so the unread count starts at zero); the Owner writes ten messages in T at fake clock offsets 0 to 45 seconds within onenotify.coalesce_window: the consumer creates one hint for Alex (onenotify.push_sendwithCount1 for his phone), onenotify.coalesce_windowand onenotify.coalesce_flushat the window end; running the window and flush handlers at the end creates the second and last hint withCount10. Variant: Alex reads T up to the tenth message before the end (putReadState): the flush creates nothing and countsread. Variant: a mention of Alex inside the window goes at once and leaves the window unchanged.TestRunWithThreeAgentMessages_ExactlyOneRunDone(PLAN row, 13 14): Sam setsallon T throughputNotificationPreference; Alex mentions Lotse in T and the consumer runs: Sam gets onemessagehint for Alex's message, which opens Sam's window; the fake clock moves pastnotify.coalesce_windowand the window and flush handlers run. Then Lotse polls (pollAgentEvents), starts the run (startRun), writes three messages in T with its agent token and finishes withcompleted(finishRun); the consumer runs again only after all of it committed. Alex gets exactly one hint, kindrun_done, pointing at the third agent message; Sam gets exactly one further hint, kindmessage, pointing at the same message; no other hint for the three messages exists. Variants:failedgivesrun_failedto Alex andmessageto Sam; a cancel throughcancelRungivesmessageto Alex and Sam; a fourth message of Lotse after the end, written once the fake clock has moved pastnotify.coalesce_windowagain, gives normalmessagehints and does not move the pointer; a run that waits for input (awaitRunInput) gives oneinput_neededto Alex, also when Alex storedmuteon T.TestAgentMentionDuringRun_GoesAtOnce: Lotse's second message in the run mentions Sam and the consumer runs: Sam getsmentionat once, which opens Sam's window; after the fake clock has moved pastnotify.coalesce_windowthe run ends and Sam still gets exactly onemessageat the end.TestAZM48_MentionWithoutReadRightGivesNothing(PLAN row, AZ-M48): Sam writes in Marketing/Kampagnen and mentions Alex: the mention row is stored (M1-60); for Alex there is nonotify.push_send, no window job, no flush job and no log record naming him;bothy.push.skippedwithno_accessrose by one.TestRestartMidRound_NoDuplicateNoLoss(PLAN row; pattern of M2-01TestConsumerCrashMidRoundNoDuplicates): writers produce messages in Allgemein while the consumer runs; a test hook inHandleEventrecordspg_backend_pid()after inserting the jobs of some events and blocks; the test ends that backend withpg_terminate_backend; subtests also for a handler error, a panic and a cancelled context. A fresh runner reaches the head; for every message and recipient that the decision admits there is exactly one hint and exactly onenotify.push_sendper registration; no hint is missing.TestRevocationBetweenEventAndSend(13 14): Sam (levelallon T) gets a hint; before the job runs the Owner removes Sam from group Leser; the job sends nothing and countsno_access.TestPushSend_ProviderResults: through the job withFakeProviderprogrammed per result class: the registration columns and the follow-up job as in Design note 13; aRetry-Afterof the fake is respected; a hint whose next retry is pastpush.max_ageis dropped asexpired; the token never appears in captured logs, only a hash prefix.TestPushSend_NoProvider(D-W-108): onlyapnsis registered; Sam'sfcmregistration: the job ends without error, no call, skipno_provider.TestPushSend_TooOld: the clock passesDueAt + push.max_agebefore the job runs: no call,expired.TestAdminHintFromAccessExpiring: an appendedaccess.expiringto Alex gives oneadminhint with thegrant_idas target and no thread; withnotify.admin_enabled = falsestored for Alex none (preference); anaccess.expiringto Lotse gives none.TestSecurityHintFromDevicePending(R-AZ-31): Alex has the activephone; a login of Alex throughCompleteLoginwithTrustUnconfirmedregisters a second device aspending: its unit of work wrote exactly onedevice.pendingwith audience Alex, resourcedeviceand a payload that validates againstDevicePendingEventand holds onlydevice_idandplatform(no name, no key, no app version); the consumer gives Alex exactly onesecurityhint with thedevice_idas target, onenotify.push_sendfor the registration of Alex'sphoneand none for the pending device; Sam and the Owner get nothing; a device registered asactivewrites nodevice.pending; with Alex disabled before the job runs the job countsno_access.TestBadgeCount(13 8, table overBadgeCounter.Forwith Alex): an unread mention in a thread with levelmentionscounts; an unread thread with levelallcounts; an unread thread with levelmentionsand no mention does not; an unread DM thread counts; a read thread does not; a thread whose messages lie belowunread_floor_seqdoes not; a thread in Marketing where Sam mentioned Alex (stored by M1-60, Alex cannot read it) never counts; after the Owner removes Alex's read right a counted thread stops counting; theBadgeof theNotificationthatFakeProviderrecords equalsForat the fake send time andHasBadgeis true.TestBadgeSync(13 8, fake clock): Alex has a counted unread thread T in Support/Allgemein and reads it throughputReadState: onenotify.badge_syncat once, then onenotify.push_sendwithKindSyncfor hisphone, recorded with the lowerBadge,Cleared= T and no thread, message or quiet flag; a second falling read beforenotify.badge_sync_min_intervalends creates one sync at the end of the interval and none before; a read that does not lower the number (a thread with levelmentionsand no mention, or a second read of T) andmarkThreadUnreadgive no sync; Sam'sfcmregistration gets the sync withClearedwhen Sam reads.TestDMHint: a fixture DM channel of Alex and Sam (written through thepgtestconnection, as M3-18 does): Sam's message gives Alex onedmhint, also when it mentions Alex.TestAutomaticFollowing(13 14 "Automatic following"): the Owner creates thread T2; Alex's first answer in T2 creates his row withalland onenotification_pref.updated(audience Alex) in the same transaction; his second answer writes nothing; his message in the main thread of Allgemein writes nothing; with Alex's storedmuteon T2 nothing changes; Lotse's message writes nothing;createThreadby Alex makes him follow the new thread; a test subscriber ofmessaging.MessagePostedregistered afternotifythat vetoes once leaves neither row nor event.TestAZP5_PushOnlyReadableChannels(PLAN M3 exit criterion 2, AZ-P5 for push): in worlds of the random generator of AZ-P8 (M1-49) with random messages and mentions, nonotify.push_sendexists for a recipient who cannot read the thread at decision time, and every recipient of amessagehint is inRecipients.Readersand allowed byauthorize.TestRegistrationsActiveGauge: with an SDK manual reader,bothy.push.registrations.activeper provider equals the active rows.internal/wire: the consumerpushis registered; the five job kinds on queuedelivery; the golden registrations showmessaging.MessagePosted -> notify.OnMessagePostedafter the subscribers ofrunsandsync;TestConfigSchemaBuildslists the keys of Design note 14;make register-checkpasses with the edgenotify -> authz.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/notify/... ./internal/authz/... ./internal/sync/... ./internal/messaging/... ./internal/identity/... ./internal/platform/jobs/... ./internal/kernel/... ./internal/wire/... ./migrations/...make migrations-lock(once after creating the migration),make migrations-lint,make codegen-check,make arch,make register-check,make config-reference(no diff afterwards),make config-doc-check,make signal-checkmake diagrams,make docs,make linkcheckmake check
Acceptance criteria¶
-
push_registrationsexists exactly as 03;PushRegistrations.Activereturns the active registrations without token;make migrations-lintis green. - The consumer
pushcreates hint jobs frommessage.created,run.state_changed,access.expiringanddevice.pendingin the round that moves its cursor; a worker restart in the middle of a round creates no hint twice and loses none. - Ten messages in one window give at most two hints, the second with the unread count; a read thread gives no flush hint.
- A run with three agent messages gives exactly one
run_doneto the starter and onemessageto the others withall, both pointing at the last agent message; agent mentions go at once. - A mention without read right gives no job and no trace (AZ-M48); no hint reaches a person who cannot read the thread (AZ-P5 for push); a revocation between event and send stops the push.
-
notify.push_sendchecks rights, age and provider before sending, sends outside transactions withinpush.max_concurrent_requests, and applies every result class of 13 7.3 to the registration; without a registered provider it ends withno_provider. - Automatic following inserts
allwith onenotification_pref.updatedonly in threads, only for humans and only without an own setting. -
identitywrites onedevice.pendingper device registered aspending; it gives onesecurityhint to the other active devices of that human and none to anyone else. - Every push carries the badge number of 13 8 counted only over readable threads; a read that lowers it gives at most one badge sync per person per
notify.badge_sync_min_intervalto mobile registrations, and none when it does not fall. -
presence.Viewwithpresence.Noneand the defaultLiveRoutesexist for M3-23; the provider port exists for M3-20 and M3-21. - 13, 02, the C4 diagram, signals.md (
quiet_hoursremoved frombothy.push.skipped),configuration.md, the READMEs, the CHANGELOG and the rowsD-M3-19-1toD-M3-19-15are updated;make register-check,make archandmake signal-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
notifyupdated in English (status, portsPushRegistrationsand the outgoing ports, the tablepush_registrationswith retention classsecurity, the consumerpushwith its sources and candidates, the jobsnotify.push_send,notify.coalesce_window,notify.coalesce_flush,notify.badge_sync,notify.badge_sync_hold, following, the badge, the skip reasons, what M3-20 to M3-23 plug in), keeping the lineMay use the ports of these modules: ...and the section## Tablesin their form for M1-06 (withauthzadded); READMEs ofauthz,sync,messaging,chat,identityandinternal/platform/jobsname their additions (identity: the writer ofdevice.pending). - Rulings of Design note 19 recorded in
docs/DECISIONS.md(one row each with reason); rulings 3, 4, 7, 8, 10, 11, 12 and 15 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>.