Skip to content

[FEAT] capsule-core::notify — alert classes and trigger predicates (S-D29 core half) - #433

Open
justin13888 wants to merge 8 commits into
chore/freeze-capsule-core-api-399from
feat/core-notify-alert-classes-411
Open

[FEAT] capsule-core::notify — alert classes and trigger predicates (S-D29 core half)#433
justin13888 wants to merge 8 commits into
chore/freeze-capsule-core-api-399from
feat/core-notify-alert-classes-411

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

capsule-core::notify — the core half of S-D29. The closed alert-class enum, its trigger
predicates, and the pre-arm deadline model, plus the capsule-sdk::ffi record that carries them
to the apps, and the doc/ledger updates that stop calling the module planned.

Summary

notifications.md closes the alert class list and places it, with the trigger predicates, in
capsule-core::notify so every platform evaluates one shared decision function rather than
reimplementing the taxonomy. Nothing implemented it — planned-modules.txt recorded the module as
unbuilt.

  • capsule-core/src/notify/ (new, native-gated, private submodules behind one pub use
    block): AlertClass (closed, serde snake_case, ALL in delivery order, severity() and
    pre_armable() as const fn), AlertSeverity, Alert (blocks_critical_flow() is
    const false for every alert, so "no alert ever blocks" is a property of the type),
    NotifyInput + SyncFacts / RecoveryFacts / QuotaFacts / QuotaAdvisory, and
    evaluate(&NotifyInput, now) -> Vec<Alert> / next_deadline(&NotifyInput, now) -> Option<Timestamp>.
  • Pure by construction. now is always an argument; no clock read, no socket, no SQLite, no
    unsafe, no allocation beyond the returned vector. BTreeMap params plus a fixed emission order
    make two runs on equal input byte-equal through serde.
  • Every input is caller-supplied, because this crate holds none of the trigger state: no
    persisted last_completed_sync, no client-side quota type (quota is server-held and only as
    current as the last GET /v1/quota), no quarantine table (a refused sync entry is a per-entry
    verdict, not a row), and pending drops live in the server-side inbox. NotifyInput therefore
    carries counts and instants only — no album id, no title, no asset id, nothing a server could
    author.
  • Arming is per class. pre_arm_deadlines(&NotifyInput, now) -> BTreeMap<AlertClass, Timestamp>
    is the arm surface: a class present should hold exactly one alarm at that instant, a class absent
    should hold none. It is keyed per class because the two pre-armable timers are independent — a
    staleness deadline two weeks out and a recovery check ninety days out are two alarms, and
    collapsing them to one loses the later alert on a device the app never runs on again.
    next_deadline remains as its minimum, documented as the single-timer convenience it is.
  • A snooze defers the alarm; only a disable cancels it. NotifyInput.suppressed (snooze-until)
    moves the armed instant to the snooze end, because a class snoozed after it fired must fire again
    when the snooze expires. NotifyInput.disabled is a separate set — snooze and disable have
    opposite effects on the timer, and a sentinel instant would not survive a string-typed FFI
    boundary.
  • capsule-sdk/src/ffi/notify.rs (new): FfiAlertClass / FfiAlertSeverity / FfiAlert /
    FfiQuotaAdvisory / FfiClassDeadline / FfiNotifyInput, and three free #[uniffi::export]
    functions — evaluate_alerts, pre_arm_deadlines, next_alert_deadline. Timestamps cross as
    RFC 3339 strings per the existing changed_at precedent; a malformed one is
    FfiError::InvalidArgument, never a panic.
  • capsule-sdk/src/recovery/cadence.rs: one additive method, RecoveryCadence::notify_facts,
    projecting the scheduler into RecoveryFacts. No dependency edge added — capsule-sdk depends on
    capsule-core, never the reverse.
  • Docs and ledger: the capsule-core::notify line leaves planned-modules.txt,
    notifications.md drops "(Planned)" and gains the status note, and the S-D29 row and detail
    block record the core half as landed while the slice stays ready.

RecoveryFacts.rewrap_due carries the guided-re-wrap escalation as the alert's recovery
parameter, so "you told us you lost your recovery secret" is not byte-identical to the routine
ninety-day check — the class set is closed, so a parameter is the only way to distinguish them.

Predicates, each against its owner doc: sync_stale at last_completed_sync + 14 d while
unsynced_changes > 0; recovery_check_due at next_due with an active snooze holding it back;
quota_soft on SoftWarning; quota_grace_expiring on HardExceeded (grace = "counting") and
GraceExpired (grace = "expired"); quarantine_pending and drop_pending on any non-zero count.
Every threshold fires at its boundary instant (now >= deadline), matching the recovery
cadence's own convention, and suppression is the mirror image (until > now, exclusive).

Validation

Run inside the worktree
/var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-core-notify-alert-classes-411.

Command Outcome
cargo nextest run -p capsule-core notify PASS — 39 tests
cargo clippy -p capsule-core -- $CLIPPY_FLAGS PASS — clean
cargo fmt -p capsule-core -- --check PASS
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p capsule-core PASS
mise run doc-check-rust (now --document-private-items, -D warnings) PASS — clean on the merged tree. Correction: an earlier revision of this section said the pre-merge run reported 14 pre-existing unresolved links; that was an undercount taken from a truncated head, and the reviewer counted 22 (it omitted import/{executor,planner,streaming} and crypto/keys/{albumstore,keystore}). The exact pre-merge count is moot now: base f508bf1a repaired all of them, and the gate passes clean on this head with private items documented. Zero of them were ever in this diff's files.
mise run build-check-wasm PASS — proves the native gate holds
cargo clippy -p capsule-sdk --features ffi -- $CLIPPY_FLAGS PASS — clean
cargo nextest run -p capsule-sdk --features ffi PASS — 174 tests, 0 failed (11 new in ffi::notify)
cargo nextest run -p capsule-sdk cadence PASS — 12 tests (3 new for the projection)
mise run gen-bindings PASS — evaluateAlerts, preArmDeadlines, nextAlertDeadline, FfiClassDeadline, recoveryRewrapDue, disabled present in both the Swift and Kotlin output
mise run check-docs-truth PASS — cross-links 473, endpoint-census 84, module-paths 120, all resolve
mise run lint-check-md PASS — 168 files, 0 issues
mise run check-rust PASS — exit 0 on this head, all 16 sub-gates in one run, including the extended binding gate (surface check passed: S-D9 client/session + S-P1 workspace verbs + S-D29 alert surface present in both languages). An earlier run on the repair commit was killed by a signal during lint-check-ffi (mise: no exit status, wrapper 144) under concurrent-lane load; classified flaky (environment), and superseded by this clean run.
mise run check-docs PASS — exit 0 (format-check-docs, lint-check-docs, test-docs, build-docs; 59 pages built, all internal links valid). Note: capsule-docs/node_modules did not exist in this worktree, so build-docs/test-docs first failed astro: command not found / vitest: command not found; bun install --frozen-lockfile in capsule-docs/ fixed it with no tracked-file change.
mise run check-md PASS — 168 files, 0 issues
mise run test-rust PASS — exit 0; 2698 tests, 0 failed (--workspace 1756, -p capsule-core --features ffi 768, -p capsule-sdk --features ffi 174)

Test counts: 39 new in capsule-core::notify (class 8, input 5, evaluate 26), 3 new in
capsule-sdk::recovery::cadence, 11 new in capsule-sdk::ffi::notify — 53 in total, every one
table-driven over the documented boundary instants.

The third commit is a self-review repair: an adversarial read of the first two found four ways
the pre-arm model dropped an alert it had promised to deliver (one global timer instead of one per
class; a snooze cancelling the timer instead of deferring it; a disable sentinel that could not
survive the FFI boundary; a recovery snooze ending before the due date pulling the alarm earlier,
into no alert). Each is now a named test.

CI on this PR. One check fails: Build Capsule.apk + :core JVM smoke. Classified
pre-existing — the same job fails identically on the base branch's own PR (#426), the errors
are Kotlin (capsule-core-kotlin/src/test/.../SoftwareSignerSmokeTest.kt "No value passed for
parameter 'client'", plus unresolved di/initKoin/MuseumObject/museumObject template
references under capsule-android/src/androidMain/), and this diff touches neither tree. Every
other completed check passes, including Rust (fmt + clippy + build), Rust (tests), all four
Rust cross targets, Docs truth, Markdown, Docs and Commit lint.

The per-platform pre-armed-delivery smoke (notifications.md, Validation — an armed alert
firing from the OS timer with the app terminated) is unavailable here and is named rather than
substituted: it belongs to the client half, which needs native toolchains and a device.

Review round 1 repairs (commits 6-8): AlertClass::severity()'s six-arm mapping is pinned by
a table test (only one pair was pinned before, so five arms could change unnoticed); the
badge/timer split of the bounded-snooze rule gets the test that proves both halves; gen-bindings
now requires the alert surface by name in both languages, so the whole API cannot silently vanish
from the bindings; FfiNotifyInput's last two fields take #[uniffi(default)], so all eleven
default and the "just installed" input needs no arguments in Swift or Kotlin; the params docs
name recovery, which the predicate emits on every recovery_check_due; and the owner docs
record the parameters (decision 17). One doc slip fixed: FfiNotifyInput.recovery_next_due said
it ignores "the other two recovery_* fields" — there are three.

Manifest widened, deliberately. Two of the round-1 files are outside this lane's recorded
Touches: list and were added on the reviewing orchestrator's explicit direction, not silently:
mise-tasks/gen-bindings (F2 — the binding-surface gate could not be extended from inside the
manifest, and leaving it un-extended is what let the whole alert API be absent from the bindings
without failing) and capsule-docs/src/content/docs/design/backup-recovery.md (decision 17 — the
recovery parameter has to be recorded by the doc that owns the re-wrap escalation; recording it
only in notifications.md would put it in the doc that explicitly delegates thresholds away).
Every other file is the original manifest.

Base merged. origin/chore/freeze-capsule-core-api-399 gained f508bf1a (gate rustdoc over
private items, and repair what that reveals
) mid-run; it is merged in by merge commit 2e451d9d,
not rebased. The merge was clean — no overlapping file — and capsule-core::notify needed no repair
under the newly strict gate, because its intra-doc links were already written to resolve from inside
private modules.

Risks and rollout

  • Pure addition. Every file is either new or gains an additive line; nothing existing changes
    behavior, so reverting the three commits restores the previous tree exactly.
  • No dependency added, no locale key added, no wire format changed, no server module touched.
  • The native gate is proved by build-check-wasm: the WASM sealing build does not link the
    module.
  • The three server-state classes (quota_*, quarantine_pending, drop_pending) cannot fire on a
    device that never runs. That gap is the design's, stated in the SSoT, and v1 accepts it — those
    three surface at next app launch.
  • S-D29 stays ready: native delivery, the notification.* catalog keys, and the
    permission-at-first-use placement are the client half and are filed separately.

Related Issues

Refs #411

Remainder filed as #439clients: S-D29 client half — native alert delivery, notification.* keys,
permission at first use
. S-D29 therefore stays ready and this is Refs, not Closes.

Decisions taken

Issue 411 - core: capsule-core::notify — alert classes and trigger predicates (S-D29, core half)
Plan:     v1 (planned against f433d918; executed on the head of lane #399's branch)
Branch:   feat/core-notify-alert-classes-411
Base:     chore/freeze-capsule-core-api-399 (head of PR #426), stacked; the PR targets that branch
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-core-notify-alert-classes-411
Cause:    -
Touches:  capsule-core/src/notify/{mod,class,input,evaluate}.rs (new; private submodules + one pub use block), capsule-core/src/lib.rs (one `#[cfg(feature = "native")] pub mod notify;` line), capsule-sdk/src/ffi/notify.rs (new), capsule-sdk/src/ffi.rs (one mod/pub use), capsule-sdk/src/recovery/cadence.rs (additive projection helper), capsule-docs/planned-modules.txt (delete the capsule-core::notify line only), capsule-docs/src/content/docs/design/notifications.md (:39 status + note), SLICES.md (S-D29 row + detail block ONLY)
Will not: native scheduling/presentation; notification.* catalog keys; Tier 1 wake; any server module; any alert class beyond notifications.md:62-66; locales/; capsule-core/src/ffi.rs; capsule-swift/**; capsule-android/**; module-map.md
Lane:     serialised behind #399; parallel with #401/#408/#410. Forecast collision: planned-modules.txt with #410 (adjacent line); capsule-sdk/src/ffi.rs with #408 (different hunks).
Settled:  Barrel/pub(crate) convention (#399). Base = head of PR #418 → stacks on #399.

Decisions taken.

1. Deliverable boundary — how much of S-D29 closes here
   Taken:    Core predicates + pre-arm deadlines + the capsule-sdk FFI record + doc/ledger updates, with per-class suppression taken as an input field (NotifyInput.suppressed) rather than a state machine core owns. S-D29 stays `ready` with the core half recorded as landed.
   Rejected: Also land the shared snooze/badge state machine (notifications.md:115) - capsule-sdk/src/recovery/cadence.rs:36,240-252 already implements bounded snooze→badge for one class; a second copy in core would be two owners of one mechanic before the client half exists; and notifications.md:125 blocks the catalog keys until a live consumer exists.
   Reverses: Add capsule-core/src/notify/snooze.rs and replace NotifyInput.suppressed with an owned per-class snooze record.
   Filed:    the lane files "S-D29 client half: native delivery, notification.* keys, permission at first use" and links it from the PR.

2. Where the recovery cadence lives relative to the predicate
   Taken:    NotifyInput carries a flat RecoveryFacts { next_due, snoozed_until, snooze_budget_spent }; the SDK maps RecoveryCadence::state into it via a small additive helper in cadence.rs. No dependency edge added.
   Rejected: Move RecoveryCadence into capsule-core - capsule-sdk depends on capsule-core, never the reverse; relocating a 489-line public SDK type widens the manifest into recovery/mod.rs and the FFI.
   Reverses: Move cadence.rs to capsule-core/src/backup/cadence.rs, re-export from the SDK, take &RecoveryCadence in NotifyInput.

3. FFI shape — free functions, not FfiWorkspace methods
   AMENDED 2026-09-02 (review round 1): this decision names exactly two exports. There are three;
             pre_arm_deadlines(input, now) was added by decision 14 below and is the one a client
             arms from. The free-function shape and the error contract this decision settled are
             unchanged and cover all three. The original text stands as written.
   Taken:    evaluate_alerts(input, now) / next_alert_deadline(input, now) in capsule-sdk/src/ffi/notify.rs; timestamps as RFC 3339 strings; bad input → FfiError::InvalidArgument.
   Rejected: FfiWorkspace::alerts(now) - the workspace holds none of the inputs (no last_sync/synced_at/last_upload in core or sdk; no client-side quota type; quarantine has no persisted table — lifecycle/sync_apply.rs:91 is a per-entry verdict).
   Reverses: Move the two exports into `impl FfiWorkspace` (workspace.rs:486) and regenerate bindings.

4. What the GraceExpired quota state emits
   Taken:    QuotaState::GraceExpired emits quota_grace_expiring with params["grace"]="expired"; HardExceeded emits it with params["grace"]="counting". No sixth class.
   Rejected: Emit nothing on GraceExpired - quota.md:37 makes GraceExpired strictly additive to Hard-exceeded, and the class set is closed at five (notifications.md:62-68).
   Reverses: Delete the GraceExpired arm in evaluate.rs's quota match and its two table rows.

Taken inside the manifest during delivery, in the same shape:

5. What next_deadline withholds - arming only what will certainly fire
   Taken:    A pre-armable class contributes a deadline only when the alert is certain to be true
             on arrival: sync_stale needs unsynced_changes > 0 already, recovery_check_due needs an
             unspent snooze budget. Consequence: next_deadline is strictly narrower than evaluate,
             and an armed OS notification never has to be re-checked when it fires.
   Rejected: Arm sync_stale from the sync epoch alone, which is notifications.md:85 read literally
             ("armed for now + two weeks at the end of each successful sync") - an armed
             notification fires with the app not running, so a client that had to re-evaluate on
             arrival to decide whether to show it would defeat the entire pre-arm rule. The
             narrower arm loses no firing: nothing but a sync can change unsynced_changes while
             the app is not running, and a sync re-arms.
   Reverses: Drop the `unsynced_changes > 0` and `!snooze_budget_spent` filters in
             pre_arm_deadline (capsule-core/src/notify/evaluate.rs).

6. What a spent snooze budget does to the class
   Taken:    RecoveryFacts.snooze_budget_spent does not silence recovery_check_due. It is carried
             as params["snooze_budget"] = "spent" | "available" and withholds only the pre-arm
             deadline. Consequence: a fourth params key; the badge stays delivery (the client's)
             rather than a class the core suppresses.
   Rejected: Suppress the class once the budget is spent - a client cannot render a persistent
             badge for a condition it was never told about, and notifications.md:40 puts delivery
             per client while the class list stays in core. Suppressing would make core the owner
             of the badge state machine decision 1 explicitly declined.
   Reverses: Return None from recovery_check_due when facts.snooze_budget_spent, and delete the
             snooze_budget param and its test.

7. Where the alert-class wire-name parse lives
   Taken:    AlertClass::from_wire in capsule-core, the inverse of as_str, used by the FFI's
             suppressed_until map. Consequence: one table serves both directions, and an
             unrecognized class name is FfiError::InvalidArgument rather than a dropped key.
   Rejected: Parse the six names inside capsule-sdk/src/ffi/notify.rs - a second copy of a closed
             enum's spelling, in the crate least able to notice when the enum grows a variant.
   Reverses: Move the match into capsule-sdk/src/ffi/notify.rs and delete AlertClass::from_wire
             plus its round-trip test.

8. VerificationState::RewrapDue in the cadence projection
   Taken:    RecoveryCadence::notify_facts maps RewrapDue to next_due = now, so it is due at once
             whatever the ladder says. Consequence: an explicit "I lost it" and the
             repeated-failure escalation surface immediately through recovery_check_due, the only
             class the closed set has for them.
   Rejected: Carry the scheduler's own next_due for RewrapDue - declare_lost leaves next_due up to
             seven days out, so the most urgent recovery state would be the quietest one; and the
             class set is closed, so there is no recovery_rewrap_due to escalate into.
   Reverses: Use self.next_due in the RewrapDue arm of notify_facts
             (capsule-sdk/src/recovery/cadence.rs).

9. Arming is per class, not one global minimum
   Taken:    pre_arm_deadlines(input, now) -> BTreeMap<AlertClass, Timestamp> is the arm surface;
             next_deadline is its minimum and is documented as the single-timer convenience.
             Consequence: a third #[uniffi::export] (pre_arm_deadlines) and an FfiClassDeadline
             record.
   Rejected: Keeping next_deadline as the only arm surface (as planned) - the two pre-armable
             timers are independent, so a staleness deadline 14 d out and a recovery check 90 d
             out collapse to one instant, and a client that armed it loses the recovery alert
             entirely on a device the app never runs on again. A client also needs the class to
             pick the notification.* catalog key for the alarm it is arming.
   Reverses: Delete pre_arm_deadlines from capsule-core/src/notify/evaluate.rs and
             capsule-sdk/src/ffi/notify.rs, restore next_deadline's own iteration, regenerate
             bindings.

10. A snooze defers the armed instant; only a disable cancels it
   Taken:    A class snoozed at `now` is dropped from evaluate but its alarm moves to the snooze
             end. Consequence: pre_arm_deadline composes the class's own instant with the snooze
             end by max().
   Rejected: Dropping a suppressed class from the arm decision (as first written) - a class
             snoozed after it fired would then never return unless the user opened the app,
             which for sync_stale is exactly the starved-background case the pre-arm rule exists
             for. The snooze end is itself a deadline the device can compute.
   Reverses: Filter suppressed classes out of pre_arm_deadlines before asking for their instant.

11. Disable is its own field, not a sentinel instant
   Taken:    NotifyInput.disabled: BTreeSet<AlertClass> (FfiNotifyInput.disabled: Vec<String>),
             separate from the snooze map. Consequence: one more field on the input and on the
             FFI record; is_suppressed checks both.
   Rejected: Timestamp::MAX in `suppressed` as the disable sentinel (the first implementation) -
             snooze and disable have opposite effects on the timer, and a sentinel instant does
             not survive a string-typed FFI boundary: a client writing "2999-01-01" would mean
             disabled and get an alarm armed 975 years out. Two of this PR's own tests were
             written against exactly that misreading before the field was split.
   Reverses: Delete NotifyInput.disabled and FfiNotifyInput.disabled, restore the
             `until == Timestamp::MAX` arm in pre_arm_deadline.

12. The guided-re-wrap escalation is carried, not inferred
   Taken:    RecoveryFacts.rewrap_due (#[serde(default)]), set by RecoveryCadence::notify_facts,
             surfacing as the alert's params["recovery"] = "rewrap" | "check". Consequence: a
             fourth RecoveryFacts field and a fifth params key.
   Rejected: Leaving RewrapDue expressed only as next_due = now - the alert would be
             byte-identical to the routine ninety-day check, so a client would render "time for
             your periodic check" at the moment the user has declared the secret lost. A Rust
             caller could re-read RecoveryCadence::state, but an app across the uniffi boundary
             holds only the projection and has no way back. The class set is closed, so a
             dedicated class is not available.
   Reverses: Delete the field, its projection arm, the `recovery` param and their tests.

13. The native-gate rationale on `pub mod notify;` is a plain comment, not a doc comment
   Taken:    capsule-core/src/lib.rs carries the rationale as `//`. Consequence: notify/mod.rs
             keeps short intra-doc links.
   Rejected: A `///` doc comment on the mod item - rustdoc merges an outer mod doc with the
             module's own `//!` block and resolves the combined text at the declaration site,
             which broke all 13 short intra-doc links in notify/mod.rs and failed doc-check-rust
             (observed, then fixed). capsule-core/src/lqip/mod.rs pays the other price for the
             same trap by fully qualifying every link.
   Reverses: Convert the comment to `///` and fully qualify every intra-doc link in
             capsule-core/src/notify/mod.rs.

Taken in review round 1, decided by the orchestrator on the reviewer's questions:

14. Per-class arming is the client contract (Q1 -> 1a)
   Taken:    pre_arm_deadlines is what a shipped client arms from; next_alert_deadline stays only
             as a convenience for a host that can hold one timer, and its doc says the collapse
             is lossy and why per-class is preferred. Decision 3 above is amended in place with a
             dated line rather than rewritten. Consequence: issue #439 carries a comment
             replacing the owed-work instruction "call next_alert_deadline after any state change
             and cancel-then-arm" with the per-class contract.
   Rejected: 1b, accept the loss for v1 - the loss is not a latency cost but a missing alert: with
             a staleness deadline 14 d out and a recovery check 90 d out, arming the minimum means
             the recovery alert never fires on a device the app is not reopened on, which is the
             one case pre-arming exists for.
   Reverses: delete pre_arm_deadlines from capsule-core/src/notify/evaluate.rs and
             capsule-sdk/src/ffi/notify.rs, restore decision 3's wording, regenerate bindings.

15. A spent snooze budget does not silence evaluate (Q2 -> 2a)
   Taken:    recovery_check_due is still reported with params["snooze_budget"]="spent" so the
             client can render the persistent non-blocking badge; the "stops re-firing" half is
             enforced at the pre-arm layer, where a spent budget arms no timer for the class,
             ever. The code already behaved this way; the test that proves both halves together
             was missing and is added
             (evaluate.rs, a_spent_snooze_budget_stops_the_timer_but_not_the_report).
   Rejected: 2b, suppress the class once the budget is spent - that satisfies "stops re-firing"
             by making "degrades to a badge" impossible, because a client cannot render a badge
             for a condition it was never told about.
   Reverses: drop the class from evaluate once the budget is spent, and delete that test.

16. RecoveryFacts.snoozed_until owns recovery snoozing (Q3 -> 3a)
   Taken:    The cadence scheduler owns the bounded-snooze budget, so the snooze and the budget
             that bounds it stay in one place; the generic `suppressed` map is for the other five
             classes. Stated on both fields and on FfiNotifyInput. A suppressed[recovery_check_due]
             entry keeps composing (later-of-the-two) and is documented in one sentence as a
             fallback, not the canonical channel.
   Rejected: 3b and 3c - rejecting or ignoring a generic entry for the class would either make a
             reasonable client call fail or make it silently do nothing, and both are worse than
             a documented composition.
   Reverses: remove the ownership sentences from input.rs and capsule-sdk/src/ffi/notify.rs.

17. The `recovery` parameter and the RewrapDue projection are recorded in the owner docs, here
   Taken:    notifications.md's alert-class table gains a Parameters column naming every key the
             predicate emits, and backup-recovery.md gains the paragraph its own section has to
             settle: the closed class set means recovery_check_due carries both the routine check
             and the re-wrap escalation, so the scheduler reports re-wrap as due now and the
             client routes on params["recovery"]. Landed in this PR rather than filed.
   Rejected: 4b, leave RewrapDue expressed only as an earlier due date - declare_lost leaves
             next_due up to seven days out, so the most urgent recovery state would be the
             quietest, and an app across the FFI holds only the projection with no way back to
             RecoveryCadence::state to recover the distinction.
   Reverses: delete the `recovery` parameter, its arm in evaluate.rs and the projection field,
             and revert the two doc edits.

Unresolved review notes

Raised by an adversarial read of this diff and deliberately not acted on, with the reason:

  1. capsule-core::notify is native-gated, so a web client cannot evaluate it.
    notifications.md lists Web (Notification from the service worker) as a Tier 0 delivery
    target, and this module pulls no native dependency — the gate costs nothing to drop. It is kept
    because the un-gated surface is the key-free guest sealing path, and the authenticated web
    decode boundary that would give a browser any of these facts is in the post-v1 register
    (api-surfaces.md; SLICES.md Post-v1 Register). A browser in v1 holds no sync state, no quota
    response, no quarantine surface and no drop inbox, so un-gating would compile a predicate
    nothing can call. Reverses: delete the two #[cfg(feature = "native")] lines on
    pub mod notify;, when the post-v1 web-decode slice lands.
  2. QuotaAdvisory::Suspended raises nothing, and no doc records that gap. Suspension is an
    admin or billing action owned by moderation.md, not a quota threshold, and the alert class set
    is closed at five — so there is no class to raise. A user whose account is suspended learns it
    from the failing operation rather than from an alert. Naming the gap belongs in quota.md or
    notifications.md, both of which are outside this lane's manifest for that edit.
  3. The params keys are documented only in Rust. Closed by decision 17.
    notifications.md's alert-class table now carries a Parameters column naming every key for
    every class, and backup-recovery.md records the recovery parameter and the RewrapDue
    projection. The four per-threshold docs still do not repeat the keys, which is correct:
    notifications.md owns the class list and the delivery contract, and duplicating the table
    into four docs is the restatement the corpus rules forbid.
  4. The three server-state classes cannot fire on a device that never runs. Stated for
    completeness: this is the design's own accepted gap (notifications.md, "The honest boundary"),
    not a defect in this change. The post-v1 wake tier is what closes it.

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

`notifications.md` closes the alert class list at five classes and places
them, with their trigger predicates, in `capsule-core::notify` so every
platform evaluates one shared decision function instead of reimplementing
the taxonomy. Nothing implemented it: the module did not exist.

`evaluate(&NotifyInput, now) -> Vec<Alert>` reports the classes true at an
instant; `next_deadline(&NotifyInput, now) -> Option<Timestamp>` returns the
one instant an OS timer must be armed for. Both are pure — `now` is an
argument, nothing is read from a clock, a socket, or SQLite — so the whole
surface is table-driven under a mocked clock, the same discipline as the
recovery cadence whose projection it consumes.

Every predicate input is caller-supplied because this crate holds none of
the trigger state: there is no persisted `last_completed_sync`, no
client-side quota type (quota is server-held and only as current as the last
`GET /v1/quota`), and no quarantine table — a refused sync entry is a
per-entry verdict. `NotifyInput` therefore carries counts and instants only:
no album id, no title, no asset id, nothing a server could author.

`next_deadline` is deliberately narrower than `evaluate`. An armed
notification fires from the OS timer with the app not running, so it cannot
be re-checked on arrival; a deadline is returned only when the alert is
certain to be true when it gets there. That withholds one from the three
server-state classes (no device-computable deadline), from a suppressed or
already-passed one, and from the two that would arrive as a badge rather
than a notification.

Suppression is an input field rather than a state machine this crate owns:
the bounded-snooze-then-badge mechanic already has one owner in the recovery
cadence, and a second copy here would be two owners of one mechanic before
the client half exists to say which shape it needs.

`native`-gated: an alert is composed from decrypted device state, and the
un-gated surface is the key-free guest sealing path, which holds none of it.
`BTreeMap` params plus a fixed emission order make two runs on equal input
byte-equal through serde.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: eb2a445
Status: ✅  Deploy successful!
Preview URL: https://a2e28d33.capsule-22k.pages.dev
Branch Preview URL: https://feat-core-notify-alert-class.capsule-22k.pages.dev

View logs

`capsule_core::notify` decides the alert classes; the apps had no way to
call it. This carries the surface across the boundary and wires the SDK's
own recovery scheduler into it.

`evaluate_alerts(input, now)` and `next_alert_deadline(input, now)` are free
`#[uniffi::export]` functions rather than `FfiWorkspace` methods: the
workspace holds none of the predicate's inputs — no persisted last-sync
instant, no client-side quota type, no quarantine table — so a method would
take the same `FfiNotifyInput` and then lock a mutex it never reads.

`FfiNotifyInput` is flat. uniffi records nest, but a foreign caller
assembling five optional sub-records to ask one question is worse than a
struct whose fields are each independently absent, and presence is explicit:
`last_completed_sync` present means the sync facts are known, and so on.
Timestamps cross as RFC 3339 strings per the existing `changed_at`
precedent, since Kotlin and Swift each have their own instant type.

Nothing is parsed leniently. A malformed instant, or a `suppressed_until`
key that is not one of the six class names, is `FfiError::InvalidArgument`
and never a default — a mistyped instant that silently became "never" would
suppress an alert forever, which is the failure this surface exists to
prevent. `AlertClass::from_wire` gives the boundary one table to parse
against instead of its own copy.

`RecoveryCadence::notify_facts(now)` projects the scheduler into
`RecoveryFacts`. It is derived from `state(now)` rather than from the fields,
so the alert and the prompt the UX renders can never disagree about whether
a check is due; `Badge` reports the spent snooze budget (reported, not
pre-armed) and `RewrapDue` is due now whatever the ladder says, because
repeated failure is not a scheduled check and the closed class set has only
`recovery_check_due` to carry it. The projection lives here because
capsule-sdk depends on capsule-core and never the reverse.
`planned-modules.txt` is the only sanctioned way a design doc may name a
module that is not there, and `check-docs-truth` fails on an entry whose
module has since been built — so the `capsule-core::notify` row has to go in
the same change that builds it, and `notifications.md` has to stop calling
the module planned.

The `S-D29` row keeps `ready` rather than taking `done*`. The predicate is
proven and nothing on a device evaluates it yet: every input is
caller-supplied because the core holds none of the trigger state, so the
remainder is the client half and it is the larger half. The detail block
says which parts are owed and why the `notification.*` keys cannot land
before them — the i18n guard needs a live consumer, and the consumer is the
client half by construction.

Only the S-D29 row and its detail block change; the row-count paragraph, the
gates table and the prose head are untouched.
An adversarial read of the first two commits found four ways the pre-arm
model lost an alert it had promised to deliver. All four share a root: an
armed notification fires from the OS timer with the app not running, so
anything the arm decision gets wrong is invisible until an alert simply
fails to arrive.

**One timer per class, not one globally.** `next_deadline` returned the
minimum over both pre-armable classes, so a staleness deadline two weeks out
and a recovery check ninety days out yielded one instant — and a client that
armed it lost the recovery alert entirely on a device the app never ran on
again. `pre_arm_deadlines` now returns the instant per class, which is also
what a client needs to pick the catalog key for the notification it is
arming. `next_deadline` remains as its minimum, documented as the
single-timer convenience it is.

**A snooze defers the timer; it no longer cancels it.** A class snoozed
after it fired was dropped from the arm decision entirely, so the alert
never returned unless the user opened the app — which for `sync_stale` is
precisely the case the pre-arm rule exists for. The snooze end is itself a
deadline the device can compute, so it is armed.

**Disable is its own field.** Snooze and disable are different mechanics
with opposite effects on the timer, so `NotifyInput.disabled` is a separate
set rather than a far-future instant in the snooze map. A sentinel instant
does not survive a string-typed FFI boundary: a client writing "the year
2999" would mean disabled and get a timer armed 975 years out.

**A recovery snooze that ends before the due date no longer pulls the timer
earlier**, which would have fired into no alert; the armed instant is the
later of the two, and the alert reports that same instant rather than
`next_due`.

Also: `RecoveryFacts.rewrap_due` carries the guided-re-wrap escalation, so
the alert for "you told us you lost your recovery secret" is no longer
byte-identical to the routine ninety-day check — the class set is closed, so
a parameter is the only way to distinguish them. The FFI stops parsing
`recovery_snoozed_until` only when `recovery_next_due` happens to be
present, since a validation that runs on one code path is the one that lets
a typo through. And `quarantine_pending` is documented as excluding pending
drops, which have their own class and were otherwise counted twice.
`severity()` had six arms and one pinned pair, so five of them could be
changed without a test noticing — and severity is how loudly a shipped
client presents an alert. The table pins all six, and zipping it against
`ALL` pins the delivery order the same table is written in.

The second test pins the two halves of the bounded-snooze rule, which live
at different layers and are easy to conflate: with the budget spent, the
pre-arm layer arms no timer for `recovery_check_due` ever (the "stops
re-firing" half), while `evaluate` keeps reporting the class carrying
`snooze_budget = spent` (the "degrades to a badge" half, which needs the
class reported or the client has nothing to render a badge from). The
behaviour was already correct; nothing proved it.

Also names `recovery` in the `Alert::params` doc, which listed four of the
five keys the predicate emits.
`FfiNotifyInput` declared uniffi defaults for nine of its eleven fields, so
`suppressed_until` and `disabled` were the only two a foreign caller had to
name to construct the "just installed, learned nothing" input — the one the
record documents as the common case. Both now take the bare
`#[uniffi(default)]` (the type's `Default`; `[]`/`{}` literals are soft
deprecated upstream for maps and sequences), and the generated Swift init
and Kotlin data class carry `= [:]` / `= mapOf()` and `= []` / `= listOf()`.

`gen-bindings` asserts the surfaces each lane consumes are present by name,
because a verb that fails to cross the namespace boundary still leaves a
large, plausible file behind. It was not extended when this surface landed,
so the whole alert API could have vanished from the bindings silently. It
now requires `FfiAlert`, `FfiClassDeadline`, `FfiNotifyInput` and the three
free functions in both languages.

Documents which field owns recovery snoozing: the cadence scheduler tracks
it against a bounded budget, so `RecoveryFacts.snoozed_until` is canonical
and the generic per-class map is for the other five classes. An entry there
for `recovery_check_due` still composes (later-of-the-two), recorded as a
fallback so a client that writes both is not surprised.
…wners

The parameters a client interpolates into its catalog string were specified
only in Rust doc comments and asserted only in Rust tests. `notifications.md`
owns the class list and delegates each threshold to its trigger owner, but
named no parameter at all — so the contract a client codes against lived
nowhere a client author would look, and the `notification.*` keys owed to
the client half would have been written from the implementation rather than
from the design.

The class table gains a Parameters column. `backup-recovery.md` gains the one
thing its own section has to settle: the re-wrap escalation and the routine
check share `recovery_check_due`, because the class set is closed, so the
scheduler reports re-wrap as due now and carries which it is in the
`recovery` parameter for the client to route on.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant