usage.threshold) can become a Web Push notification. One sink in cloud-api reads the backbone and decides per person and event type. Preferences are stored per person, not per device.
Routes
Clerk session or acompute-scoped API key.
GET /api/v1/notifications/preferences
approval.requested, approval.resolved, agent.run.completed, thread.needs_user, message.received, call.ended, inbox.item.created, vendor.ticket.created, subscription.login_needed, subscription.signed_in, usage.threshold. title and description come from the registry (the same titles as GET /api/v1/events/catalog), so a client renders switches without its own copy. default is what enabled is when the person hasn’t changed it. pushConfigured: false means the server has no VAPID keys, so nothing is sent whatever the switches say.
PUT /api/v1/notifications/preferences
true / false sets a type; null puts it back to its default. The change is all or nothing. Answers the same body as GET. Errors: 400 {"error":"unknown_event"} for a name that isn’t a notifiable event type (platform-only types such as message.status included), 400 {"error":"bad_value"} for an empty map or a value that isn’t true, false or null.
Defaults
message.received and call.ended default on because the app already pushed messages and missed calls before the sink existed.
The push sink
cmd/allternit-cloud-api/src/routes/notifications.rs, started from main.rs only when the VAPID env is set. Every 3 seconds it reads user events (platform_events, subject = 'user') stored in the last 10 minutes that it hasn’t seen, and for each one:
- claims it in
notification_push_log(INSERT … ON CONFLICT DO NOTHING), so a restart, a second replica or a rescan never pushes it twice; - skips it when it happened at the producer more than 12 hours ago;
- checks the owner’s preference;
- builds the notification (copy and links);
- for
message.received, applies the message limits (one per conversation per 20 seconds, 40 an hour) and skips it when a direct channel push went to the same person in the last 2 minutes (see below); - sends it to every device the owner registered, and records
sent,off,unreachable(no device took it) orskipped.
060_notification_preferences.sql adds notification_preferences, notification_push_log and an index on platform_events (created_at) WHERE subject = 'user'. It is applied by hand to production, after 057.
Copy and links
The payload is the Web Push JSON withtype: "event" ("message" for message.received) and data: {event, botId, threadId}. Links use the push-links scheme: /?allternit_thread=<threadId>&bot=<botId> when the event has both, /?allternit_channel=<provider> for a message without a thread, / otherwise.
<bot> is the forwarded botName, or “Your bot”. approval.requested with source: "gizzi-permission" (a coding-session permission ask) is never pushed here: the gizzi remote-control lane (services/remote-control-push) already notifies for it.
Direct pushes
These don’t go through the sink, because the backbone carries no event for them:
A message relayed to a runtime through a channel address (
channel_inbound) used to be pushed directly. The runtime records the same message and forwards it as message.received, so now the direct push only goes out for a runtime that has never forwarded an event (an older build), and follows the message.received preference. Once a runtime forwards, the sink sends it, with the sender and text. While the sink isn’t working (VAPID unset, or migration 060 not applied yet) every relayed message keeps its direct push. For the one message where both could fire (the first event a runtime ever forwards), the sink sees the direct push in push_log and skips its own.
Usage alerts
The cost tracker (services/cost_service.rs, every minute) emits the usage.threshold event into the backbone when a person’s monthly cloud spend crosses their budget alert threshold, and again when it reaches 100% of the budget. Each crossing is emitted once: the event’s source id is <user>:<YYYY-MM>:<level>:<budget in cents>, so the minute-by-minute check never repeats it, a new month starts over, and changing the budget counts as a new crossing. data:
cost_alerts rows the tracker already wrote every 24 hours are unchanged.