Skip to main content

ADR-0027 — Platform write-tool confirm & execute-on-approve (Quad mutating tools)

  • Status: Accepted (Principal Architect, 2026-07-25) — resolves the #2120 mechanism escalation. Extends ADR-0026 D5 ("writes get an explicit fail-closed gate; always trip requires_approval / in-thread confirm") by fixing the mechanism D5 left open. No founder product gate (the feature + the Quad-agent-clearance rule were green-lit; no stack / dependency / secret / external-egress change).
  • Date: 2026-07-25
  • Deciders: Principal Architect (author + decision), Backend SME (implementation feasibility / escalation)
  • Refs: #2120 (Quad mutating tools + this ruling), #2146 (deferred team-tool -32001 HITL fix), upsquad-client#655 (in-thread confirm card). Extends ADR-0026 D5. Reuses ADR-0024 (MCP tool governance), the subagent LLD 14 async-approval pattern, and the memory_review (AML LLD #2069 §5) cross-service execute-on-approve topology.

Context

ADR-0026 built the /mcp/platform dispatch surface and its read tools (#2119). D5 said mutating tools (#2120 — trigger_workflow, record_decision) must be fail-closed and "always trip requires_approval (in-thread confirm)", but did not specify the confirm→execute mechanism. Implementation of #2120 surfaced that the implied mechanism (worker requires_approvalInterruptRequiredEvent → user confirms → resume → execute) is not wired for platform MCP tools. Verified against origin/main:

  1. The worker skips its governance pre-check for MCP-namespaced tools (services/agent-worker/.../graph/nodes.py:808, governance_skip_mcp_edge, ADR-0024 D2). The platform tools are upsquad__{tool} and upsquad is an allowed prefix, so the in-step interrupt (_governance_decide_tooldecision.suspend) never fires for them.
  2. The gateway -32001 "approval-required" envelope collapses to a plain tool error in mcp/client.py::_dispatch_call (~L405-413) — a pre-existing dead-end for all MCP-tool HITL, not just platform.
  3. The platform/agent token carries no session_id (internal/auth/oauth/agent_token.go payload), so a session-keyed approval dedup/resume cannot key on /mcp/platform.
  4. GovernanceService.Check re-gates on resumeengine.go:334/377 creates a new approval on every RequiresApproval and never consults a resolved one; there is no tool_call ActionTypeHandler, so a resumed re-call re-gates → confirm loop.
  5. Cross-service splitWorkflowService lives in context-engine; approval.Service / RecordDecision + the ActionTypeHandler execute-on-approve seam live in agent-orchestrator; the orchestrator holds no WorkflowService client.

Three mechanisms were proposed: A (worker interrupt surface + session-scoped approval + resume marker — most literal, also fixes the team-tool dead-end), B (resource-scoped confirm approval + execute-on-approve ActionTypeHandler — the org_draft_publish #1585 / subagent LLD 14 / memory_review pattern), C (in-context-engine confirm_token two-call — ships today, LLM-driven confirm).

The founder constraint is non-negotiable: mutations always confirm in-thread; the user authorises, never the LLM; Quad-agent clearance (user−1, floor 1); fail-closed; no silent mutation.

Decision

D1 — Mechanism B. Reject C. Defer A.

  • C is rejected on security grounds: the LLM holds the confirm_token, so a prompt-injection or a compliant-but-wrong model can issue both calls and self-confirm without the user — a direct violation of "no silent mutation / the user authorises." (A one-time, args-bound token may appear inside B as defence-in-depth binding the approved mutation to exact args, but it is never the authorisation.)
  • A is deferred to #2146 (LLD-first): it is the most literal InterruptRequiredEvent, but it requires teaching the worker to recognise -32001, rebuilding the worker interrupt/resume lifecycle for MCP tools, breaking the resume re-gate, and adding session_id to the token — a large multi-language blast radius that fixes a pre-existing global MCP-HITL gap platform writes do not need.
  • B is adopted: it reaches the same security property (the user authorises a durable approval; the mutation executes server-side; fail-closed; no re-gate) with far less surface and two proven in-tree precedents — subagent LLD 14 (async approval + execute-on-approve) and memory_review (a context-engine resource whose approval an orchestrator-registered ActionTypeHandler resolves on approve). The latter is exactly the trigger_workflow topology, so finding 5 is a solved pattern, not a new one.

D2 — The security invariant (binds every part of the implementation).

  1. A write tool handler NEVER mutates. Its only effects are a clearance check and creating one resource-scoped confirm approval (a governance_approvals row) carrying the mutation payload + a write ActionType + a human summary, then returning confirmation_required{approval_id, summary}.
  2. The mutation is performed exclusively by an orchestrator-registered ActionTypeHandler.Approved, invoked by the existing user approve → RecordDecision path, only when the approval resolves approved. Denied → no-op.
  3. No path from the LLM/worker can cause the mutation without a user-resolved approved approval; the LLM can only trigger step 1 and cannot self-approve.
  4. Above the Quad agent's clearance floor → friendly refusal + deep link; no approval created, no mutation.

D3 — Fail-closed gate is deterministic, not policy-dependent (PlatformGovEngine, extends the ADR-0026 ReadAllowEngine).

  • read Resource (workflow.read) → Allow (unchanged).
  • write Resource (workflow.trigger, approval.decide) with Action.Clearance ≥ floor(action)RequiresApproval (async mode) — the deterministic confirm gate; it does not depend on a seeded per-org policy (which would default-deny and block the tool).
  • write Resource with Action.Clearance < floor(action)Deny, reason = the friendly refusal + deep link (the /mcp/platform route already surfaces a governance deny as an isError tool result the LLM relays). Enforces "Quad-agent clearance (user−1); above-floor refuses" at the gate with no orphaned approval.
  • else → inner.Check (fail-closed default-deny). The route injects the Quad agent's inferred clearance (InferAgentClearance, parent−1, floor 1) into middleware.Identity.Clearance for write tools (reads keep 0 — UX-only projection).

D4 — Execute-on-approve is server-side, stamped as the Quad agent.

  • quad_workflow_trigger.Approved → WorkflowService.TriggerWorkflow via a new orchestrator→context-engine gRPC client (the one net-new wiring piece), stamped with the Quad agent's org + agent_id from the approval metadata at Quad clearance — not the approving user (preserves the #2120 founder ruling: Quad-mediated mutations act at the Quad agent's clearance, not viewer-stamped).
  • quad_record_decision.Approved → in-process approval.Service.RecordDecision on the target workflow approval (the confirm approval and the workflow gate are two distinct approvals).
  • Both .Denied → no-op + a "cancelled" surface; idempotent (a handler may fire more than once).

D5 — In-thread confirm surface. Under B the surface is a confirmation_required tool result (not an interrupt_required stream arm). The client renders it as an in-thread confirm card (summary + Confirm/Cancel) that resolves approval_id via the existing approve/reject API — tracked in upsquad-client#655 (the existing quad-approval-banner is subagent-bubble-scoped). Backend ships a graceful deep-link fallback (the LLM relays the summary + an approvals-surface link) so the mutation path never dead-ends if the card lands later; #2120's "in-thread confirm" acceptance is fully met once the card is live.

Consequences

  • Positive: genuine user-authorised confirm with server-side execution, fail-closed by construction (the handler cannot mutate); reuses two proven patterns and adds only one net-new gRPC client; no re-gate loop; needs neither session_id in the token nor changes to the worker interrupt lifecycle; extensible to future platform write tools (register a new write Resource + ActionTypeHandler).
  • Negative / cost: two approvals exist for record_decision (the confirm approval + the target workflow gate) — deliberate and mirrors the two-step nature of the action; a new orchestrator→context-engine WorkflowService client; a client companion (upsquad-client#655) is required for the richest UX (deep-link fallback bridges the gap).
  • Security: internal surfaces only; tenant isolation continues on the ADR-0026 org-scoped path (cross-tenant + IDOR probes required through the tool path). The no-direct-mutation test (govengine gate removed ⇒ still zero mutation) is the fail-closed proof.

Alternatives rejected

  • C — LLM-held confirm_token two-call: the LLM can self-confirm (prompt-injection double-call), violating "no silent mutation / the user authorises." Rejected as the authorisation (may survive only as defence-in-depth arg-binding).
  • A — worker interrupt surface + session-scoped approval: most literal InterruptRequiredEvent and it fixes the team-tool -32001 dead-end, but its blast radius (worker interrupt/resume rebuild + -32001 recognition + session_id token schema + resume re-gate break) is disproportionate for the platform write path, which B satisfies without any of it. Deferred to #2146 (the -32001 fix is a pre-existing global MCP-HITL gap worth doing on its own, LLD-first).
  • Policy-dependent requires_approval (rely on a seeded governance policy to gate writes): the real engine default-denies on no-match, so an unseeded org would block the tool entirely rather than confirm it. Rejected for a deterministic PlatformGovEngine verdict (D3).