Repository navigation
ADR 0022 Notifications Rules And Delivery
Accepted (2026-10-09)
Phase 2 promises notifications for "finished, stuck, battery, offline" over Web Push to the PWA, with ntfy as an alternative (project plan, feature table). The Notifications service owns subscriptions and preferences, is reusable, and is the NotificationChannel extension point (Web Push, ntfy, e-mail, Telegram). The inputs exist already: the Device-Gateway publishes a MowerState on every significant change and as a heartbeat every 10 s on telemetry.mower.<id>, and mower.connected/mower.disconnected events (ADR 0013); Fleet publishes the mower.* registry events; Identity publishes identity.user.* and identity.grants.changed (ADR 0015). Authorization is OpenFGA through mowdeck-authz (ADR 0014). Services are stateless and scale out (Twelve-Factor); all persistence is PostgreSQL (ADR 0006).
Four questions had to be answered: where the rules run, who receives a notification, how delivery survives failures and replicas, and how channels are added.
Rules run in the Notifications service, derived from telemetry. The gateway stays a translator of mower protocols and publishes facts (states, links); "a mow finished" or "the battery is low" is an interpretation that depends on thresholds, hysteresis and cooldowns, and other consumers (Telemetry, Realtime) do not need it. The rules are pure functions over a MowerWatch (what the service remembers per mower: last activity, job start, battery arming, notified error, standstill anchor, disconnect time, offline flag, last notification per kind), tested with fixed timestamps:
| Kind | Fires when | Default |
|---|---|---|
job_started |
the activity becomes mowing while no job runs |
off in user preferences |
mowing_finished |
a running job reaches docked or charging after at least the minimum duration |
120 s |
mower_stuck |
activity error (once per error until it clears), or no position change while mowing
|
300 s, 0.5 m |
battery_low |
below the threshold while not charging; again only after the re-arm level (hysteresis) | 20 %, 30 % |
mower_offline |
no state for a while, a state with activity offline, or mower.disconnected without reconnect within a grace time |
120 s, 30 s |
mower_online |
the first contact after a notified mower_offline
|
A kind is sent at most once per cooldown (300 s) and mower. The first state of a mower notifies nothing, so a restart does not repeat old news. The watch is stored as JSON per mower and updated FOR UPDATE in one transaction per message; the queue group consumer notifications-states may hand messages of one mower to different replicas. The offline timer runs on every replica and takes its batch with FOR UPDATE SKIP LOCKED. Every event carries a dedupe_key (kind, mower and the occurrence, for example the job start time), message keys with parameters, and a severity.
Recipients are subscribers, re-checked with can_view at delivery time. list_objects answers "which mowers may this user view", not "which users may view this mower", and Identity offers no request for an account's users. Instead, a user subscribes in their preferences, to "all my mowers" or to selected mowers (each must be in the account and viewable, otherwise 404), and enables kinds. For an event the service loads the subscribers of the mower's account, filters kinds, and asks check_many(can_view, mower) for all of them in one batch before anything is stored: a user whose access was revoked after subscribing gets nothing, and "all my mowers" follows grants automatically. Quiet hours in the user's time zone keep the inbox item but queue no delivery. Messages are rendered per recipient from data templates (messages/de.json, messages/en.json). identity.user.disabled and .deleted delete the user's preferences, targets, inbox and deliveries; identity.grants.changed drops cached decisions.
An outbox with SKIP LOCKED delivers. The fan-out writes the inbox item (unique per user and dedupe_key, so a repeated event is stored once) and one deliveries row per channel target in one transaction, then publishes notifications.sent for in-app feeds. A worker loop on every replica claims due rows with FOR UPDATE SKIP LOCKED in a short transaction that increments attempts and moves next_attempt_at by a lease (60 s), sends outside the transaction with bounded concurrency, and records the result: sent; retry with exponential backoff (30 s doubling to 1 h, or the server's Retry-After when longer) up to 8 attempts, then failed; gone (Web Push 404/410: the subscription is deleted, the delivery dropped); failed (permanent). A crash after sending means one more send after the lease (at least once); a crash between the rule commit and the fan-out loses that one notification (at most once for derived events), which we accept over holding a database transaction across an OpenFGA call. Finished deliveries are pruned after 7 days; the inbox keeps the newest 200 per user.
Channels are plug-ins. A channel is a NotificationChannel (name, async send(delivery) -> DeliveryResult), created by a factory from the entry point group mowdeck.notification_channels, like the connection registry of mowdeck-control. The service ships webpush (VAPID with py-vapid, aes128gcm encryption with pywebpush's WebPusher.encode, sent with the shared async httpx client; TTL and Urgency by severity; Topic per kind and mower), ntfy (JSON publish to the server root, optional access token, priority and tags by kind) and log (development). E-mail and Telegram are documented interfaces only. Targets are validated: a push endpoint must be HTTPS on an allowed push service host, an ntfy server must be on an allow list, so users cannot make the service call internal addresses. VAPID keys come from settings (or secret files); in development Compose creates a key file in a volume on first start and logs the public key.
- Thresholds change in one service without touching the gateway; a new rule is a pure function with table tests.
- Nobody is notified until they subscribe; the app has to offer "notify me about all my mowers" on first use.
job_startedis opt-in. - The
can_viewbatch per event costs one OpenFGA round trip per event, independent of the number of subscribers (check_many); a user who can view nothing cannot use the API at all (403). - Delivery is at least once per channel; Web Push
Topicand the service worker'stagcollapse repeats on the device. - A Web Push subscription belongs to the last user who registered the endpoint (one browser, one owner). ntfy access tokens are stored in the service database and never returned by the API.
- Follow-up: the PWA service worker and the settings page (frontend task), e-mail and Telegram channels as separate packages, and per-mower thresholds if users ask for them.
Mirrored from docs/adr/0022-notifications-rules-and-delivery.md at commit a117684672263ba4f0b7e2e3538233456122d24e. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit a117684672263ba4f0b7e2e3538233456122d24e. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- Development
- Operations
- Spikes
- Guides
- Original plan (German)