| D-M3-18-1 |
2026-10-15 |
A preference write and delete resolve the target with chat.Structure.LockNotificationPath, which takes FOR SHARE on the thread row (kind thread), then the channel row, then the space row (when the channel has one), the order of moveThread, before authorize(notification_pref.update). Every thread can be a target, the main thread of a text channel included. An unknown id, an id of another kind and a target without read right give the same 404. Place: internal/notify/app/preferences.go, internal/chat/app/notification.go. |
Brief M3-18 note 4 a and b: the share locks make the archive check exact (an archive holds the row FOR UPDATE), one lock order avoids deadlocks; the automatic following of 13 3.2 skips the main thread, an explicit setting does not; AZ-P4. |
| D-M3-18-2 |
2026-10-15 |
No new preference on an archived target (an archived space or channel, or a channel in an archived space): 409 conflict with reason target_archived, nothing written; deleting stays possible. Place: Prefs.Put, 13 3.1, openapi putNotificationPreference (new 409). |
The cleanup of D-M3-18-5 runs once, at archive time; a later row would stay forever on a target that never gets a message (07 4.5 point 2 treats management writes the same way). |
| D-M3-18-3 |
2026-10-15 |
muted_until is cut to whole microseconds and stored as given, also when it has passed; a passed value has no effect because ResolveLevel applies it only while it is in the future. The write is one statement INSERT ... ON CONFLICT DO UPDATE ... WHERE (level, muted_until) IS DISTINCT FROM (...) RETURNING; no returned row is a no-op answered with the stored row, without event and without version change (domain.PreferenceUnchanged documents the comparison). Responses and event payloads show muted_until in the API form of 06 2 (UTC, milliseconds), not in microseconds as the brief's test row says. Place: upsertPreference, queries/notification_preferences.sql, 13 3.1. |
A client in an outbox may send a mute that has expired meanwhile; refusing it would fail the entry for no gain. Two parallel first writes end in one row and, with equal values, one event. 06 2 fixes the time format of every API time; the stored precision stays microseconds. |
| D-M3-18-4 |
2026-10-15 |
Deleting a stored preference writes notification_pref.updated with the last stored values, version the last version plus one and deleted: true; no stored row answers 204 without event; a later put starts again at version 1. Place: Prefs.Delete. |
The pattern of M1-61 for settings: clients compare the version in the payload, and a deleted row has no tombstone (06 7). |
| D-M3-18-5 |
2026-10-15 |
Archiving a space or channel removes, in the archive's unit of work, every preference of every person on the space, on every channel of it (archived ones included) and on every thread of those channels, through the in-transaction subscribers notify.OnChannelArchived and notify.OnSpaceArchived, with one notification_pref.updated (deleted: true, version plus one, audience the row's person, the actor of the unit) per removed row in ascending (user_id, target_kind, target_id). A veto later in the unit rolls everything back. Place: internal/notify/app/subscribers.go, internal/wire/txn.go, 13 3.1. |
R-DM-16 (no foreign key on the polymorphic target, the application cleans up on archiving); 02 4.3 names both subscriptions; the share locks of D-M3-18-1 keep other writers off the archived path without a deadlock. |
| D-M3-18-6 |
2026-10-15 |
Both events have resource {kind: user, id: <person>} without version, an empty scope, audience the person, receive action self, and no device field in the payload. Place: internal/notify/app/events.go. |
The resource kind user has no version of its own (04 9 point 3), the version is inside the payload as for settings.updated; the events are personal data routed by audience; 03 gives neither table an updated_by_device_id. |
| D-M3-18-7 |
2026-10-15 |
Every quiet-hour write authorizes quiet_hours.update on the own user, then takes pg_advisory_xact_lock(hashtextextended('notify.quiet_hours:' \|\| user_id, 0)) before it reads or writes a rule; quiet_hours.updated carries the complete list as of its own commit, ascending by id, disabled rules included. Place: internal/notify/app/quiethours.go, queries/quiet_hours.sql, 13 3.4. |
Without the lock two parallel creates would each read the list without the other's new row and the last event would miss a rule; a hash collision only serializes two people. |
| D-M3-18-8 |
2026-10-15 |
time_zone must load with time.LoadLocation (with time/tzdata imported) and is never Local or empty, otherwise 400 validation_failed with errors[].code unknown_time_zone on /time_zone; absent stores NULL. A write whose encoded QuietHoursPayload would not stay below sync.event_payload_max_bytes (measured as the appender measures it) is 400 validation_failed with errors[].code limit_reached, empty pointer, nothing stored. QuietRules loads a stored zone that no longer loads as UTC and logs one Warn line without the name and without an id. Place: quiethours.go, facts.go, 13 3.4. |
04 2.2 rule 2 makes the appender refuse a larger payload while the payload must carry the complete list, so an existing key bounds the number of rules; UTC is the documented default of users.time_zone, so a rule stays effective instead of disappearing. |
| D-M3-18-9 |
2026-10-15 |
Facts.LevelFacts and Facts.QuietRules are app functions of notify, not port methods: LevelFacts makes one path read through chat.Structure.NotificationPath, one query on notification_preferences for all people and the three targets (the space left out for a DM channel), one sync.Settings.ValuesFor and reads notify.user_default_level, and never resolves the level; QuietRules reads all rules of the people in one query and the person zones through identity.Directory.Users, loading each zone once. The port additions are chat.Structure.NotificationPath, LockNotificationPath, ChannelIDsOfSpace, ThreadIDsOfChannels, sync.Settings.ValuesFor and identity.Directory.Users; none of them authorizes. Place: internal/notify/app/facts.go, the four READMEs, 13 12 point 2. |
13 12 point 2 asks for one batched query; only notify (M3-19) calls these reads, and notify never reads a table of another module (03 principle 12). |
| D-M3-18-10 |
2026-10-15 |
notification_pref.update and quiet_hours.update are refused to agents and apps by authorize with subject_kind_not_allowed: the requirement table of authz adds Human to both (All(Human, Self, AtLeast(LevelRead)) and All(Human, Self)). Place: internal/authz/domain/requirements_table.go, TestDecide_PersonalDataOnlyForPeople. |
Brief M3-18 requirement 12 and the test row "agent token gets 403 on each of the seven operations"; the base allowed an agent naming itself through Self. M1-61 note 22 decided the same for the other personal data actions in authz, not in the module. |
| D-M3-18-11 |
2026-10-15 |
Deviations from the brief: notify.QuietHoursInput.TimeZone is *string (nil is NULL) instead of string, so an explicit "" is refused with unknown_time_zone; Service.UpsertPreference exports upsertPreference for M3-19; architecture/modules.yaml gives notify the adapter vendor pgx (sqlc code and the repository), which make arch needs, although the brief only names depends_on. The integration tests use the messaging test world with the test authenticator of httpservertest (as M1-61) instead of DPoP proofs, and set users.time_zone by SQL because updateMyProfile is not part of that world; the DM channel is a fixture written with the owner connection. No core rule or contract change was needed from main: the actions, events and tables are on the base. Branch feat/M3-18-notify-preferences from int/m1-wave25 (719cfaa); not merged locally, the PR lane ports it. Text added to the German chapter 13 and the READMEs is English. |
The launcher's task takes precedence over the brief's merge step; merged names win over the brief (D-W-100); the test world of M1-61 is the existing HTTP harness with response validation. |
| D-M3-18-12 |
2026-10-20 |
Port onto main from the integration squash of int/m1-wave28: the eleven own commits of the ticket (719cfaa..efc51c8), stacked on M1-61, without the integration fixes INT-W28/1 and INT-W28/2 (they concern M1-62 and M1-63, which are not on main; ShareChannel and ShareSpace stay the ones of this ticket). The text added to chapter 13 stands in the English 13-push-notifications.md, the configuration key names that chapter, the new migrations cite 03-data-model.md, and the generated files (configuration reference, schema, contract copy of the fake server) are regenerated. The migrations keep the numbers 00043 and 00044 after 00042 of M1-61. |
English documentation on main wins over the branch text; only the ticket's own commits enter, so no change of another ticket reaches main through this one. |