Telling a user
Your module has something to tell someone: a loan is overdue, an invoice is paid. You write one line, and the kernel handles the channels.
from kewos.notifications import notify_userfrom kewos.notifications.user_inbox import NotificationKind
with engine.begin() as conn: ... # your business write notify_user( conn, user_id=42, kind=NotificationKind.STATE_CHANGE, payload={"model": "library_loan", "record_id": 19, "state": "overdue"}, )That is rule 31: no module emits a notification any other way. No hand-rolled email, no private table, no direct call to a push service.
What that line does
| Channel | When |
|---|---|
| The inbox in the interface | immediately, inside your transaction |
A live nudge (WebSocket) on user:42 | after the commit |
| A phone notification (encrypted Web Push) | after the commit, if the user opted in and the server holds its keys |
You declared none of those channels. A user with no browser open and no push finds the notification in their inbox next time they sign in: the channel is a convenience, the inbox is the truth.
The property that matters: a rolled-back transaction tells nobody
notify_user takes your connection, not the engine. The insert therefore joins your
transaction. Run:
after a ROLLED-BACK transaction : 1 unread (unchanged)after a COMMITTED transaction : 2 unreadA failed business write un-notifies the user, because the notification never existed. Same posture as the mail outbox (rule 17): you do not announce something that did not happen.
And both external channels fire on the commit event, not inline:
- the nudge only says “re-read your inbox”, with no content. A lost race or a rolled-back transaction therefore produces an empty re-read at worst, never wrong information on screen;
- the push leaves after the commit with a minimal payload (title, body, URL) because it travels through a third party. It is end-to-end encrypted before leaving: the push service reads nothing.
Four kinds, and why the list is closed
MENTION, COMMENT, STATE_CHANGE, SYSTEM. The list is short on purpose: the kind drives the
rendering, the grouping and the email digest. One kind per module would make the inbox unreadable
and the digest impossible to write.
The payload is free and yours: the kernel does not read it, it renders it.