`medication.clientManaged` and `measurementReminder.clientManaged` mean
the iOS client schedules the reminder locally, so the server should not
push it. That is what the OpenAPI description, the settings copy and the
iOS contract all say. What the crons did was skip the dispatch entirely,
so a user who flipped the switch also lost Telegram, ntfy, webhook,
email and Web Push — channels they may well read on a desktop while the
phone holding the local schedule sits in a drawer.
Move the gate into the dispatcher's channel loop, where it applies to
APNs and nothing else. The skip is not counted as an attempt, so
`dispatched` keeps meaning "something reached the user", and it stays
countable: the same wide-event keys the crons emitted are emitted here,
plus a `skipped` row in the push-attempt ledger.
The Vorsorge path is the sharper half. It suppressed every channel and
then advanced `nextDueAt`, which dropped the reminder out of the
preventive-care card and out of the daily digest: a reminder that never
reached anyone was recorded as handled. The advance now happens only
where it always belonged, after a dispatch that a channel actually
delivered. When nothing delivers, the reminder stays overdue and visible.
The push endpoint host is user-controlled — the browser supplies it at
subscribe time. `isPublicUrl` guards it at subscribe time and again before each
dial, but it is a literal input-time check and cannot see a DNS rebind between
the accept and the connect; with a short-TTL record the dial reaches the pod's
private network.
`web-push`'s `sendNotification` dials with its own internal `https.request`,
which bypassed `safeFetch` and therefore the connect-time pin entirely. Both
senders now sign and encrypt through `generateRequestDetails` — identical VAPID
handling and payload encryption — and dial through `safeFetch` with
`requirePublicHost`. Non-2xx is re-shaped as the `{ statusCode }` rejection the
existing classification already understands, so the 410/404 expiry reap and the
transient/permanent split are unchanged.
This is the last user-controlled-host egress that was not behind the pinned
dispatcher (issue #217).
The MEDICATION_INTAKE_SYNC fan-out used to fire one silent APNs per
distinct dose slot, so a bulk intake marking five doses woke every other
device five times. Replace the per-slot dispatch with a per-user
coalescer: the first mutation in a burst dispatches immediately, every
further mutation inside the 2 s window folds into one trailing fan-out.
A bulk request now costs exactly one queue call, and a rapid burst of N
single-dose mutations costs at most two fan-outs. The window state is
in-process; multiple app instances throttle independently, which is
accepted because the iOS reconcile is idempotent and the deployment
model is a single container.
The silent payload shrinks to a pure sync trigger — eventType +
syncedAt. The per-slot medicationId / scheduledFor fields are gone: the
iOS handler runs a full reconcile regardless, a coalesced burst has no
single slot to name, and a push payload travels through Apple's
infrastructure, so it now carries no health data by construction. The
Live Activity end push likewise sends only the ActivityKit envelope.
The trailing flush keeps the origin skip only when every coalesced
mutation came from the same device; a mixed-origin burst wakes everyone.
Each flush still records one push_attempts ledger row, reaps
hard-rejected device tokens, and annotates the wide event with the
recipient count and coalesced-mutation count.
A channel whose stored config cannot be decrypted or JSON-parsed is a
structurally-permanent failure, not a transient blip: the ciphertext/key
pairing and the malformed body decode the same way on every retry. Routing
it through the transient path burned the 5-failure backoff budget and ended
on the misleading `give_up_after_5_failures` reason — during a key-rotation
gap every channel hit this and auto-disabled for the wrong reason.
Classify decrypt and parse failures as hard rejects so the channel
auto-disables on first occurrence with the precise reason
(`<type>_config_decrypt_failed` / `<type>_config_parse_failed`). Genuine
transient failures (network, timeout, 5xx) keep the existing backoff path.
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
Self-hosters without an Apple Developer account rely on Web Push to the
installed PWA — there is no Live Activity or lock-screen widget to drive.
Make that path carry the dose lifecycle on its own:
- Stable per-slot tag. The medication dose-due reminder now sets a
`med:<id>:<iso-slot>` Web Push tag (medicationDoseTag), so a re-fired
reminder for the same slot coalesces instead of stacking, and a later
clear can target it.
- Clear-on-taken. When a dose is marked taken or skipped, push a
`type:"clear"` notification reusing that slot tag. The service worker
closes the matching pending reminder and refreshes the badge — the PWA
equivalent of ending a Live Activity. A snooze leaves the dose
outstanding and clears nothing. Wired into the single + bulk intake
routes alongside the existing APNs cross-device sync.
- App badge. The dispatcher carries a server-authoritative outstanding-
dose count; the SW reflects it via the Badging API
(setAppBadge/clearAppBadge), feature-detected and a no-op where
unsupported.
- The SW push handler honours requireInteraction for urgent events and
renotify so a re-fired reminder re-alerts.
Dose-due, preventive-care, and low-stock reminders already cascade to
Web Push when no APNs channel exists; tests pin that an APNs-less
instance still delivers.
Introduce the MEDICATION_INTAKE_SYNC event and the server-side dispatcher
that wakes a user's other iOS devices after a dose is logged on one of
them, so a running Live Activity or Home-Screen widget reconciles against
the canonical server state.
The dispatch is APNs-only by construction: it sends a silent
apns-push-type: background push (content-available, no alert) carrying
the affected medicationId + scheduledFor, and never fans out to Telegram,
ntfy, or Web Push, which have no silent-sync transport. A new raw APNs
send helper carries the background and liveactivity push types without
the alert/sound/badge enrichment the alert path injects. Devices APNs
reports as permanently dead are reaped; the originating device is
excluded from the fan-out via its registered device token.
When a device has a stored Live Activity push token, an additional
liveactivity end push goes to the per-Activity topic so the lock-screen
Activity ends immediately rather than waiting for the next background
wake.
Reminder pushes (medication-due, mood, briefing, cycle) carried the
colour-coded phase markers the Telegram channel uses, which leaked onto the
iOS lock screen and other push channels as decorative bubbles. Strip emoji
in the APNs, Web Push and ntfy senders for every event except SYSTEM_ALERT,
so routine reminders render as plain text while system / failure alerts keep
their severity glyphs. Telegram is unchanged — emoji stay idiomatic there.
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, disabled_reason
captured, audit log notification.channel.auto_disabled written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter consecutive_failures increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
columns (recordChannelSuccess, recordChannelHardReject,
recordChannelTransientFailure, reEnableChannel,
isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
give-up. Sender-throws absorbed as transient failures.
Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).
Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>