M3-17: Notification decision as a pure function¶
Status: ready | Depends on: M1-05 | Milestone: M3
Goal¶
After this ticket the package internal/notify/domain holds the per-recipient decision of 13
"Decision per recipient" as side-effect-free functions with a documented decision table.
ResolveLevel applies the seven steps of 13 3.1 "Effective level" including a temporary
muted_until; InQuietHours applies 13 3.4 "Quiet hours with time zone" to the local wall time of
each rule's zone; Decide takes the facts about one hint for one person (kind, the result of the
push.deliver check, the level sources, the personal switches, the quiet-hour rules, the person's
active devices with their live connection, focus and push registrations, the bundling window of
the thread and the two window lengths) and returns exactly one outcome: no hint with a skip
reason, collect into the open window, or send now, quiet or normal, with one route per device
(notification.created over the gateway, push now, or push after the desktop delay). Flush
decides the one hint at the end of a bundling window, and ReleaseDelayed drops a delayed push
the person has read in the meantime. The functions read no database, call no authorize, use no
clock, load no time zone and read no configuration, so the rules are proven by tables: a golden
table of every combination of kind, effective level, muted_until, quiet hours, the two personal
switches, activity in the thread, active desktop and bundling window; quiet hours in
Europe/Berlin on both changeover days and across midnight; and input_needed under mute
arriving quietly in quiet hours. The tickets of the push path (M3-18 settings, M3-19 consumer and
bundling, M3-23 desktop delivery) call these functions instead of re-implementing them.
Read first¶
docs/spec/13-push-notifications.md: 1 "Goals" (points 2 to 4); 2 "Kinds of notifications and recipients" (the table, the paragraph "No notification is created for"), 2.1 "Agent messages during a run", 2.2 "Report channels"; 3.1 "Effective level"; 3.2 "Automatic following" (owned by M3-19); 3.3 "Personal switches"; 3.4 "Quiet hours with time zone"; the whole of 4 "Decision per recipient": 4.1 "Flow" (flowchart), 4.2 "Permissions", 4.3 "Level and kind", 4.4 "Active on a device", 4.5 "Coalescing", 4.6 "Delivery path per device"; 6.3 "Gateway eventnotification.created"; 9 "Delivery measurement" (row t0); 12 "Execution in the server" (point 3: the decision is a pure function in the domain ofnotify); 14 "Tests" (rows "Decision table", "Quiet hours inEurope/Berlin", "input_neededwithmute", and the rows owned by M3-19 and M3-23 that this ticket mirrors at decision level); 15 "Decisions of this chapter" (R-PN-01 to R-PN-06); 17 "Configuration values of this chapter".docs/spec/03-data-model.md:notification_preferences(level,muted_until, the paragraph "Effective level" after the table, R-DM-16),quiet_hours(weekdays,start_local,end_local,time_zone,enabled, checkweekdays BETWEEN 1 AND 127),users.time_zone,channels.default_notification_level,user_settings(notify.*keys),devices(platform,state),push_registrations(disabled_at),threads.last_message_seq,messages.thread_seq,read_states(last_read_seqand the formula "Unread" withthreads.unread_floor_seq).docs/spec/04-sync-protocol.md: 6.1 "Endpoint and version" (a device holds exactly one connection), 6.2 "Ops" (rowfocuswithidle), 7.6 "Consumers in the worker (push, webhooks)" (every job checkspush.deliveragain before it sends).docs/spec/05-actions.md: 4.9 rowspush.deliver,notification_pref.update,quiet_hours.update,settings.read; 6, table of ephemeral events, rownotification.created.docs/spec/07-permissions.md: 11 test row AZ-M48 (a mention without read right gives no push; owned by M3-19, mirrored here at decision level).docs/spec/01-product.md: 10.1 "When a notification arrives"; screen D9 (quiet hours "all day", temporary muting "until today 18:00"); R-PR-05, R-PR-06.docs/spec/02-architecture.md: 4.2 "Module list" (rownotify), 4.3 and 4.4 (rowsnotify), 5 "Layers within a module" (domain imports onlykernel-core), 6 "Code layout", 11.5 "Worker consumers (push, webhooks)".docs/spec/23-quality.md: "Documentation in code" (Go: revive rulesexportedandpackage-comments).docs/api/openapi.yaml: schemasNotificationKind,NotificationLevel,NotificationPreference(muted_until),QuietHoursRule(weekdays1 to 127,start_localandend_localasHH:MM,time_zone),NotificationCreatedPayload(quiet,count, optionalthread_id). Read only; this ticket enables no operation.docs/api/asyncapi.yaml: messageevent.notification.created.docs/operations/configuration.md:notify.user_default_level,notify.coalesce_window,notify.mobile_delay_when_desktop_active,notify.default_input_needed_breaks_quiet_hours,notify.default_admin_enabled(this ticket reads none of them; the caller passes their values).- ADRs 0008 (hexagonal modules and checked dependencies), 0031 (push without content), 0051 (worker consumers of the event log).
docs/DECISIONS.md: D-PN-01 to D-PN-04, D-PN-07, D-PR-01, D-W-100; section 10 "Rulings of the build milestones" (format of the rows you add).docs/OPEN-QUESTIONS.md: OQ-05 (quiet hours make hints silent, not muted; default "yes, as in chapter 13", answer due before M3-19). This ticket builds the default; a different answer changes one function.docs/security/threat-model.md: T-PU-01 (push metadata; only IDs leave the server).docs/RISKS.md: no RK row names this ticket; RK-37 (presence state per api node) concerns the presence port of 13 12 point 4 (M3-23) and multi-node operation (M7), not the decision, which gets the connections as facts.docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Unit"); section 9 "M3 Platform services": "Goal", "Exit criteria" point 1, "Test strategy" ("golden table of the notification decision"), "Red team" (content in push payloads), rows M3-17 (this ticket), M3-18, M3-19, M3-20 to M3-24 (who builds what next).- Dependencies: the header names M1-05; its closure adds M1-01. Nothing of M2 or of the other M1
tickets is needed, because the decision imports no foreign module. Read
docs/briefs/M1-05-server-skeleton.md(layers,kernel/ids,kernel/errs, the pattern ofTestCodesMatchChapter06for finding the repository root) and, for the source offocusandidle,docs/briefs/M1-66-gateway-focus-typing.md. - Code on the base branch (merged names win, D-W-100):
services/server/internal/notify/doc.goandREADME.md(port package only, nodomain/yet),services/server/internal/kernel/ids(ids.ID,IsZero),services/server/internal/kernel/errs(errs.New,errs.CodeInternalError,errs.As, the field(*errs.Error).Code),services/server/architecture/modules.yaml(modulenotify),services/server/.go-arch-lint.yml(componentnotify-domainmay depend only on itself andkernel-core).
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:
- The decision per recipient follows 13 4.1 in this order: the
push.deliverresult, kind and effective level, activity in the thread, quiet hours, bundling window, route per device; it is a pure function in the domain ofnotifythat gets facts and returns a decision (13 12 point 3, PLAN row M3-17, R-PN-01). - The effective level is the first present value of the seven steps of 13 3.1; if the chosen own
row has
muted_untilin the future,muteapplies (13 3.1, 03notification_preferences). - Level and kind:
allallows every kind,mentionsevery kind exceptmessage,muteonlyinput_needed(own run),securityandadmin;admindepends only onnotify.admin_enabled;input_neededandsecuritycome at every level (13 2, 13 4.3, R-PN-03, R-PR-05). - Recipients of 13 2: the author of a message never gets a hint for it;
run_done,run_failedandinput_neededgo only to the person who triggered the run; only active humans are recipients (13 2, 2.1, 4.2). - A device of the person with a gateway connection whose focus contains the thread and that does
not report
idlemeans no hint for that thread on any device (13 4.4). - Quiet hours: now converted into the zone of the row gives weekday and time of day; same-day and
across-midnight windows with weekday bits as in 13 3.4 points 2 to 4; the server never computes
the boundaries as instants. In quiet hours a hint is quiet, never dropped; the only exception is
input_neededwithnotify.input_needed_breaks_quiet_hours(13 3.4, R-PN-05, R-PR-06). - Bundling per recipient and thread with
notify.coalesce_window: the first hint goes at once and opens the window, furthermessagehints are collected, one hint with the unread count goes at the window end;mention,dm,input_needed,run_done,run_failedandsecurityalways go at once (13 4.5, R-PN-06). - Route per active device: a live connection gets
notification.created; otherwise each active push registration gets a push; with an active desktop, pushes to mobile devices waitnotify.mobile_delay_when_desktop_activeand are dropped if read meanwhile; a device with neither gets nothing (13 4.4, 4.6). - The decision carries only IDs, kinds, flags, counts and times, never text (ADR 0031, T-PU-01).
- Golden table test of all combinations; quiet hours in
Europe/Berlinon both changeover days and across midnight;input_neededwithmutequiet in quiet hours (PLAN row M3-17, 13 14). - No I/O, no
authorize, no clock, no time zone loading, no configuration reading ininternal/notify/domain(PLAN row M3-17; 02 5 "Layers within a module", row "Domain").
Scope¶
All paths under services/server/ unless noted. This ticket creates no migration, enables no
operation, appends no event, schedules no job, records no metric and adds no configuration key.
Every new Go file starts with // SPDX-License-Identifier: Apache-2.0, like the existing
internal/notify/doc.go (make licenses).
Create:
internal/notify/domain/doc.go: package comment of the domain layer ofnotify(what lives here, that it imports onlykernel-coreand the standard library without I/O, and that callers pass facts in). If the merged code already has this file with a package comment only (M1-05 adds such files when go-arch-lint needs them), extend it instead.internal/notify/domain/kinds.go:Kind,Level,Platform,LevelSource,Outcome,SkipReason,Viawith their constants,Validmethods,Kind.HasThread,Kind.Urgent,Platform.Desktop,Platform.Mobileand the list functionsKinds,Levels,Platforms(design notes 2 and 3).internal/notify/domain/level.go:Preference,LevelFacts,EffectiveLevel,ResolveLevel(design note 4).internal/notify/domain/quiet.go:LocalTime,ParseLocalTime,LocalTime.String,QuietRule,InQuietHours(design note 6).internal/notify/domain/decide.go:Switches,Connection,Device,Window,Policy,RecipientFacts,Route,Decision,Decide(design notes 2, 3, 5, 7, 8).internal/notify/domain/flush.go:FlushState,Flush(design note 7).internal/notify/domain/delay.go:ReleaseDelayed(design note 9).- Tests (design of each in "Tests"):
level_test.go,quiet_test.go,decide_test.go,flush_test.go,delay_test.go,table_test.go,contract_test.go,purity_test.go,fuzz_test.go, andtestdata/decision_table.golden.
Change:
internal/notify/README.md(English, like the file): status line, section "Code" (domain/now exists), a new section "Decision per recipient" with the decision table of design note 3, the input table of design note 2 (which module or port supplies each fact, for M3-18, M3-19 and M3-23), the window rules of design note 7, the route rules of design note 8 and the rulings of design note 12. Keep the lineMay use the ports of these modules: ...and the section## Tablesexactly as they are; M1-06 parses them.services/server/CHANGELOG.md: entry under "Unreleased", "Added".docs/spec/13-push-notifications.md(not core contract; English, like the chapter): in 2 after the table that a message in a DM channel givesdmand nevermessageormention, also when it mentions r (design note 5); in 3.4 after point 3 the sentence forstart_local = end_local(design note 6); in 4.4 that hints without thread (security,admin) know no "Active in the thread" (design note 5); in 4.5 that hints without thread open and use no window (design note 5), the window rules of design note 7 as new points 4 and 5, and in point 2 the unread count by the formula of 03read_statesinstead of the shortened difference; in 4.6 that no route on any device gives no hint and no window (design note 8).docs/DECISIONS.md, section 10: rowsD-M3-17-1toD-M3-17-7(design note 12).
No new top-level Makefile target.
Out of scope¶
- Tables
notification_preferencesandquiet_hours, their migrations, the operationslistMyNotificationPreferences,putNotificationPreference,deleteNotificationPreference,listMyQuietHours,createQuietHoursRule,putQuietHoursRule,deleteQuietHoursRule, the eventsnotification_pref.updatedandquiet_hours.updated, cleanup at archive, validation oftime_zonenames on write and the batched query of the three targets (13 12 point 2): M3-18, which proves the order of 13 3.1 against PostgreSQL throughResolveLevel. - The worker consumer
push(13 12, ADR 0051) and everything around one decision: which event gives which kind to which candidate (13 2 and 2.1: nomessagehint for agent messages during a run, exactly onerun_doneto the starter andmessageto others withall, nothing for edits,silent,importandsystem), candidates fromauthz, thepush.delivercheck before every send (04 7.6), automatic following (13 3.2), the window state per recipient and thread as a unique scheduled River job, the unread count, reading the configuration keys and the personal switches, loading time zones withtime.LoadLocation, the jobnotify.push_sendand the metricbothy.push.skipped: M3-19 (with AZ-M48 end to end, "ten messages in one window" and "exactly onerun_done"). - APNs and FCM payloads,
interruption-level, priorities,time-sensitivewithpush.apns.time_sensitive_enabled,push.ttl,push.max_age, the error tables: M3-20 and M3-21. The badge and the badge sync of 13 8 are not part of the decision. - Push registrations, the preview token and endpoint,
notify.preview_mode: M3-22. notification.createdover the gateway, the presence port that supplies connections, focus andidle(13 12 point 4), the job of the desktop delay and its read check throughReleaseDelayed: M3-23 (M1-66 stores focus andidle).- The delivery measurement of 13 9 and
perf.push_handoff_p95: M3-24. Showing quiet hints on devices: M4 and M5. - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-17-notify-decision; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(notify): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- This ticket has unit tests only: no listener, no container, no PostgreSQL, no network. Tests read only
docs/spec/13-push-notifications.md,docs/spec/03-data-model.mdanddocs/api/openapi.yamlin the working tree, the package's owntestdata/and its own source files (purity test). - Production code in
internal/notify/domainimports only standard library packages without I/O andkernel-core(kernel/ids,kernel/errs);make archenforces the layer andTestDomainPuritythe rest. Nocontext.Context, no logger, no clock (time.Now,time.Since,time.Until), no time zone loading (time.LoadLocation,time.LoadLocationFromTZData), no randomness, no configuration access in production code of this package. - No push provider, no device, no real time zone file outside the Go toolchain: tests load
Europe/Berlinwithtime.LoadLocationafter a blank import oftime/tzdatain a test file (with a comment saying why), so they do not depend on the host's zone database. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Purity and place. The decision lives in
internal/notify/domain(13 12 point 3; 02 5: the domain layer holds pure functions and imports onlykernel-core). Its inputs are value types of this package that mirror what the consumer of M3-19 gathers through ports; the domain imports no other module. The functions are deterministic, never mutate their input (copy slices before sorting) and takeNowas an input. They never callauthorize: thepush.deliverresult is the factAllowed, gathered by the caller with current rights (13 4.2, 04 7.6, D-B7). They never load a time zone: each quiet-hour rule carries its*time.Location, which the caller loads fromquiet_hours.time_zoneor, when NULL,users.time_zone. They never read configuration: the caller passes the values ofnotify.coalesce_window,notify.mobile_delay_when_desktop_activeandnotify.user_default_level, and the personal switches already resolved with their defaults (notify.default_admin_enabled,notify.default_input_needed_breaks_quiet_hours). -
Inputs. Signatures (names may follow the merged code, D-W-100; the fields are binding):
type Kind string // NotificationKind: message, mention, dm, run_done, run_failed, input_needed, security, admin type Level string // NotificationLevel: all, mentions, mute type Platform string // 03 devices.platform: ios, android, macos, windows, web, cli type LocalTime int // minutes after local midnight, 0 to 1439 (HH:MM of QuietHoursRule) type Preference struct { // one own row of notification_preferences Level Level MutedUntil time.Time // zero when NULL } type LevelFacts struct { // sources of 13 3.1 for the thread of the hint Thread, Channel, Space *Preference // own rows; nil when absent ChannelDefault Level // channels.default_notification_level; "" when NULL DirectMessage bool // channels.kind = 'dm' PersonalDefault Level // user_settings['notify.default_level']; "" when unset InstanceDefault Level // value of notify.user_default_level; required } type Switches struct { // 13 3.3, resolved with their defaults by the caller AdminEnabled bool // notify.admin_enabled InputNeededBreaksQuietHours bool // notify.input_needed_breaks_quiet_hours } type QuietRule struct { // one row of quiet_hours Weekdays uint8 // bit 0 = Monday, 1 to 127 Start LocalTime // start_local End LocalTime // end_local; End <= Start crosses midnight Location *time.Location // zone of the row or users.time_zone; loaded by the caller Enabled bool } type Connection struct { // the live gateway connection of a device (one per device, 04 6.1) Focus []ids.ID // threads of the last focus op Idle bool // idle of the last focus op } type Device struct { // an active device of the recipient (devices.state = 'active') ID ids.ID Platform Platform Connection *Connection // nil when not connected Registrations []ids.ID // active push registrations (disabled_at IS NULL) } type Window struct{ End time.Time } // window of (recipient, thread); zero: none; open while Now < End type Policy struct { CoalesceWindow time.Duration // notify.coalesce_window MobileDelay time.Duration // notify.mobile_delay_when_desktop_active } type RecipientFacts struct { // one hint for one person r Kind Kind Now time.Time ThreadID ids.ID // zero for security and admin Allowed bool // push.deliver for r on the thread; for security and admin the recipient rule of 13 2 Author bool // r wrote the message the hint points at RunStarter bool // r is runs.triggered_by of the run the hint points at Level LevelFacts // kinds with thread only Switches Switches QuietHours []QuietRule // every row of r, enabled or not Devices []Device // every active device of r Window Window // kinds with thread only Policy Policy }Where each fact comes from (write this table into the README for M3-18, M3-19 and M3-23):
Kind,ThreadID,AuthorandRunStarterfrom the event the consumerpushreads (M3-19, 13 2);Allowedfromauthorize(r, push.deliver, thread)in M3-19 (13 4.2; it is false for a principal who is not an active human); the own rows ofLevelFactsfromnotification_preferences(M3-18),ChannelDefaultandDirectMessagefromchat,PersonalDefaultand the switches fromuser_settingsthroughsync,InstanceDefaultfromnotify.user_default_level;QuietHoursfromquiet_hours(M3-18) with the zone of the row orusers.time_zonefromidentity;Devicesfromidentity(active devices),push_registrations(M3-22) and the presence port of the gateway (M3-23);Windowfrom the window state of M3-19;Policyfrom the configuration. -
Decide(in RecipientFacts) (Decision, error): the decision table of 13 4.1, evaluated in this order; the first row that decides wins:Step Condition Outcome SkipReasonvalidate input invalid (note 10) error, zero Decisionallowed Allowedis falsenonenot_allowedrecipient message,mentionordmandAuthor;run_done,run_failedorinput_neededand notRunStarternonenot_recipientlevel kind with thread and the effective level does not allow it (table below) nonelevellevel adminand notSwitches.AdminEnablednoneadmin_disabledactivity kind with thread and a device is active in the thread (note 8) noneactive_in_threadquiet quiet hours hold (note 6) and not ( input_neededandInputNeededBreaksQuietHours)sets Quietfor a sendwindow messageand the window is open (note 7)collectroute no device has a route (note 8) noneno_routesend otherwise sendEffective level Kinds with thread it allows allmessage,mention,dm,run_done,run_failed,input_neededmentionsthe same except messagemuteonly input_neededsecuritypasses the level step at every level,adminonly by its switch; both have no thread, so no level is resolved for them. Output (every field documented in code):type Decision struct { Outcome Outcome // none, collect, send Skip SkipReason // only with none Kind Kind Effective EffectiveLevel // kinds with thread once the level step ran; zero before it and for security and admin Quiet bool // send only: deliver without sound (13 3.4) Count int64 // send only: 1, or the unread count of a Flush OpensWindowUntil time.Time // send only: end of the window this hint opens; zero otherwise Routes []Route // send only: in the order of Devices } type Route struct { DeviceID ids.ID Via Via // gateway, push, push_delayed Registrations []ids.ID // push and push_delayed: all active registrations of the device NotBefore time.Time // push_delayed: Now + MobileDelay; zero otherwise }The skip reasons are internal Go constants of this package (
not_allowed,not_recipient,level,admin_disabled,active_in_thread,no_route, and forFlushnothing_collectedandread), not API values. Mapping them to the attribute values ofbothy.push.skipped(docs/observability/signals.md) is M3-19's work; no skip reason of this package stands for quiet hours, because quiet hours make a hint quiet and never drop it (R-PN-05). -
ResolveLevel(f LevelFacts, now time.Time) (EffectiveLevel, error)withEffectiveLevel{Level Level; Source LevelSource; Muted bool}(13 3.1; Ruling for docs/DECISIONS.md,D-M3-17-3). The first present source wins:Thread,Channel,Space(sourcesthread,channel,space),ChannelDefault(channel_default),DirectMessagegivesall(direct_message),PersonalDefault(personal_default),InstanceDefault(instance_default). Only an own row hasmuted_until: if the chosen row'sMutedUntilis strictly afternow, the level ismuteandMutedis true; oncenowreachesMutedUntilthe row's ownlevelapplies again. Amuted_untilon a row that was not chosen has no effect ("of the selected row"). A row stored asmutestaysmuteafter itsmuted_untilpassed. -
Recipients and kinds without thread (13 2, 4.3; Ruling for docs/DECISIONS.md,
D-M3-17-2). The domain does not classify events into kinds; it guards the recipient column of 13 2 so that a wrong caller fails closed: the author never getsmessage,mentionordmfor their own message, andrun_done,run_failedandinput_neededreach only the starter of the run, which also makes "input_needed(own run)" of 13 4.3 hold.securityandadminhave no thread: no level, no activity in a thread and no bundling window apply to them (ThreadIDandWindoware ignored), but quiet hours do, because 13 3.4 namesinput_neededas the only exception. Admhint requiresLevelFacts.DirectMessage, andmessage,mentionand the run kinds require a channel that is not a DM channel (13 2: a message in a DM channel isdm, also when it mentions r, because both kinds have the same level and go at once; 09 3.4: DM channels trigger no agent); a mismatch is invalid input. 05 4.9 definespush.deliveron a thread only, so forsecurityandadminthe factAllowedis the recipient rule of 13 2 as the caller evaluates it at decision time (M3-19); the domain treats both cases alike. -
Quiet hours (13 3.4; Ruling for docs/DECISIONS.md,
D-M3-17-4).InQuietHours(rules []QuietRule, now time.Time) (bool, error)convertsnowinto each enabled rule'sLocationand takes the weekday (Monday is bit 0) and the wall time in seconds after midnight; start and end are whole minutes.Start < End: quiet if the weekday's bit is set andStart <= time < End.End <= Start: quiet if the weekday's bit is set andtime >= Start, or the previous weekday's bit is set andtime < End(Monday's previous day is Sunday, bit 6).Start == Endis therefore a window of 24 hours fromStarton each set weekday, which is how screen D9 "all day" is stored (00:00to00:00). Any matching enabled rule makes the person quiet; disabled rules are ignored; no rule means not quiet. No boundary is ever computed as an instant, so a rule inside the skipped hour of a spring changeover matches nothing that day and one inside the repeated hour of an autumn changeover matches twice.ParseLocalTimeaccepts exactly theHH:MMpattern ofQuietHoursRule(00:00to23:59) andLocalTime.Stringwrites it back. For a delayed push (note 8) theQuietflag of the decision stays: the gateway copy of the same hint went out with it at once, and the delay is at mostnotify.mobile_delay_when_desktop_active. -
Bundling and
Flush(13 4.5; Ruling for docs/DECISIONS.md,D-M3-17-5). The window of (r, thread) is open whileNowis beforeWindow.End; atNow == Window.Endit is closed.Decidefor a kind with thread and no open window sends and setsOpensWindowUntil = Now + CoalesceWindow(the first hint opens the window, whatever its kind).messagein an open window givescollectwithout routes.mention,dm,input_needed,run_doneandrun_failedin an open window send at once and leave the window unchanged (OpensWindowUntilzero); they replace the same slot on the device through the thread ID (13 6.1, adapters). WithCoalesceWindowzero no window opens, so every hint goes at once.Countof an immediate hint is 1.Flush(in RecipientFacts, s FlushState) (Decision, error)withFlushState{Collected int; Unread int64}runs at the window end:in.Kindmust bemessage;Collectedzero givesnonewithnothing_collected;Unread(03read_states"Unread", computed by the caller) zero or less givesnonewithread; otherwise the steps allowed, level, activity, quiet and route of note 3 run again at the flush time (rights, level and quiet hours may have changed;AuthorandWindoware ignored, a bundle has no single author) and a send carriesCount = Unreadand opens the next window (OpensWindowUntil = Now + CoalesceWindow). A flush that sends nothing leaves the window closed. Reason: 13 1 point 3 "One notification per thread and window" holds under a steady stream of messages, and the first message after a quiet period still goes at once. -
Activity and routes (13 4.4, 4.6; Ruling for docs/DECISIONS.md,
D-M3-17-6). A device is active in the thread if it has aConnection,Idleis false andFocuscontainsThreadID; any such device, of any platform, stops the hint on all devices. The desktop is active if a device with platformmacosorwindowshas aConnectionwithIdlefalse. Routes, one per device in the order ofDevices: a device with aConnection(idle or not) getsgateway; a device without one but with registrations getspush_delayedwithNotBefore = Now + MobileDelayif it isiosorandroid, the desktop is active andMobileDelayis above zero, elsepush; a device with neither gets no route and is left out.webandclifollow the same rules (they have no registrations in practice). No route at all givesnonewithno_route, and such a decision opens no window. -
ReleaseDelayed(hintSeq, readSeq int64) bool(13 4.4 "Desktop active"; part ofD-M3-17-6): true sends the delayed push, false drops it.hintSeqis thethread_seqof the message the hint points at (for a flush hintthreads.last_message_seqat the flush);readSeqis the read position of 03read_states(the greater oflast_read_seqandthreads.unread_floor_seq). The push is dropped exactly whenreadSeq >= hintSeq. A hint without a message (hintSeqzero or less:security,admin, or a run hint without a message) is always released. -
Invalid input (Ruling for docs/DECISIONS.md,
D-M3-17-7).ResolveLevel,InQuietHours,DecideandFlushvalidate first and return an error made witherrs.New(errs.CodeInternalError, ...)and the zeroDecision(outcomenone, no routes) for: an unknownKind,LevelorPlatform; a zeroNow; a negativeCoalesceWindoworMobileDelay; a kind with thread and a zeroThreadID;securityoradminwith a non-zeroThreadID; an empty or unknownInstanceDefaultfor a kind with thread; the DM mismatch of note 5; a zero or duplicate device ID, a zero registration ID or a zero thread ID in a focus; a rule withWeekdaysoutside 1 to 127, a time outside 0 to 1439 or a nilLocation; forFlusha kind other thanmessageor a negativeCollected. Fail closed: a broken input never yieldssendorcollect. The error text names the field, never a value. -
No content.
DecisionandRoutehold only enum strings of this package,ids.ID,bool,int64andtime.Time; no field can carry a name, a text or a token (ADR 0031, T-PU-01, the M3 red team item "content in push payloads").TestDecisionCarriesNoTextholds this. -
Rulings for
docs/DECISIONS.mdsection 10, one row each with reason and place:D-M3-17-1the decision is split intoResolveLevel,InQuietHours,Decide,FlushandReleaseDelayed, takes facts andNowin and never callsauthorize, reads a clock, loads a zone or reads configuration (note 1);D-M3-17-2recipient guards and kinds without thread (note 5);D-M3-17-3muted_untilstrictly after now and only on the chosen row (note 4);D-M3-17-4quiet-hour details: minute times, second precision,start_local = end_localas 24 hours, zone loaded by the caller, quiet flag kept for delayed pushes (note 6);D-M3-17-5window rules andFlush(note 7);D-M3-17-6activity, routes andReleaseDelayed(notes 8 and 9);D-M3-17-7invalid input (note 10). Rulings 2, 4 (24 hours), 5 and 6 (no route) also go into 13 (Scope, "Change").
Tests (write them first)¶
Unit tests in internal/notify/domain (PLAN 5, row "Unit"). The recipient is Alex; Sam wrote the
message or started the run in the "not the recipient" cases; Robin gets the admin hints. Alex's
active devices are named by platform: phone (ios, one registration), tablet (android, one
registration), desktop (macos), laptop (windows) and console (web). Durations in tests
are fixture values of Policy, not the configuration defaults.
TestResolveLevel_Order(13 3.1): one case per step, each removing the sources above it: thread row, channel row, space row, channel default, DM channel givesall, personal default, instance default; assertsLevelandSource; a DM channel with a channel default uses the default (step 4 before 5).TestResolveLevel_MutedUntil: chosen thread rowallwithMutedUntilone second after now givesmuteandMuted; equal to now givesall; in the past givesall; a space row muted into the future under a thread rowmentionsgivesmentions, not muted; a row stored asmutewithMutedUntilin the past staysmute.TestInQuietHours_EuropeBerlinChangeoverDays(PLAN row, 13 14): rule every day (127),22:00to07:00,Europe/Berlin. Spring changeover (Sunday 2026-03-29,02:00CET becomes03:00CEST):2026-03-28T20:59:59Z(Saturday 21:59:59) not quiet,2026-03-28T21:00:00Z(22:00) quiet,2026-03-29T00:59:59Z(01:59:59 CET) quiet,2026-03-29T01:00:00Z(03:00 CEST) quiet,2026-03-29T04:59:59Z(06:59:59) quiet,2026-03-29T05:00:00Z(07:00) not quiet. Autumn changeover (Sunday 2026-10-25,03:00CEST becomes02:00CET):2026-10-24T19:59:59Znot quiet,2026-10-24T20:00:00Zquiet,2026-10-25T00:30:00Z(02:30 CEST) quiet,2026-10-25T01:30:00Z(02:30 CET) quiet,2026-10-25T05:59:59Z(06:59:59 CET) quiet,2026-10-25T06:00:00Z(07:00 CET) not quiet. Counting every minute from 12:00 UTC on the Saturday to 12:00 UTC on the Sunday gives 480 quiet minutes in spring and 600 in autumn.TestInQuietHours_RuleInsideChangeoverHour: rule Sunday only (64),02:15to02:45,Europe/Berlin: no minute of the UTC day 2026-03-29 is quiet; on the UTC day 2026-10-25 exactly 60 minutes are quiet, among them00:30:00Zand01:30:00Z, not00:45:00Zand not01:14:59Z.TestInQuietHours_AcrossMidnightAndWeekdayBits(13 14 "across midnight, weekday bits"), all rules inEurope/Berlin: rule Friday only (16),22:00to07:00:2026-10-23T21:30:00Z(Friday 23:30) quiet,2026-10-24T04:30:00Z(Saturday 06:30) quiet,2026-10-24T05:00:00Z(Saturday 07:00) not,2026-10-24T21:00:00Z(Saturday 23:00) not,2026-10-23T04:30:00Z(Friday 06:30) not; rule Sunday only (64),22:00to07:00:2026-10-26T04:30:00Z(Monday 05:30) quiet,2026-10-27T04:30:00Z(Tuesday 05:30) not; rule Monday to Friday (31),12:00to13:00:2026-10-26T11:00:00Zquiet,2026-10-26T11:59:59Zquiet,2026-10-26T12:00:00Znot,2026-10-24T10:30:00Z(Saturday) not.TestInQuietHours_WholeDayZonesAndDisabled: rule Saturday and Sunday (96),00:00to00:00:2026-10-23T21:59:59Znot,2026-10-23T22:00:00Z(Saturday 00:00) quiet,2026-10-25T22:59:59Z(Sunday 23:59:59) quiet,2026-10-25T23:00:00Z(Monday 00:00) not; the same rule inUTCand inEurope/Berlindiffers at2026-10-23T22:30:00Z; a disabled matching rule is ignored; no rules gives false.TestParseLocalTime:00:00,07:00,23:59parse and print back;24:00,7:00,07:60,07:00:00and the empty string are errors.TestDecide_InputNeededMutedQuietHours(PLAN row, 13 14 "input_neededwithmute"): Alex started the run; levelmutestored on the thread, and separatelyallwithMutedUntilin the future; in quiet hours:send,Quiettrue, routes present; withInputNeededBreaksQuietHours:Quietfalse; outside quiet hours:Quietfalse; the same hint for Sam, who did not start the run:nonewithnot_recipient.TestDecide_AZM48_NotAllowed(mirrors AZ-M48 at decision level): amentionwithAllowedfalse givesnonewithnot_allowed, no routes, no window, no effective level; the same for every other kind.TestDecide_Recipients(13 2):Authorwithmessage,mention,dmgivesnot_recipient; notRunStarterwithrun_done,run_failed,input_neededgivesnot_recipient;securityandadminignore both flags.TestDecide_LevelAndKind(13 4.3): each kind with thread at each effective level against the table of note 3;securityat every level sends;adminsends at every level with the switch and givesadmin_disabledwithout it.TestDecide_ActiveInThread(13 4.4, mirrors the M3-23 case "Thread in focus on the desktop"): the desktop focusing the thread, not idle, givesnonewithactive_in_thread; the phone focusing it does the same; an idle focus or a focus on another thread does not;securityandadminare neveractive_in_thread; amessagemuted and active giveslevel(order).TestDecide_Routes(13 4.4, 4.6, mirrors the M3-23 case "Desktop active"): connected devices getgatewayalso when idle; with the desktop or the laptop connected and not idle, the phone and the tablet without connection getpush_delayedwith their registrations andNotBefore = Now + MobileDelay; an idle desktop delays nothing;MobileDelayzero givespush; the console with a connection getsgateway; a device with neither connection nor registration is left out; no route at all givesnonewithno_routeand no window; routes keep the order ofDevices.TestDecide_Window(13 4.5): no window:sendwithOpensWindowUntil = Now + CoalesceWindow;messagein an open window:collect, no routes; each ofmention,dm,input_needed,run_done,run_failedin an open window:send,OpensWindowUntilzero;Now == Window.End: closed,sendand opens;securityandadminwith aWindow.Endin the future:send, no window;CoalesceWindowzero: never a window.TestDecide_QuietHours: every kind in quiet hours isQuietexceptinput_neededwith the switch; the switch changes nothing for other kinds; outside quiet hours nothing isQuiet.TestDecide_InvalidInput(note 10): each listed case returns an errorerrfor whiche, ok := errs.As(err)givesoktrue ande.Code == errs.CodeInternalError, and the zeroDecision(outcomenone, no routes); the same forFlush,ResolveLevelandInQuietHourswith their cases.TestFlush(note 7):Collectedzero givesnothing_collected;Unreadzero givesread;Collected3 andUnread4 givessendwithCount4, routes andOpensWindowUntil = Now + CoalesceWindow,Quietby the quiet hours at the flush time; the thread level changed tomentionsgiveslevel; a device active in the thread givesactive_in_thread;Allowedfalse givesnot_allowed;Authortrue is ignored; kindmentionis an error.TestFlush_TenMessagesInOneWindow(mirrors the M3-19 case of 13 14 "Ten messages"): the test keeps the window state as a caller would; tenmessagehints atNowplus 0 to 45 seconds withCoalesceWindowone minute give onesendand ninecollect;Flushat the window end withCollected9 andUnread10 gives the second and lastsendwithCount10.TestReleaseDelayed:hintSeq5 withreadSeq4 releases, with 5 and 6 drops;hintSeq0 releases.-
TestDecisionTableGolden(PLAN row "golden table test of all combinations", 13 14 row "Decision table"), intable_test.go: enumerates every combination of kind (8 values in the order ofKinds), stored level of a thread row (all,mentions,mute),muted_untilof that row (n: NULL,y: one hour afterNow), quiet hours (n: no rule,y: one enabled rule every day22:00to07:00inEurope/Berlin),InputNeededBreaksQuietHours,AdminEnabled, active in thread (the phone connected, focus on the thread, not idle), desktop active (the desktop connected, focus on another thread, not idle) and window open (Window.End30 seconds afterNow), eachnbeforey. Fixed:Now = 2026-10-23T21:30:00Z(Friday 23:30 in Berlin),Allowedtrue,Authorfalse,RunStartertrue,DirectMessageexactly fordm, devicesphone(ios, one registration) anddesktop(macos, no registration),CoalesceWindowone minute,MobileDelay30 seconds,ThreadIDzero forsecurityandadmin. Withactive_in_thread=nthe phone has no connection, withdesktop_active=nthe desktop has none, and withwindow_open=nWindowis zero;InstanceDefaultismentions, and the thread row is always present, so the stored level is the chosen row. One line per combination intestdata/decision_table.golden, after one header comment line:kind=<k> level=<l> muted=<y|n> quiet_hours=<y|n> breaks_quiet=<y|n> admin_enabled=<y|n> active_in_thread=<y|n> desktop_active=<y|n> window_open=<y|n> -> outcome=<o> skip=<r|-> effective=<l|-> quiet=<y|n> count=<n> window_until=<+Ns|-> routes=<device:via[@+Ns],...|->The test fails on any difference and names the first differing line;
go test ./internal/notify/domain -run TestDecisionTableGolden -updaterewrites the file, and the diff is reviewed. Each line is also checked against an oracle written in the test from 13 2, 3.1, 3.4, 4.3, 4.4, 4.5 and 4.6 as plain boolean expressions (no call into production helpers), so a wrong golden file cannot pass. -
TestKindsLevelsPlatformsMatchContract, incontract_test.go: walks up from the package directory to the directory that containsdocs/spec, then asserts thatKinds()equals theenumofNotificationKindindocs/api/openapi.yamland the first column of the table in 13 "2. Kinds of notifications and recipients",Levels()theenumofNotificationLevel, andPlatforms()theCHECK (platform IN (...))list in the tabledevicesofdocs/spec/03-data-model.md(not the one of the tablehosts). A line scan is enough: theenum:line of the schema block inopenapi.yaml, the table rows and the check text in the chapters; no YAML library. A new value in one of them fails this test until the domain follows. TestDomainPurity, inpurity_test.go: parses the non-test files of the package withgo/parserand asserts that imports are onlytime,slices,cmp,math,strconv,strings,fmt,errorsand the module'sinternal/kernel/idsandinternal/kernel/errs, and that no call oftime.Now,time.Since,time.Until,time.Sleep,time.After,time.Tick,time.NewTimer,time.LoadLocationortime.LoadLocationFromTZDataexists.TestDecisionCarriesNoText(note 11): walks the fields ofDecision,RouteandEffectiveLevelwithreflectand fails on any field of kindstringthat is not one of the package's enum types, on any map and on any interface.FuzzDecide(seed corpus from the table cases; runs ingo test): buildsRecipientFactsfrom the fuzz input (kind, levels, flags, minute offsets within the week of the goldenNow, one quiet rule, up to four devices), skips inputs for whichDecidereturns an error, and asserts:noneexactly whenSkipis set;sendalways has routes andCount1;collectonly formessagein an open window and without routes; an effectivemutesends no kind with thread exceptinput_needed;mentionsnever sends or collectsmessage;Quietonly whenInQuietHoursis true and never forinput_neededwith the switch; a device active in the thread meansnonefor kinds with thread;push_delayedonly foriosandandroidwithout connection while a desktop is active;OpensWindowUntilonly for kinds with thread without an open window; two calls give equal results and the input is unchanged.
No integration, contract or end-to-end test: this ticket has no I/O. M3-18, M3-19 and M3-23 add the PostgreSQL and gateway tests when they apply the decision.
Run FuzzDecide once locally with go test -run=^$ -fuzz=FuzzDecide -fuzztime=<duration of your
choice> ./internal/notify/domain and report duration and result; commit any crashing input as a
seed after the fix.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/notify/...make arch,make lint,make docsmake check
Acceptance criteria¶
-
internal/notify/domainexists withResolveLevel,InQuietHours,Decide,FlushandReleaseDelayed, a doc comment on every public symbol, and imports only the standard library without I/O andkernel-core;make arch,make lintandTestDomainPuritypass. -
TestDecisionTableGoldencovers every combination of kind, stored level,muted_until, quiet hours, both personal switches, activity in the thread, active desktop and open window; the golden file matches and every line agrees with the oracle. - Quiet hours in
Europe/Berlinpass on both changeover days (including the rule inside the changeover hour) and across midnight with weekday bits. -
input_neededwithmute(stored or bymuted_until) is sent quietly in quiet hours and normally withnotify.input_needed_breaks_quiet_hours. - The effective level follows 13 3.1 with
muted_until; level and kind follow 13 4.3; the recipient guards, activity in the thread, routes with the desktop delay, the window rules,FlushandReleaseDelayedbehave as in design notes 4 to 9. - Kinds, levels and platforms equal
openapi.yaml, 13 and 03 (TestKindsLevelsPlatformsMatchContract); the decision carries no text. - Invalid input never yields
sendorcollect; theFuzzDecideseed corpus passes. - The notify README holds the decision table, the input sources, the window and route rules;
13 2, 3.4, 4.4, 4.5 and 4.6 carry the rulings;
D-M3-17-1toD-M3-17-7are indocs/DECISIONS.md; the CHANGELOG has the entry. -
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 (status, code layout, the decision table of note 3, the input sources of note 2, the window rules of note 7, the route rules of note 8). - Rulings of note 12 recorded in
docs/DECISIONS.md(one row each with reason); rulings 2, 4, 5 and 6 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>.