The core half of S-D29 landed in #411: capsule-core::notify is the shared decision function
(evaluate(&NotifyInput, now) for the classes true at an instant, next_deadline(&NotifyInput, now)
for the one instant to arm), and capsule-sdk::ffi exports both as free functions with RFC 3339
timestamps. S-D29 stays ready because nothing on a device evaluates it yet: every predicate
input is caller-supplied, since the core holds none of the trigger state.
This is the remainder — the delivery half, which is the larger one.
Owed:
- Native scheduling and presentation. iOS
UNUserNotificationCenter with
UNCalendarNotificationTrigger / UNTimeIntervalNotificationTrigger; Android
NotificationManagerCompat scheduled via AlarmManager.setExactAndAllowWhileIdle (or
WorkManager where inexact timing is acceptable). No abstraction layer — the design says
native APIs directly.
- The arm / re-arm / cancel loop, per client. Call
next_alert_deadline after any state
change and cancel-then-arm if the value moved. next_alert_deadline withholds a deadline
whenever the alert would not certainly be true on arrival, so an armed notification never
needs re-checking at fire time — which is the whole point, since it fires with the app not
running.
- The three server-state classes surface at next app launch.
quota_soft,
quota_grace_expiring, quarantine_pending and drop_pending are not pre-armable, by
design. Closing that gap is the post-v1 wake tier, not this issue.
- The client's own state, wired into
FfiNotifyInput. Nothing persists the fields today:
the last completed sync instant and the un-synced count, the last GET /v1/quota state, the
quarantine-surface count, and the pending-drop count. RecoveryCadence::notify_facts already
covers the recovery half.
- Snooze and disable, per class. They ride
FfiNotifyInput.suppressed_until (class wire
name to an instant, exclusive), so the client owns the bounded-snooze-then-badge state and
the core does not get a second copy of a mechanic the recovery cadence already implements.
Disabling suppresses the warning and never the behavior.
- The
notification.* catalog keys in locales/, one per class, with the parameters the
predicate emits (count, days_behind, grace, snooze_budget). Blocked until now by the
i18n guard, which requires a live consumer — that consumer is this issue.
- Permission at first use. Android
POST_NOTIFICATIONS from API 33 and the iOS
authorization prompt, requested the first time a class has something to say rather than at
launch, so the prompt carries context. A denial degrades every class to its in-app badge:
no error, no retry, no failed flow.
- Validation. The pre-arm lifecycle under a mocked clock (arm on deadline-known, re-arm on
the change that moves it, cancel when it clears, never two live timers per class); the snooze
bound degrading to a badge; a refused authorization degrading to a badge without error; and
the per-platform smoke that fires a pre-armed alert with the app terminated — the one gate
the core half could not run.
Contract: capsule-docs/src/content/docs/design/notifications.md (Delivery, Shared Alert
Mechanics, Validation). Ledger: the S-D29 row and detail block in SLICES.md.
Refs #411
The core half of
S-D29landed in #411:capsule-core::notifyis the shared decision function(
evaluate(&NotifyInput, now)for the classes true at an instant,next_deadline(&NotifyInput, now)for the one instant to arm), and
capsule-sdk::ffiexports both as free functions with RFC 3339timestamps.
S-D29staysreadybecause nothing on a device evaluates it yet: every predicateinput is caller-supplied, since the core holds none of the trigger state.
This is the remainder — the delivery half, which is the larger one.
Owed:
UNUserNotificationCenterwithUNCalendarNotificationTrigger/UNTimeIntervalNotificationTrigger; AndroidNotificationManagerCompatscheduled viaAlarmManager.setExactAndAllowWhileIdle(orWorkManagerwhere inexact timing is acceptable). No abstraction layer — the design saysnative APIs directly.
next_alert_deadlineafter any statechange and cancel-then-arm if the value moved.
next_alert_deadlinewithholds a deadlinewhenever the alert would not certainly be true on arrival, so an armed notification never
needs re-checking at fire time — which is the whole point, since it fires with the app not
running.
quota_soft,quota_grace_expiring,quarantine_pendinganddrop_pendingare not pre-armable, bydesign. Closing that gap is the post-v1 wake tier, not this issue.
FfiNotifyInput. Nothing persists the fields today:the last completed sync instant and the un-synced count, the last
GET /v1/quotastate, thequarantine-surface count, and the pending-drop count.
RecoveryCadence::notify_factsalreadycovers the recovery half.
FfiNotifyInput.suppressed_until(class wirename to an instant, exclusive), so the client owns the bounded-snooze-then-badge state and
the core does not get a second copy of a mechanic the recovery cadence already implements.
Disabling suppresses the warning and never the behavior.
notification.*catalog keys inlocales/, one per class, with the parameters thepredicate emits (
count,days_behind,grace,snooze_budget). Blocked until now by thei18n guard, which requires a live consumer — that consumer is this issue.
POST_NOTIFICATIONSfrom API 33 and the iOSauthorization prompt, requested the first time a class has something to say rather than at
launch, so the prompt carries context. A denial degrades every class to its in-app badge:
no error, no retry, no failed flow.
the change that moves it, cancel when it clears, never two live timers per class); the snooze
bound degrading to a badge; a refused authorization degrading to a badge without error; and
the per-platform smoke that fires a pre-armed alert with the app terminated — the one gate
the core half could not run.
Contract:
capsule-docs/src/content/docs/design/notifications.md(Delivery, Shared AlertMechanics, Validation). Ledger: the
S-D29row and detail block inSLICES.md.Refs #411