Skip to content

Notifications

Draft

This doc owns alert delivery and the vocabulary. The server holds only ciphertext, and the short-poll sync feed stays the fallback for any push (Network Resilience), so a server can tell a client that something changed, never what.

TermDirectionCarriesOwner
Alertdevice → its own userLocalized text composed on-deviceThis doc
Wakeserver → clientNothing: “something changed for you”This doc
Hintserver ↔ server, peer ↔ peer“A new event exists in album A”; advisory, no authorityFederation, Peering

Alert classes and trigger predicates live in capsule-core::notify, one decision function for every platform; delivery is native per client. Open: the Apple mock lane arms into an in-memory scheduler rather than UNUserNotificationCenter until the Rust-backed adapter is composed, the Android app has no flow yet that records the facts the predicate reads, and the pre-armed delivery smoke has not run on a device.

This is all of v1.

No alert text ever originates from a server. Clients render copy from their own catalogs, keyed by a class decided locally.

A closed enum. Thresholds belong to the trigger owner. Parameters are plain values interpolated into catalog strings, never text.

ClassTrigger ownerParametersPre-armable
sync_staleSynccount, days_behindYes
recovery_check_dueBackup — Schedulesnooze_budget (available/spent), recovery (check/rewrap)Yes
quota_soft / quota_grace_expiringQuotagrace (counting/expired), second onlyNo
quarantine_pendingQuarantine SurfacescountNo
drop_pendingWeb UploadcountNo

An alert whose trigger is a deadline the device can compute MUST be pre-armed as a scheduled local notification when the deadline becomes known, and MUST be re-armed or cancelled on every state change that moves it.

sync_stale reports that the OS gave the app no background time, so it cannot rely on background time; it is armed for two weeks after each successful sync and fires from the OS timer.

Server-side conditions (quota_*, quarantine_pending, drop_pending) cannot be pre-armed and surface at next launch; Tier 1 is what would close that gap.

The client loop is one idempotent refresh, run after every state change: read the facts, take the badge set from evaluate_alerts and the per-class instants from pre_arm_deadlines, then reconcile the OS timers against them. A class present holds exactly one timer at its instant (a moved instant or changed copy cancels and re-arms it); a class absent holds none. next_alert_deadline is never the client’s input: it is the minimum, and arming only it loses the later class’s alarm. The armed copy is fixed at arm time, because the alarm fires with the app not running: sync_stale carries the current un-synced count and the days to its deadline, recovery_check_due its recovery split. A tap on recovery = rewrap opens the guided re-wrap, never the routine verification prompt.

PlatformAPIFires with app not running
iOSUNUserNotificationCenter (calendar triggers at an absolute UTC instant, one request identifier per class)Yes
AndroidNotificationManager from an AlarmManager.setAndAllowWhileIdle alarm, one PendingIntent per class, re-armed after rebootYes
DesktopNative OS notificationsWhile running
WebService-worker NotificationBest-effort
CLIExit codes and stderrn/a

Permission is requested the first time a class has something to say, never at first launch: the first refresh that has a timer to arm asks, once. Android alarms are inexact on purpose: a two-week or ninety-day deadline needs no minute precision, an inexact alarm fires late but never early (so its alert is still true on arrival), and an exact one would need the SCHEDULE_EXACT_ALARM special-access grant.

  • Snooze is bounded, then becomes a badge that never escalates back to an alert. A snooze lasts a day and defers the class’s timer to its end; after three consecutive snoozes the class is badge-only until its condition clears, which restores the budget. The recovery check is snoozed by its own cadence scheduler, which owns that budget.
  • Disabling suppresses the warning, never the behavior it reports on.
  • No alert ever blocks any flow.
  • Permission denial is not an error: every class degrades to its in-app badge; no retry, no nag.
  • Copy is a catalog key under notification.* in locales/ (i18n): notification.<class>.title / .body, with an enumerated parameter selecting the key (notification.recovery_check_due.rewrap.*, notification.quota_grace_expiring.expired.*) because the catalog compiles only a whole-message plural, and a count as that plural’s quantity.

Post-v1, opt-in, off by default.

No album, actor, asset, count or timestamp. The client responds with its ordinary poll (/v1/sync, /v1/quota, /v1/drops) and composes any alert locally.

Every client MUST reach the same state with wake permanently unavailable; only timeliness degrades.

A wake transport MUST require no credential that the operator cannot generate themselves.

So no APNs, FCM (bound to the published app) or project relay.

TransportCredentialPlatforms
Web Push (RFC 8030) + VAPID (RFC 8292)Operator-generated keypairWeb, desktop
UnifiedPushA distributor the user choosesAndroid, desktop
none (default)—All

iOS has no wake tier, permanently: events from other actors surface at next launch.

  • Opt-in per device, revocable. The endpoint is opaque bytes on the device row, cleared when the device’s session is revoked.
  • Coalesced and rate-capped. The server coalesces behind a floor interval; a client ignores wakes above a rate and falls back to its schedule.
  • Jittered so wake timing is not a clean activity side-channel.

Server-composed or rich notifications; APNs, FCM or relays; comment and reaction alerts; email; presence.

Tiers per Validation Tiers.

  • Trigger predicates (unit): each class fires exactly at its threshold.
  • Pre-arm lifecycle (unit): arm, re-arm, cancel; never two timers per class.
  • Snooze, disable, denial (unit): each degrades to a badge and changes no behavior.
  • Pre-armed delivery (smoke, per platform): fires with the app terminated.
  • Wake contentlessness (unit, post-v1): no library-derived field; response identical to the scheduled poll.