Notifications: web push and in-app inbox #22

Closed
opened 2026-09-24 14:33:57 +00:00 by kayg · 13 comments
Owner

Owner decision 2026-09-24 (DESIGN §27). Web push via the PWA plus an in-app inbox for: share received, agent turn done or needing approval, sync conflicts. No email in v0.0.1.

Owner decision 2026-09-24 (DESIGN §27). Web push via the PWA plus an in-app inbox for: share received, agent turn done or needing approval, sync conflicts. No email in v0.0.1.
Author
Owner

Calendar reminders: event alerts from any calendar (external CalDAV events and log entries with a time) go through this web push + inbox channel. No email (owner, calendar C11, DESIGN §30).

Calendar reminders: event alerts from any calendar (external CalDAV events and log entries with a time) go through this web push + inbox channel. No email (owner, calendar C11, DESIGN §30).
Author
Owner

Starting notifications work on branch job/notifications, based on 57118d9648582e682f0a0e1997fc8ad9f84bab35 (main). I am mapping the plugin, event, calendar and secret-storage contracts before implementation. Scope follows this issue and DESIGN §§27, 30: web push and in-app inbox for shares, agent turns, sync conflicts, and calendar reminders; no email.

Starting notifications work on branch `job/notifications`, based on `57118d9648582e682f0a0e1997fc8ad9f84bab35` (`main`). I am mapping the plugin, event, calendar and secret-storage contracts before implementation. Scope follows this issue and DESIGN §§27, 30: web push and in-app inbox for shares, agent turns, sync conflicts, and calendar reminders; no email.
Author
Owner

Implementation findings from the current base (57118d9):

  • The server has no general instance-secret store. Root exposes .system/config.toml, and InstanceConfig includes OIDC client secrets; I found no VAPID or other private-key store. I will keep any VAPID private key out of API responses and logs.
  • External calendar data is not available yet: wire.rs installs ExternalCalendarsPending, and calendar-view documents its event provider as waiting for #40. A reminder hook can be added, but live external-event reminders depend on that provider being connected.
  • Sync conflicts are recorded in the client journal and have no server reporting route. Collaboration exposes AgentTurn::finish, but no notification hook. Existing calternal-reminders metadata is lossless frontmatter data; no scheduler currently consumes it.

I am shaping the proposal around the existing plugin and Index patterns and will call out the cross-crate hooks required for sources that are not yet wired.

Implementation findings from the current base (`57118d9`): - The server has no general instance-secret store. `Root` exposes `.system/config.toml`, and `InstanceConfig` includes OIDC client secrets; I found no VAPID or other private-key store. I will keep any VAPID private key out of API responses and logs. - External calendar data is not available yet: `wire.rs` installs `ExternalCalendarsPending`, and `calendar-view` documents its event provider as waiting for #40. A reminder hook can be added, but live external-event reminders depend on that provider being connected. - Sync conflicts are recorded in the client journal and have no server reporting route. Collaboration exposes `AgentTurn::finish`, but no notification hook. Existing `calternal-reminders` metadata is lossless frontmatter data; no scheduler currently consumes it. I am shaping the proposal around the existing plugin and Index patterns and will call out the cross-crate hooks required for sources that are not yet wired.
Author
Owner

Starting notifications work on job/notifications, based on 41aa77499e0a090371ff252458722fd80b4e6a44 (main). I read issue #22 and its comments, plus the repo contract, CONTEXT.md, and docs/DESIGN.md §§27 and 30. The approved scope is the notifications core plugin, typed publish hook, Index-backed VAPID secret helper, web push 0.11.0, inbox, and explicit reminder triggers (VALARM on cached CalDAV Events and calternal-reminders on Log entries). I will also wire existing share, agent-turn, and sync-conflict producers where their current APIs support it. Agent approval remains contract-only.

Starting notifications work on `job/notifications`, based on `41aa77499e0a090371ff252458722fd80b4e6a44` (`main`). I read issue #22 and its comments, plus the repo contract, `CONTEXT.md`, and `docs/DESIGN.md` §§27 and 30. The approved scope is the notifications core plugin, typed publish hook, Index-backed VAPID secret helper, web push 0.11.0, inbox, and explicit reminder triggers (`VALARM` on cached CalDAV Events and `calternal-reminders` on Log entries). I will also wire existing share, agent-turn, and sync-conflict producers where their current APIs support it. Agent approval remains contract-only.
Author
Owner

Implementation findings from base 41aa774:

  • files::shares::create commits the Share before returning and has the recipient ID, so it can publish a typed ShareReceived event after the durable write.
  • AgentTurn::finish flushes the completed Note and is a usable typed AgentTurnFinished hook.
  • The Calendar cache exposes cached Event records and retains each source iCalendar object, which is enough to evaluate explicit VALARM triggers.
  • Sync conflicts are stored only in calternal-sync's local SQLite journal. This server job has no conflict report route or producer, so it cannot publish a real conflict notification without changing sync-client behavior. The issue's approved scope says to wire existing producers where their current APIs support it; I will leave this as an integration gap.
Implementation findings from base `41aa774`: - `files::shares::create` commits the Share before returning and has the recipient ID, so it can publish a typed `ShareReceived` event after the durable write. - `AgentTurn::finish` flushes the completed Note and is a usable typed `AgentTurnFinished` hook. - The Calendar cache exposes cached Event records and retains each source iCalendar object, which is enough to evaluate explicit `VALARM` triggers. - Sync conflicts are stored only in `calternal-sync`'s local SQLite journal. This server job has no conflict report route or producer, so it cannot publish a real conflict notification without changing sync-client behavior. The issue's approved scope says to wire existing producers where their current APIs support it; I will leave this as an integration gap.
Author
Owner

OpenAPI generation found that the inbox cursor and limit parameters were emitted as required path parameters even though Axum reads them from the query string. The IntoParams annotation now sets parameter_in = Query, and regenerated contracts/openapi.json and packages/api-client/src/generated.ts show optional query parameters. The duplicate-operation check reported no duplicates.

A repeated reminder scan could also enqueue an existing notification for an Installation added after the original event. Persistence now exits on the dedupe conflict before creating push-delivery rows; the regression test verifies only the original Installation receives the delivery.

OpenAPI generation found that the inbox `cursor` and `limit` parameters were emitted as required path parameters even though Axum reads them from the query string. The `IntoParams` annotation now sets `parameter_in = Query`, and regenerated `contracts/openapi.json` and `packages/api-client/src/generated.ts` show optional query parameters. The duplicate-operation check reported no duplicates. A repeated reminder scan could also enqueue an existing notification for an Installation added after the original event. Persistence now exits on the dedupe conflict before creating push-delivery rows; the regression test verifies only the original Installation receives the delivery.
Author
Owner

Finished kayg/calternal#22 on branch job/notifications.

Head SHA: 440469589f609126f595444391587ad3cbf11d89

Built the Notifications core Plugin with an Index-backed VAPID secret, typed share and AgentTurn hooks, a per-User inbox, per-Installation Web Push subscriptions, bounded durable delivery retries, cached Calendar VALARM reminders, and calternal-reminders Log-entry reminders. Added server registration and the minute dispatch job, generated the OpenAPI and TypeScript client, and extended both adversarial rounds for notification input, ownership, and concurrency.

Gate output:

  • cargo fmt --all --check: exit 0, no output.
  • cargo clippy --all-targets -- -D warnings:
    Finished dev profile [unoptimized + debuginfo] target(s) in 24.57s
  • cargo test: exit 0 for the workspace. Notification crate output:
    test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
  • bash packages/api-client/check-generated.sh (exit 0):
    ✨ openapi-typescript 7.13.0
    🚀 ../../contracts/openapi.json → src/generated.ts [1.3s]
  • bash tests/adversarial/run.sh (exit 0):
    ==== FINDINGS 0
    ==== ROUND 2 FINDINGS 0
  • bun run check: svelte-check found 0 errors and 0 warnings
  • bun run test:
    Test Files 9 passed (9)
    Tests 80 passed (80)
  • cargo clean:
    Removed 17840 files, 12.0GiB total

Decisions not specified in the design: floating Event times use the server timezone; ambiguous daylight-saving times select the earlier instant; reminders are caught up for 24 hours; the inbox retains at most 10,000 items for 180 days; each User may have 64 subscriptions; delivery batches contain at most eight pushes and retry at most eight times. Push endpoints are restricted to the FCM, Mozilla, and Apple Push domains.

Known gaps: Calendar VALARM REPEAT/DURATION and recurring Event instances are not expanded. The agent_approval_needed kind has no producer, as decided. There is no sync-conflict producer because current sync conflicts remain in the local calternal-sync journal. The UI is outside this job's owned files.

Finished `kayg/calternal#22` on branch `job/notifications`. Head SHA: `440469589f609126f595444391587ad3cbf11d89` Built the Notifications core Plugin with an Index-backed VAPID secret, typed share and AgentTurn hooks, a per-User inbox, per-Installation Web Push subscriptions, bounded durable delivery retries, cached Calendar `VALARM` reminders, and `calternal-reminders` Log-entry reminders. Added server registration and the minute dispatch job, generated the OpenAPI and TypeScript client, and extended both adversarial rounds for notification input, ownership, and concurrency. Gate output: - `cargo fmt --all --check`: exit 0, no output. - `cargo clippy --all-targets -- -D warnings`: `Finished `dev` profile [unoptimized + debuginfo] target(s) in 24.57s` - `cargo test`: exit 0 for the workspace. Notification crate output: `test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s` - `bash packages/api-client/check-generated.sh` (exit 0): `✨ openapi-typescript 7.13.0` `🚀 ../../contracts/openapi.json → src/generated.ts [1.3s]` - `bash tests/adversarial/run.sh` (exit 0): `==== FINDINGS 0` `==== ROUND 2 FINDINGS 0` - `bun run check`: `svelte-check found 0 errors and 0 warnings` - `bun run test`: ` Test Files 9 passed (9)` ` Tests 80 passed (80)` - `cargo clean`: `Removed 17840 files, 12.0GiB total` Decisions not specified in the design: floating Event times use the server timezone; ambiguous daylight-saving times select the earlier instant; reminders are caught up for 24 hours; the inbox retains at most 10,000 items for 180 days; each User may have 64 subscriptions; delivery batches contain at most eight pushes and retry at most eight times. Push endpoints are restricted to the FCM, Mozilla, and Apple Push domains. Known gaps: Calendar `VALARM` `REPEAT`/`DURATION` and recurring Event instances are not expanded. The `agent_approval_needed` kind has no producer, as decided. There is no sync-conflict producer because current sync conflicts remain in the local `calternal-sync` journal. The UI is outside this job's owned files.
Author
Owner

Starting the notifications UI on branch job/notifications-ui, based on c04d499 (main). Scope: top-bar bell with unread count, inbox panel (floating on desktop, bottom sheet on phones), /notifications deep link, Settings → Notifications with this-device web push toggle and subscription list, service worker push/notificationclick handlers, Playwright e2e against real producers.

Starting the notifications UI on branch `job/notifications-ui`, based on `c04d499` (`main`). Scope: top-bar bell with unread count, inbox panel (floating on desktop, bottom sheet on phones), `/notifications` deep link, Settings → Notifications with this-device web push toggle and subscription list, service worker `push`/`notificationclick` handlers, Playwright e2e against real producers.
Author
Owner

Finished the notifications UI on branch job/notifications-ui (head d3d72af, based on c04d499). Not pushed or merged.

What is in it:

  • Top bar bell with an unread count badge (stops at 99+). It opens the inbox: a floating panel under the bell on wide screens, the bottom sheet on phones. The panel shows Today / Earlier groups, an icon for each kind, the title, the body and the time. Opening an item goes to its link and marks it read. The panel also has Mark all read, per-item delete (a button, the Delete key, or a left swipe on touch), Copy link (menu, or a right swipe), infinite scroll, and an empty state that says what arrives there.
  • /notifications opens the panel (deep link).
  • Settings → Notifications (/settings/notifications/this-device, /inbox): an "on this device" switch. It asks for permission, subscribes through a new service worker (push and notificationclick only, no fetch handler, registered only when turned on) and registers the subscription with the server. The screen explains blocked permission, insecure origins, unsupported browsers and the iPhone/iPad Home Screen requirement. There is no device list and there are no per-kind switches, because the API has neither.
  • Updates: the client polls (a one-item page while the panel is closed), polls again when the tab becomes visible, and the service worker tells open windows when a push arrives.

Server changes (small, for gaps the UI needed):

  • InboxPage.unread_count, plus a partial index (notifications migration 2).
  • PUT /api/v1/notifications/inbox/read?until_ms= marks all read. The bound keeps items that arrive while the list is open unread.
  • A job-queue deadlock: run_handler awaited the lease heartbeat without polling the handler. A handler in a write transaction held the single writer connection, so both waited until the pool timeout (30 s) and the job worker stopped. On a loaded host, Log reminders never arrived. Fixed in calternal-db with a regression test. The server also restarts the job worker after an Index error now.

Gates:

  • bun run check: COMPLETED 1302 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
  • bun run test: Test Files 22 passed (22) / Tests 164 passed (164)
  • bun run build: ✓ built in 25.97s
  • cargo fmt --all --check: exit 0. cargo clippy --all-targets -- -D warnings: exit 0.
  • bash packages/api-client/check-generated.sh: exit 0.
  • cargo test --workspace --no-fail-fast: everything I touched passes. Tests that already failed on c04d499: calendar view::tests::cached_provider_returns_only_enabled_visible_owner_events (no such table: calendar_accounts), and files tests::listing_shared_folder_does_not_assign_item_ids and tests::shared_physical_folder_entries_use_index_keyset_pages. wire::tests::full_app_setup_session_config_and_backup hangs with c04d499's own wire.rs and worker.rs too.
  • tests/adversarial/run.sh: no notifications findings (the new mark-all-read probes included). The other findings are already on main or come from host load: slow task storm, the shared-folder listing, and user-deletion steps that got 403 after the step-up window ran out on a slow run.
  • e2e apps/web/e2e/notifications.mjs: NOTIFICATIONS E2E PASSED. It uses real producers (shares from a second User created through an invite, and a Log reminder from the minute job).
Finished the notifications UI on branch `job/notifications-ui` (head `d3d72af`, based on `c04d499`). Not pushed or merged. What is in it: - Top bar bell with an unread count badge (stops at 99+). It opens the inbox: a floating panel under the bell on wide screens, the bottom sheet on phones. The panel shows Today / Earlier groups, an icon for each kind, the title, the body and the time. Opening an item goes to its link and marks it read. The panel also has Mark all read, per-item delete (a button, the Delete key, or a left swipe on touch), Copy link (menu, or a right swipe), infinite scroll, and an empty state that says what arrives there. - `/notifications` opens the panel (deep link). - Settings → Notifications (`/settings/notifications/this-device`, `/inbox`): an "on this device" switch. It asks for permission, subscribes through a new service worker (`push` and `notificationclick` only, no `fetch` handler, registered only when turned on) and registers the subscription with the server. The screen explains blocked permission, insecure origins, unsupported browsers and the iPhone/iPad Home Screen requirement. There is no device list and there are no per-kind switches, because the API has neither. - Updates: the client polls (a one-item page while the panel is closed), polls again when the tab becomes visible, and the service worker tells open windows when a push arrives. Server changes (small, for gaps the UI needed): - `InboxPage.unread_count`, plus a partial index (notifications migration 2). - `PUT /api/v1/notifications/inbox/read?until_ms=` marks all read. The bound keeps items that arrive while the list is open unread. - A job-queue deadlock: `run_handler` awaited the lease heartbeat without polling the handler. A handler in a write transaction held the single writer connection, so both waited until the pool timeout (30 s) and the job worker stopped. On a loaded host, Log reminders never arrived. Fixed in `calternal-db` with a regression test. The server also restarts the job worker after an Index error now. Gates: - `bun run check`: `COMPLETED 1302 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS` - `bun run test`: `Test Files 22 passed (22)` / `Tests 164 passed (164)` - `bun run build`: `✓ built in 25.97s` - `cargo fmt --all --check`: exit 0. `cargo clippy --all-targets -- -D warnings`: exit 0. - `bash packages/api-client/check-generated.sh`: exit 0. - `cargo test --workspace --no-fail-fast`: everything I touched passes. Tests that already failed on `c04d499`: `calendar view::tests::cached_provider_returns_only_enabled_visible_owner_events` (no such table: calendar_accounts), and `files tests::listing_shared_folder_does_not_assign_item_ids` and `tests::shared_physical_folder_entries_use_index_keyset_pages`. `wire::tests::full_app_setup_session_config_and_backup` hangs with `c04d499`'s own wire.rs and worker.rs too. - `tests/adversarial/run.sh`: no notifications findings (the new mark-all-read probes included). The other findings are already on main or come from host load: slow task storm, the shared-folder listing, and user-deletion steps that got 403 after the step-up window ran out on a slow run. - e2e `apps/web/e2e/notifications.mjs`: `NOTIFICATIONS E2E PASSED`. It uses real producers (shares from a second User created through an invite, and a Log reminder from the minute job).
Author
Owner

Merged local adversarial run at 8d9f86b5 observed one no-response case for a valid PUT /api/v1/notifications/settings/quiet-hours while the 20,000-file watcher-overflow fixture and Search staged rebuild were active. The request timed out without an HTTP response. Other notification calls in the same run returned responses, including one slow reminder creation. This is separate from preview attach and has no confirmed root cause. The one adversarial run was stopped at its 25-minute limit.

Merged local adversarial run at `8d9f86b5` observed one no-response case for a valid `PUT /api/v1/notifications/settings/quiet-hours` while the 20,000-file watcher-overflow fixture and Search staged rebuild were active. The request timed out without an HTTP response. Other notification calls in the same run returned responses, including one slow reminder creation. This is separate from preview attach and has no confirmed root cause. The one adversarial run was stopped at its 25-minute limit.
Author
Owner

Hygiene review: the valid PUT /api/v1/notifications/settings/quiet-hours timed out without a response during the latest adversarial run. The cause is unconfirmed, so #22 stays open.

Hygiene review: the valid `PUT /api/v1/notifications/settings/quiet-hours` timed out without a response during the latest adversarial run. The cause is unconfirmed, so #22 stays open.
Author
Owner

Already implemented on origin/dev. git log origin/dev --grep='(#22)' shows 6e755d35d, which merges the in-app inbox, web push flow, settings controls and notification UI. Current apps/web/src/lib/notifications/InboxPanel.svelte, inbox.svelte.ts and push.ts implement the inbox and subscription flow; the notifications plugin exposes the server routes. Recommend recording the merged implementation here. Do not close the issue in this audit.

Already implemented on origin/dev. git log origin/dev --grep='(#22)' shows 6e755d35d, which merges the in-app inbox, web push flow, settings controls and notification UI. Current apps/web/src/lib/notifications/InboxPanel.svelte, inbox.svelte.ts and push.ts implement the inbox and subscription flow; the notifications plugin exposes the server routes. Recommend recording the merged implementation here. Do not close the issue in this audit.
Author
Owner

Fixed in 6e755d35d (origin/dev); covered by apps/web/e2e/notifications.mjs and the notifications worker tests in crates/calternal-db/tests/queue.rs.

Fixed in 6e755d35d (origin/dev); covered by `apps/web/e2e/notifications.mjs` and the notifications worker tests in `crates/calternal-db/tests/queue.rs`.
kayg closed this issue 2026-10-03 11:55:13 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#22
No description provided.