Skip to content

Plugin reply refusals: push the owner, hand the reply back (bounded), pre-check before approval - #593

Open
ZacxDev wants to merge 4 commits into
mainfrom
plugin-draft-send-failures
Open

ZacxDev wants to merge 4 commits into
mainfrom
plugin-draft-send-failures

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Nov 11, 2026

Copy link
Copy Markdown
Owner

What was wrong

When a plugin reported a reply draft's result as ok: false, muster recorded failed and an audit row, and that was all. The route had already been closed fallback when the draft was filed, so plugin_messages_list still called the message relayed. Nobody was pushed, the chief was not told, and the session that wrote the reply was never asked for a new one. Before that, plugin_draft_decide had told the session the approved text "sends exactly this text, once". Nothing checked a reply before the owner was asked to approve it. None of this was specific to one plugin.

What changes

A refused reply comes back (a failed result, or a refused pre-check):

  • The draft's session route ends in a new terminal state, send_failed (migration 0112). This happens in the same transaction, and the route then owes a hand-back. plugin_messages_list reports send_failed, not relayed.
  • The route sweep hands the message back to the session that wrote the reply. It does this as a new route (routed_via redraft of <route> to <session>). The new paste says the earlier reply was not sent, and quotes the plugin's reason and the refused text as fenced data. The chief gets a note.
  • Bounded: this happens at most plugins.MaxRouteRedrafts (2) times per message. The decision is made once and stored on the row (session_routes.redraft). The next refusal goes to the chief as an ordinary hand-back, the same path a timed-out route takes. So does any case where the session's window is gone.
  • The owner is pushed on the draft's own tag (plugin-draft-<id>), with the plugin's reason on one line. This happens for every failed send. For a pre-check refusal it happens only when the reply is not going back to its session.
  • A draft the chief filed itself (reply_draft, no route) is noted to the chief in its plugin session.
  • All of this runs once, on the call that recorded the refusal. FinishDraft now reports fresh, so a repeated result changes nothing.

Pre-check before approval (draft_checks, a new capability chosen when the token is minted, like every capability):

  • A pending draft for such a plugin's message has check_state = 'awaiting'. While it waits, the owner gets no push and no attention item. The Asks card offers Reject only, and plugin_drafts_list marks it awaiting_plugin_check.
  • Approving it is refused with ErrDraftUnchecked, which has its own sentence. A CHECK constraint also blocks it.
  • GET /plugin/v1/drafts/checks lists the drafts waiting. POST /plugin/v1/drafts/{id}/check {sendable, detail} records the plugin's verdict:
    • sendable: the owner is asked now.
    • refused: the draft becomes rejected and can never be approved, and it is handed back as above.
  • The sendability rule stays in the plugin, not in Go.
  • Back-compat: a plugin minted without draft_checks behaves exactly as before.
  • On /plugins, a "Checks replies" checkbox mints the capability. An existing token cannot gain it; mint a new one.

Wording:

  • The routed preface now tells the session to write plain text, with no Markdown lists, headings or code blocks. This applies to every plugin, because muster has no per-plugin place to put a reply-format rule. It is a stopgap beside the pre-check.
  • plugin_draft_decide's approval note now says the reply is queued and not sent yet, and that a refusal comes back. The tool description now says "queues", not "sends".

docs/plugins.md gains "Check a reply before it can be approved" and "When the plugin refuses a reply". It also updates the capability, state and tool tables.

Tests

internal/api/plugin_draft_refusals_pg_test.go:

  • the production path (route → route_reply → MCP approve → claim → ok=false)
  • the redraft bound
  • a session whose window is gone
  • a chief-filed draft
  • the pre-check: held, refused, then sendable
  • a chief draft for a checks plugin
  • a messages-only plugin, unchanged

The route, capability, isolation and principal ledgers are updated for the two new routes.

Red at base, green at HEAD:

  • At base, the same assertions spelled with literals fail. The decide note says "sends exactly this text". No push is sent. There is 1 route where there should be 2. The chief gets no hand-back and no note. The updated preface test also fails.
  • At base the pre-check tests cannot be built: draft_checks does not exist and the capability CHECK refuses it.
  • A battery of 25 mutants at HEAD was run, each going red in the expected test with that guard's own error. These include: the bound off by one and removed; the unchecked-approval guard; the route half of fail and of refuse; the push conditions and the push body; the chief note; the list state; the capability gate; the ownership check on a verdict; and the repeated-result guard.

Local runs are change-scoped only; CI runs the rest.

🤖 Generated with Claude Code

ZacxDev and others added 4 commits November 11, 2026 01:03
… before approval

A reply draft its plugin refused (result ok=false) used to end silently: the
route stayed "relayed", nobody was told, and plugin_draft_decide had told the
session the text would be sent.

- A refused draft (a failed result, or a refused pre-approval check) ends its
  session route send_failed in the same transaction (new route state, 0111),
  owing a hand-back. The route sweep hands the message back to the session that
  wrote the reply as a NEW route whose paste carries the plugin's reason and the
  refused text, at most plugins.MaxRouteRedrafts (2) times per message; after
  that, or when the session is gone, it goes to the chief as a timed-out route
  does. The owner is pushed on the draft's tag with the reason; a chief-filed
  draft is noted to the chief. plugin_messages_list reports send_failed.
- New capability draft_checks (minted, like every capability): such a
  plugin's pending drafts await its verdict before they can be approved.
  GET /plugin/v1/drafts/checks lists them; POST /plugin/v1/drafts/{id}/check
  records sendable (the owner is asked now) or refused (rejected, never
  approvable, handed back). A plugin without it behaves as before.
- The routed preface tells the session to write plain text.
- plugin_draft_decide's approval note says the reply is queued, not sent, and
  that a refusal comes back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…gin's verdict

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ZacxDev
ZacxDev marked this pull request as ready for review November 11, 2026 07:18
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