feat(outreach): calls and texts are approved only by a person in the dashboard - #699
Merged
Merged
Conversation
This was referenced Jul 30, 2026
…dashboard A proposal on a voice or SMS channel can only be approved by an actor of type 'user'. Every other caller is refused and the proposal stays pending: MCP agents, unrestricted admin API keys on the control plane, curators, and the Slack button. Email approval is untouched. The rule lives in OutreachService.approveProposal rather than in MCP tool metadata, because _meta.ui.visibility is a hint the host may or may not honour and the control plane admits admin_agent actors too. One check, every surface. Slack declines in-thread pointing at the dashboard instead of surfacing a service error. The panel drops approve for these proposals — dismiss stays, since dismissing sends nothing — and says where the call is placed. Quiet hours and blackout dates are enforced at approve time for calls and texts; they were stored on the campaign and only ever read when listing due follow-ups, so nothing stopped a 3am call. cadenceRules gains an optional validated quietHoursTimezone, since reading them in UTC is not what a Norwegian campaign means by "no calls before 08:00". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm
kmonsoe
force-pushed
the
feat/outreach-voice-approval-gate
branch
from
July 30, 2026 10:36
f1670a5 to
d879259
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 3 of the outbound-calling series. Stacked on #698 — review that first; this PR's base is
feat/outreach-proposal-destinationand it needs that branch'sdeliveryfield.The rule
A proposal whose campaign runs on a voice or SMS channel can only be approved by an actor of type
user— a signed-in dashboard session. Everything else is refused and the proposal stayspending:mn_admin_*key on/v1/outreach/proposals/:id/approveEmail approval is untouched, including by agents and admin keys — that flow was already blessed by the directory review and there's no reason to add friction to it.
Why the service and not tool metadata
_meta.ui.visibility: ['app']is a hint. Hosts without MCP Apps list the tool normally, anddispatch.tshas no visibility check — it gates on audience and scopes only. The control plane isn't a human-only surface either:control-plane.guard.ts:31admitsadmin_agentactors with scope*. So a rule anywhere but the service would have holes in it. InapproveProposalit holds no matter which surface the call arrives through, and it's a property we can point at rather than a behaviour we hope the client implements.Earlier drafts of this had an
awaiting_confirmationstatus and a second confirm step. That was worse: if the panel's approve just moves a status and the same person then confirms in the dashboard, they clicked twice for one decision. Restricting the first approval is one decision in one place, and it removes a migration, a status, a token, a confirm endpoint and a queue filter change.Surfaces
Slack declines in-thread — ":telephone_receiver: Calls are approved in the Munin dashboard, not from Slack" — rather than letting a service error surface. It reads
delivery.channelTypefrom #698, so it never calls approve at all.Panel drops the approve button for voice and SMS. Dismiss stays, since dismissing sends nothing and is useful. In its place, a line naming the destination and pointing at the dashboard.
Dashboard is unchanged and already correct — it's the one surface that works.
Quiet hours actually apply now
quietHoursStart/quietHoursEnd/blackoutDateswere stored on the campaign and read only inside thelistDueFollowupsSQL. Nothing consulted them at send time, so nothing stopped a 3am call. They're now enforced when a call or text is approved.cadenceRulesgains an optionalquietHoursTimezone(IANA, validated withIntlat the tool boundary, falling back to UTC if imported data slips through). Reading quiet hours in UTC is not what a Norwegian campaign means by "no calls before 08:00" — it'd be off by one or two hours depending on DST.cadenceRulesis jsonb, so no migration.Tests
Five new unit tests: an agent is refused and no Vapi call is placed and the proposal stays pending; a human is refused inside quiet hours; a human is refused on a blackout date; an agent can still dismiss; email approval by an agent still works. Plus an end-to-end MCP test: an admin key calling
outreach_approve_proposalon a voice proposal gets an error and the proposal is still pending afterwards.Three existing voice tests approved as an
admin_agentand now correctly fail, so the voice block'srunAsSystemtakes an actor and they pass auser— the send path is still covered, just by the caller that's allowed to do it.Full
@getmunin/backend-coresuite green at 1304 tests.pnpm typecheckclean across 31 tasks.skill://outreach/review-proposalsrewritten: it previously documented the model-callable approve recipe under a "Without a panel" heading, which is exactly the bypass this PR closes.🤖 Generated with Claude Code
https://claude.ai/code/session_011HwhYb3yU3qnGjtFRodQxm