[FEAT] capsule-core::notify — alert classes and trigger predicates (S-D29 core half) - #433
Open
justin13888 wants to merge 8 commits into
Open
Conversation
`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.
Deploying capsule with
|
| 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 |
`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.
…9' into feat/core-notify-alert-classes-411
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
capsule-core::notify— the core half ofS-D29. The closed alert-class enum, its triggerpredicates, and the pre-arm deadline model, plus the
capsule-sdk::ffirecord that carries themto the apps, and the doc/ledger updates that stop calling the module planned.
Summary
notifications.mdcloses the alert class list and places it, with the trigger predicates, incapsule-core::notifyso every platform evaluates one shared decision function rather thanreimplementing the taxonomy. Nothing implemented it —
planned-modules.txtrecorded the module asunbuilt.
capsule-core/src/notify/(new,native-gated, private submodules behind onepub useblock):
AlertClass(closed,serdesnake_case,ALLin delivery order,severity()andpre_armable()asconst fn),AlertSeverity,Alert(blocks_critical_flow()isconst falsefor every alert, so "no alert ever blocks" is a property of the type),NotifyInput+SyncFacts/RecoveryFacts/QuotaFacts/QuotaAdvisory, andevaluate(&NotifyInput, now) -> Vec<Alert>/next_deadline(&NotifyInput, now) -> Option<Timestamp>.nowis always an argument; no clock read, no socket, no SQLite, nounsafe, no allocation beyond the returned vector.BTreeMapparams plus a fixed emission ordermake two runs on equal input byte-equal through
serde.persisted
last_completed_sync, no client-side quota type (quota is server-held and only ascurrent as the last
GET /v1/quota), no quarantine table (a refused sync entry is a per-entryverdict, not a row), and pending drops live in the server-side inbox.
NotifyInputthereforecarries counts and instants only — no album id, no title, no asset id, nothing a server could
author.
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_deadlineremains as its minimum, documented as the single-timer convenience it is.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.disabledis a separate set — snooze and disable haveopposite 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 asRFC 3339 strings per the existing
changed_atprecedent; a malformed one isFfiError::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-sdkdepends oncapsule-core, never the reverse.capsule-core::notifyline leavesplanned-modules.txt,notifications.mddrops "(Planned)" and gains the status note, and theS-D29row and detailblock record the core half as landed while the slice stays
ready.RecoveryFacts.rewrap_duecarries the guided-re-wrap escalation as the alert'srecoveryparameter, 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_staleatlast_completed_sync + 14 dwhileunsynced_changes > 0;recovery_check_dueatnext_duewith an active snooze holding it back;quota_softonSoftWarning;quota_grace_expiringonHardExceeded(grace = "counting") andGraceExpired(grace = "expired");quarantine_pendinganddrop_pendingon any non-zero count.Every threshold fires at its boundary instant (
now >= deadline), matching the recoverycadence'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.cargo nextest run -p capsule-core notifycargo clippy -p capsule-core -- $CLIPPY_FLAGScargo fmt -p capsule-core -- --checkRUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p capsule-coremise run doc-check-rust(now--document-private-items,-D warnings)head, and the reviewer counted 22 (it omittedimport/{executor,planner,streaming}andcrypto/keys/{albumstore,keystore}). The exact pre-merge count is moot now: basef508bf1arepaired 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-wasmnativegate holdscargo clippy -p capsule-sdk --features ffi -- $CLIPPY_FLAGScargo nextest run -p capsule-sdk --features ffiffi::notify)cargo nextest run -p capsule-sdk cadencemise run gen-bindingsevaluateAlerts,preArmDeadlines,nextAlertDeadline,FfiClassDeadline,recoveryRewrapDue,disabledpresent in both the Swift and Kotlin outputmise run check-docs-truthmise run lint-check-mdmise run check-rustsurface 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 duringlint-check-ffi(mise:no exit status, wrapper 144) under concurrent-lane load; classified flaky (environment), and superseded by this clean run.mise run check-docsformat-check-docs,lint-check-docs,test-docs,build-docs; 59 pages built, all internal links valid). Note:capsule-docs/node_modulesdid not exist in this worktree, sobuild-docs/test-docsfirst failedastro: command not found/vitest: command not found;bun install --frozen-lockfileincapsule-docs/fixed it with no tracked-file change.mise run check-mdmise run test-rust--workspace1756,-p capsule-core --features ffi768,-p capsule-sdk --features ffi174)Test counts: 39 new in
capsule-core::notify(class8,input5,evaluate26), 3 new incapsule-sdk::recovery::cadence, 11 new incapsule-sdk::ffi::notify— 53 in total, every onetable-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 forparameter 'client'", plus unresolved
di/initKoin/MuseumObject/museumObjecttemplatereferences under
capsule-android/src/androidMain/), and this diff touches neither tree. Everyother 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 alertfiring from the OS timer with the app terminated) is
unavailablehere and is named rather thansubstituted: 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 bya 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-bindingsnow 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 elevendefault and the "just installed" input needs no arguments in Swift or Kotlin; the
paramsdocsname
recovery, which the predicate emits on everyrecovery_check_due; and the owner docsrecord the parameters (decision 17). One doc slip fixed:
FfiNotifyInput.recovery_next_duesaidit 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 themanifest, 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 — therecoveryparameter has to be recorded by the doc that owns the re-wrap escalation; recording itonly in
notifications.mdwould 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-399gainedf508bf1a(gate rustdoc overprivate 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::notifyneeded no repairunder the newly strict gate, because its intra-doc links were already written to resolve from inside
private modules.
Risks and rollout
behavior, so reverting the three commits restores the previous tree exactly.
nativegate is proved bybuild-check-wasm: the WASM sealing build does not link themodule.
quota_*,quarantine_pending,drop_pending) cannot fire on adevice 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-D29staysready: native delivery, thenotification.*catalog keys, and thepermission-at-first-use placement are the client half and are filed separately.
Related Issues
Refs #411
Remainder filed as #439 — clients: S-D29 client half — native alert delivery, notification.* keys,
permission at first use.
S-D29therefore staysreadyand this isRefs, notCloses.Decisions taken
Taken inside the manifest during delivery, in the same shape:
Taken in review round 1, decided by the orchestrator on the reviewer's questions:
Unresolved review notes
Raised by an adversarial read of this diff and deliberately not acted on, with the reason:
capsule-core::notifyisnative-gated, so a web client cannot evaluate it.notifications.mdlists Web (Notificationfrom the service worker) as a Tier 0 deliverytarget, 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.mdPost-v1 Register). A browser in v1 holds no sync state, no quotaresponse, 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 onpub mod notify;, when the post-v1 web-decode slice lands.QuotaAdvisory::Suspendedraises nothing, and no doc records that gap. Suspension is anadmin or billing action owned by
moderation.md, not a quota threshold, and the alert class setis 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.mdornotifications.md, both of which are outside this lane's manifest for that edit.TheClosed by decision 17.paramskeys are documented only in Rust.notifications.md's alert-class table now carries a Parameters column naming every key forevery class, and
backup-recovery.mdrecords therecoveryparameter and the RewrapDueprojection. The four per-threshold docs still do not repeat the keys, which is correct:
notifications.mdowns the class list and the delivery contract, and duplicating the tableinto four docs is the restatement the corpus rules forbid.
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