Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(notify): honor an explicit email opt-out with no other channel enabled

merged#2889CedarCopilot

CedarCopilot wants to merge 4 commits into staging from fix/notify-honor-explicit-email-off-20260922-180835

Live on prod, 5 of 10 surfaces working, 2 days leftTimeline and evidence
  1. Opened
    Sep 22, 2026, 6:13 PM
  2. Sep 22, 2026, 7:33 PM
  3. Merged
    Sep 22, 2026, 7:45 PM
  4. Live on prod
    Sep 22, 2026, 7:45 PM
  5. Observed 0 hours, 10 surfaces, 2,806 requests
    Sep 22, 2026, 7:45 PM
  6. Watching

    Live on prod, 5 of 10 surfaces working, 2 days left

    Sep 22, 2026, 7:45 PM
  7. Pipelines steady after this deploy
    Sep 22, 2026, 7:45 PM

Behaviors Libra is checking

Access-request workflows treat an approver whose channels are all muted as neither notified nor failed, excluding that recipient from the sent count and warning logs.Not checked
prod
Agent run notification history omits successful notify-user calls that were suppressed because the recipient muted every channel.Not checked
prod
Direct agent additions whose notification is suppressed for a fully muted recipient report `notified:false` even though the underlying setup succeeds.Not checked
prod
A muted recipient's pending agent-share invite is not marked with `notifiedAt`, so the system can retry the notification rather than treating the undelivered invite as complete.Not checked
prod
When a user explicitly disables email and has no other enabled notification channel, Cedar resolves no channel instead of defaulting to email, while preserving the default for unset email preferences and honoring enabled or explicitly preferred sibling channels.Not checked
prod
When an enabled non-email channel fails for a user who explicitly disabled email, Cedar reports the delivery failure instead of falling back to email.Not checked
prod

Libra measured 5 of 10 production surfaces on prod. 2 surfaces had under 20 requests, so Libra has not judged them; 3 surfaces had no requests at all. Libra checks hourly for 3 days after each deploy.

What was wrong

resolvePrimaryChannel (apps/server/src/services/notifications/primary-channel.ts:100) resolves which single channel a notify-user call goes to. When no channel is explicitly enabled: true in a user's settings, it falls through to a hardcoded DEFAULT_NOTIFICATION_CHANNEL = 'email' , by design, for someone who never touched their notification settings at all.

The gap: that fallback branch never checks whether emailNotifications.enabled was itself explicitly set to false. So a user who deliberately turned email off, and hasn't connected an alternate channel yet, gets emailed anyway , every single notify-user call, not just the daily agenda digest (which has its own, separate enabled/agendaEmail check in process-agenda-emails.ts that already handles this correctly).

Evidence: Kaelin Pallavicini (Strac, <email>) asked to stop Cedar emails until Slack is connected. Her notification_settings had no channel explicitly enabled (Slack not connected). Pulling her last 30 hours of agent_tool_calls for notify-user showed 15 events, all delivered on email , task/draft/reply alerts, not just the digest, including two that explicitly requested slack as the preferred channel and still fell back to email. Separately, 3 production users already have emailNotifications.enabled: false stored with nothing else enabled, and are being silently emailed anyway today , this bug already affects real accounts, not just a hypothetical.

What changed

  • primary-channel.ts: resolvePrimaryChannel returns null instead of 'email' when nothing is enabled and emailNotifications.enabled === false was explicitly set. Every other case (nothing configured at all, one channel enabled, explicit caller preference) is unchanged.
  • deliver-to-user.ts: resolveChannelFor's return type widens to NotificationChannel | null; notifyUserPlain (used by access-request and agent-share-invite notifications) treats null as "suppress, report success" rather than forcing a send.
  • notifyUserTool.ts: the notify-user tool's apply() treats null the same way , logs it, returns { success: true, muted: true } instead of calling deliverWithEmailFallback, so the agent doesn't see an error or retry.

Untouched, checked explicitly:

  • Default-on behavior for anyone who has never set emailNotifications.enabled , still resolves to 'email'.
  • login-notifier.ts's new-device sign-in alert , never routed through resolvePrimaryChannel at all (it always mails directly, on purpose, since Slack could be reached via the very Google account being alerted about).
  • The agenda-email cron , already independently checks enabled === false and skips.

How to test

  • pnpm --filter @zero/server exec vitest run src/services/notifications/__tests__/primary-channel.test.ts , 15 passing, including 4 new cases for the explicit-opt-out path.
  • Regression check: src/mastra/tools/notify/__tests__/notifyUserTool.test.ts, src/services/agents/__tests__/share-invite.test.ts, src/services/users/__tests__/resolve-user-settings.test.ts, src/services/users/__tests__/categorized-key-access.test.ts , all passing, no changes needed.
  • pnpm --filter @zero/server run types , clean.
  • Once deployed: Kaelin's user_settings row will be updated with emailNotifications.enabled: false + agendaEmail: false , verify her subsequent notify-user tool calls report muted: true in agent_tool_calls.result instead of deliveredOn: 'email'.

πŸ€– Generated with Claude Code

Show production surfaces and changed-file mapping

Production surfaces

SurfaceRequestsErrorsp95UsersVerdict
/a8n/notify/google1134 β†’ 17880 β†’ 0 (0%)195 ms β†’ 236 ms0Working
1,788 requests since the deploy with 0 errors (0 errors in the 1,134-request baseline before it). p95 236 ms, was 195 ms.
POST /a8n/notify/google378 β†’ 5960 β†’ 0 (0%)198 ms β†’ 237 ms0Working
596 requests since the deploy with 0 errors (0 errors in the 378-request baseline before it). p95 237 ms, was 198 ms.
/a8n/notify/google-calendar195 β†’ 2850 β†’ 0 (0%)73 ms β†’ 72 ms0Working
285 requests since the deploy with 0 errors (0 errors in the 195-request baseline before it). p95 72 ms, was 73 ms.
POST /a8n/notify/google-calendar65 β†’ 950 β†’ 0 (0%)75 ms β†’ 76 ms0Working
95 requests since the deploy with 0 errors (0 errors in the 65-request baseline before it). p95 76 ms, was 75 ms.
orchestrator_agent_step24 β†’ 290 β†’ 0 (0%)36298 ms β†’ 191942 ms18Working
29 requests since the deploy with 0 errors (0 errors in the 24-request baseline before it). p95 191942 ms, was 36298 ms.
model_chunk On-Event Execution Agent (Understudy)0 β†’ 80 β†’ 0 (0%)not measured β†’ 3 ms0Insufficient traffic
8 requests, under the 20 Libra needs
model_chunk Automation Agent (Understudy)9 β†’ 50 β†’ 0 (0%)1 ms β†’ 1 ms0Insufficient traffic
5 requests, under the 20 Libra needs
/api/trpc/agentExecutions.getAgentExecutions0 β†’ 00 β†’ 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/aopAgents.listForConversation0 β†’ 00 β†’ 0 (0%)not measured0No traffic
No requests recorded since this deploy.
/api/trpc/agentExecutions.findRecentTrees0 β†’ 00 β†’ 0 (0%)not measured0No traffic
No requests recorded since this deploy.

Changed files β†’ surfaces

  • apps/server/src/mastra/tools/notify/notifyTool.ts/a8n/notify/googlePOST /a8n/notify/google/a8n/notify/google-calendarPOST /a8n/notify/google-calendar
  • apps/server/src/trpc/routes/agent.tsorchestrator_agent_stepmodel_chunk On-Event Execution Agent (Understudy)model_chunk Automation Agent (Understudy)/api/trpc/agentExecutions.getAgentExecutions/api/trpc/aopAgents.listForConversation/api/trpc/agentExecutions.findRecentTrees
  • apps/server/src/mastra/tools/notify/notifyUserTool.tsno production surface mapped
  • apps/server/src/services/access/requests.tsno production surface mapped
  • apps/server/src/services/agents/share.tsno production surface mapped
  • apps/server/src/services/notifications/__tests__/primary-channel.test.tsno production surface mapped
  • apps/server/src/services/notifications/deliver-to-user.tsno production surface mapped
  • apps/server/src/services/notifications/primary-channel.tsno production surface mapped