Suppress approval pushes the user is already looking at
Status summary#
Core suppression implemented end to end: intent carries the batch identity
top-level, the claim event exists on the root stream, devices grace-delay
approval pushes and settle them suppressed on a claim, the DO grace alarm
nudges the send when no claim comes, and the mobile in-thread card appends
the claim. Scope grew mid-review (Misha): a mobile Notifications view
(past notifications with statuses incl. "already on screen", deep
links per row) — built, with a comparison playwright spec (3 consecutive
local passes) and a rendered comparison video handed to the orchestrator.
Remaining: final full-suite gate + CI. See the implementation log for
accepted edge cases.
Ask (Misha, via #2339 follow-ups)#
Only send the approval push notification if the user isn't already in the app: give the mobile client ~a second to say "don't worry, I've shown it" — the in-thread dialog sends that claim when it renders. A claim inside the window cancels delivery for that batch; a claim after the push went out is a harmless no-op.
Design#
Constraints settled during #2339 (see its implementation log):
- The notification processor stays stateless-per-event (ADR 0007). The grace
window lives in the DEVICE processor, whose obligations are already
state-derived with an alarm (
receiptCheckDelayMssets the precedent). - The seen-claim must reference the approval BATCH offset — the client sees
root-stream approval events, never device-stream offsets. So the
notification/requestedintent must carryapprovalRequestEventOffseteven foragent-chatdestinations (today only theapprovalsdestination carries it). - Grace expiry needs the device DO's alarm to nudge the state-derived send pass — with no new events, nothing else wakes it.
Decisions made while fleshing out (assumptions, flag in PR if wrong):
- Intent change: hoist
approvalRequestEventOffsetto a top-level optional field on thenotification/requestedpayload (set for approval batches regardless of destination kind). The destination union keeps its current shapes. - Claim event:
project/approval-presented{ approvalRequestEventOffset }, appended to the project ROOT stream by the mobile chat screen when an in-thread batch card renders while the app is foregrounded (one claim per batch per screen mount is enough; duplicate claims are no-ops). Root stream, not the device stream: the user saw it, so EVERY device's pending push for that batch should die, and the root stream is what all devices already subscribe-copy from. - Copy rule: extend the existing device subscription (the one that copies
notification/requestedonto each device stream) to also copyproject/approval-presented. - Device processor: obligations whose intent carries
approvalRequestEventOffsetare not runnable untilrequestedAt + config.approvalGraceMs(default ~1500ms). The at-head pass arms the DO alarm at the earliest pending grace expiry (same slice pattern as receipt checks). A reduced claim marks matching open obligations; the send pass settles them with a new terminal outcomesuppressedinstead of attempting. Claims for unknown/settled obligations reduce to nothing. - Expiry interplay: a batch's own
expiresAtkeeps working unchanged; grace only delays the attempt, never extends anything.
Checklist#
-
notification/requestedintent carriesapprovalRequestEventOffsetfor approval batches on both destination kinds (+ notification processor test) optional top-level field in notification-intent-contract.ts (0.2.0); set fromevent.offsetin notification-processor-implementation.ts; both existing destination-kind tests now assert it -
project/approval-presentedevent type on the project root stream, appended by the mobile chat screen when the in-thread dialog renders foregrounded defined in the standaloneapproval-presented-contract.tscatalog (spread into the project contract's events — the device contract must consume it too and cannot import the project contract back); appended byInThreadApprovalCardvia a staleTime-Infinity useQuery gated onAppState.currentState === "active", idempotency-keyed per batch -
Device subscription copies the claim event onto device streams
notificationIntentSubscriptionEvent's filter now lists both types; existing devices pick it up on their next enrolls/token-update re-arm -
Device processor:
approvalGraceMsconfig, grace-delayed attempts, alarm nudge at earliest grace expiry,suppressedterminal outcome contract 0.5.0:approvalGraceMs(default 1500ms), obligations gain requestedAt/presentedAt/approvalRequestEventOffset,suppressedoutcome; implementation: claim reduce, suppressed sweep, grace-gated send loop,releaseApprovalGracescalled by the DO's newdevice-approval-gracealarm slice -
Device processor tests: push goes out after grace with no claim; claim inside grace suppresses (no attempt started); claim after send is a no-op; alarm re-arms correctly; non-approval intents are unaffected "DeviceProcessor approval-push suppression" section in device-processor.test.ts — four specs matching exactly these cases Scope added after review (Misha, on the PR):
-
Mobile Notifications view: flat newest-first list of every past notification for this device with a human status per row (Sent / Delivered / "Skipped — already on screen" / Expired / Send failed / …) and an in-app deep link per row
apps/mobile/src/app/project/[projectId]/notifications.tsx+ pure reducerlib/notifications.ts(unit-tested); drawer entry next to Approvals; rows route through the existing push-tap routing inlib/notification-routing.ts -
Comparison spec: the same script-parked batch suppressed when sitting in its thread vs sent when elsewhere, both statuses asserted via the Notifications view UI, deep-link tap asserted
specs/mobile/notifications.spec.ts— three consecutive local passes (~22s each); the device is stood up server-side with an undeliverable Expo token (web has no push channel), so the "sent" lane's deterministic terminal status is "Send failed" — see implementation log -
Comparison video (
VIDEO_MODE=1),video-rendered.webmhanded to the orchestrator for the PR body 20s webm; final frame shows both rows side by side ("Send failed" vs the accent-colored "Skipped — already on screen") -
pnpm typecheck && pnpm lint && pnpm knip && pnpm test; PR hygiene
Out of scope#
- Web dashboard claims (mobile first; the event shape is client-agnostic)
- Per-device (rather than per-user) suppression semantics
- Any change to expiry or decision semantics
Implementation log#
- Contract ownership vs import cycle: the project contract imports the
device contract (processorDeps), so the device contract could not import
the claim's schema from it.
defineProcessorContractaccepts standalone event catalogs asprocessorDeps, so the claim lives inapps/os/src/domains/projects/approval-presented-contract.ts, spread into the project contract'sevents(docs-site ownership) and listed as a dep catalog by the device contract (typed consumption). One schema, no drift. - Why an explicit
releaseApprovalGracesmethod instead of "the alarm nudges catchUp": the runner's catchUp early-returns when there are no new events (stream-processor-runner.ts, thepending.length === 0 && scanned === committedguard), so it never fires the eventless at-head pass on a pure clock tick. Grace expiry appends nothing, hence the checkReceipts-shaped method the DO alarm calls with the current state; it re-arms/disarms its own slice. - Accepted race (per spec): a claim copied onto the device stream BEFORE its intent copy reduces to nothing, and the later intent still sends after grace. Requires the mobile claim to beat the notification processor's intent append on the root stream — unlikely, and the failure mode is just the pre-feature behavior (push arrives while looking at the app).
- Replay caveat carried over from #2339: intents already committed without the top-level field would re-append with a DIFFERENT body if the notification processor ever re-processed those events with lost progress (idempotency same-key conflict → wedge). Same exposure #2339 accepted when it changed the destination shape; progress loss without cache loss is a crash-window rarity. Old committed intents (no top-level field) reduce to UNGATED obligations on devices — they send immediately, documented by the existing "copied project notification intent" test.
- Existing devices' subscriptions keep the old one-type filter until their next enroll/push-token-update re-arms the rule (the mobile app re-enrolls on launch), so suppression activates per device organically.
- Notification-intent contract bumped 0.1.0 → 0.2.0 (additive optional field); device contract 0.4.0 → 0.5.0 (state shape change refolds caches).
Notifications view + spec (the added scope):
- Web build has NO device enrollment (
enrollPushDevicereturns null on web; #2337's auto-enroll is the approval KEY, not a device). The spec therefore fixes the browser's device identity up front (the web secure store is localStorage) and enrolls that id server-side via admin itx with a format-valid but undeliverable Expo token — the same "non-user step" lane the approvals spec uses for egress rules. - "Sent" on web is "Send failed", deterministically: probed the real
Expo API — a fabricated token gets ticket
error: DeviceNotRegisteredat SEND time (not only at receipt time), so the sent-lane obligation settlesrejected-by-exposeconds after grace, and the rejection also revokes the fake device (why that lane runs LAST in the spec). A real phone would show Sent → Delivered. The view's status mapping is honest about it. - Status vocabulary (lib/notifications.ts): Waiting to send / Sending / Sent (ticketed) / Delivered (accepted-by-push-service) / Skipped — you were already looking (suppressed) / Expired before sending / Not sent — notifications were off (device-unavailable) / Send failed (rejected-*) / Delivery unknown (uncertain + unrecognized future outcomes).
- The deep-link tap at the spec's end doubles as the live demonstration that a LATE claim is a no-op: opening the offending thread renders its still-open batch card, whose claim fires after the push already went out.
- Spec gotcha: project slugs must not contain "notifications" — Playwright's getByText substring-matches header titles.
Review round 2 (video feedback, comment 5140442265):
- "Cut-off drawer" root cause: a spec/video artifact, not a product bug. Playwright pressed the drawer item ~60% into the 180ms slide-in (the press works mid-slide), and middlewright's video-mode freezes the click-moment screenshot while it draws its synthetic pointer — so the rendered video showed a half-open drawer for a long beat. Verified product-side with a settled screenshot at the exact spec viewport (390x844): the panel rests at translateX(0), nothing clipped. Fix: the spec now polls the drawer item's bounding box until it stops moving before pressing.
- Status copy rework: terminal statuses read past-tense, no second person. "Skipped — you were already looking" → "Skipped — already on screen"; everything else already read fine (Sent / Delivered / Send failed / Expired before sending / Not sent — notifications were off / Delivery unknown); in-flight stays present ("Waiting to send", "Sending").