Skip to content

feat(outreach): calls and texts are approved only by a person in the dashboard - #699

Merged
kmonsoe merged 1 commit into
mainfrom
feat/outreach-voice-approval-gate
Jul 30, 2026
Merged

feat(outreach): calls and texts are approved only by a person in the dashboard#699
kmonsoe merged 1 commit into
mainfrom
feat/outreach-voice-approval-gate

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

PR 3 of the outbound-calling series. Stacked on #698 — review that first; this PR's base is feat/outreach-proposal-destination and it needs that branch's delivery field.

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 stays pending:

  • MCP agents, in any host
  • an unrestricted mn_admin_* key on /v1/outreach/proposals/:id/approve
  • curators and other in-process agents
  • the Slack approval button

Email 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, and dispatch.ts has no visibility check — it gates on audience and scopes only. The control plane isn't a human-only surface either: control-plane.guard.ts:31 admits admin_agent actors with scope *. So a rule anywhere but the service would have holes in it. In approveProposal it 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_confirmation status 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.channelType from #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 / blackoutDates were stored on the campaign and read only inside the listDueFollowups SQL. Nothing consulted them at send time, so nothing stopped a 3am call. They're now enforced when a call or text is approved.

cadenceRules gains an optional quietHoursTimezone (IANA, validated with Intl at 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. cadenceRules is 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_proposal on a voice proposal gets an error and the proposal is still pending afterwards.

Three existing voice tests approved as an admin_agent and now correctly fail, so the voice block's runAsSystem takes an actor and they pass a user — the send path is still covered, just by the caller that's allowed to do it.

Full @getmunin/backend-core suite green at 1304 tests. pnpm typecheck clean across 31 tasks. skill://outreach/review-proposals rewritten: 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

@kmonsoe
kmonsoe deleted the branch main July 30, 2026 10:34
@kmonsoe kmonsoe closed this Jul 30, 2026
@kmonsoe kmonsoe reopened this Jul 30, 2026
@kmonsoe
kmonsoe changed the base branch from feat/outreach-proposal-destination to main July 30, 2026 10:35
…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
kmonsoe force-pushed the feat/outreach-voice-approval-gate branch from f1670a5 to d879259 Compare July 30, 2026 10:36
@kmonsoe
kmonsoe merged commit b98f618 into main Jul 30, 2026
2 checks passed
@kmonsoe
kmonsoe deleted the feat/outreach-voice-approval-gate branch July 31, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant