CedarCopilot wants to merge 6 commits into staging from feat/notify-user-post-meeting-drafts
Live on prod, no production signal yet
Libra has verdicts on 0 of 3 tracked behaviors on prod; 3 are still being checked. Libra checks hourly for 3 days after each deploy.
Post-meeting "your draft is ready" was the one notification in Cedar a user could not
control. draft-email and draft-slack fired it themselves, programmatically, through
userNotificationRegistry.post_meeting_draft_ready , so it could not be moved to another
channel, reworded, or switched off, and it ignored the playbook entirely. Every other agent
notifies through notify-user, driven by prose the user owns.
This migrates it onto notify-user, and deletes the machinery that existed only to serve it.
| Layer | Change |
|---|---|
lib/cedar-routes.ts | New ExternalRoute enum + externalUrl() for absolute links (cedarUrl prepends VITE_PUBLIC_APP_URL, so it can't build them). The patterns and the #all/ vs #drafts/ rule are taught in generateCedarLinksBlock(), which every preamble carries. |
agents/on-event-execution-agent.ts | Grants notify-user. Corrects the prompt, which claimed the draft tools fire a DM and banned notify-user outright. |
orchestrator-dispatch-tools.ts | Appends a <notification_policy> fence , inside the drafter?.instructions branch. |
agent-defaults/drafter.ts | A "Telling the user the recap is waiting" section the user can edit or delete. |
| Deletions | LAYER 6.5, LAYER 9, and everything only they reached. |
Settled before this PR: in the 30 days to 2026-08-29 the model wrote 6,341 link-button URLs,
including 522 Salesforce, 161 HubSpot, 19 Notion and 3 app.slack.com , hosts no registry could
have enumerated. So the leverage is in teaching the shape once, centrally, not in plumbing
resolved URLs through requestContext.
All four were tested by clicking in production. Do not "tidy" them.
https://mail.google.com/mail/u/0/#all/{gmailThreadId}https://mail.google.com/mail/u/0/#drafts/{gmailThreadId} (#all/ finds nothing; an unsent draft isn't in All Mail)https://slack.com/app_redirect?team={teamId}&channel={channelId} (chosen over app.slack.com/client and slack:// because it prompts to open the desktop app)CedarRoute.EmailThreadu/0 means "first signed-in account". The u/{email} form Gmail shows in the address bar 404s
when pasted , the enum member carries a comment saying so, and a test asserts {email} never
appears.
orchestrator-dispatch-tools.ts documents: THE DOCUMENT'S PRESENCE IS THE SWITCH , an account
with no drafter.md gets a byte-identical pre-drafter prompt. That is the whole safety argument
during a per-account backfill, so the <notification_policy> block is built inside the same
if (drafter?.instructions) branch as <drafter_instructions>. No document → no block → no grant,
and the executor's own prompt says "with no such block, do not notify at all".
drafter-executor-prompt.test.ts already pinned that with an exact string comparison; this adds an
explicit not.toContain('<notification_policy>') on the no-document path and a placement assertion
on the document path.
Each was grepped across apps/server and apps/mail before removal:
onEventExecutionDraftEmailTool.ts) and LAYER 9 (onEventExecutionDraftSlackTool.ts) , the two post_meeting_draft_ready call sites. LAYER 6.5 held savedDraft.threadId!, a forbidden non-null assertion that goes with it.user-notification-registry.ts (whole file) , post_meeting_draft_ready was its only entry.notification-dispatcher.ts (whole file) and DispatchPayload , the registry's send was their only caller. Nothing imports the module now. loops-client.ts is still used by four other call sites and stays.PostMeetingDraftReadyNotification, formatPostMeetingDraftMessage, `formatMeetingTLibra has not measured any production surfaces for this change yet.