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
-32001HITL 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 thememory_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_approval → InterruptRequiredEvent → user confirms → resume → execute) is not wired for platform MCP tools. Verified against origin/main:
- 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 areupsquad__{tool}andupsquadis an allowed prefix, so the in-step interrupt (_governance_decide_tool→decision.suspend) never fires for them. - The gateway
-32001"approval-required" envelope collapses to a plain tool error inmcp/client.py::_dispatch_call(~L405-413) — a pre-existing dead-end for all MCP-tool HITL, not just platform. - The platform/agent token carries no
session_id(internal/auth/oauth/agent_token.gopayload), so a session-keyed approval dedup/resume cannot key on/mcp/platform. GovernanceService.Checkre-gates on resume —engine.go:334/377creates a new approval on everyRequiresApprovaland never consults a resolved one; there is notool_callActionTypeHandler, so a resumed re-call re-gates → confirm loop.- Cross-service split —
WorkflowServicelives in context-engine;approval.Service/RecordDecision+ theActionTypeHandlerexecute-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 addingsession_idto 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-registeredActionTypeHandlerresolves on approve). The latter is exactly thetrigger_workflowtopology, so finding 5 is a solved pattern, not a new one.
D2 — The security invariant (binds every part of the implementation).
- A write tool handler NEVER mutates. Its only effects are a clearance check and creating one resource-scoped confirm approval (a
governance_approvalsrow) carrying the mutation payload + a writeActionType+ a human summary, then returningconfirmation_required{approval_id, summary}. - The mutation is performed exclusively by an orchestrator-registered
ActionTypeHandler.Approved, invoked by the existing user approve →RecordDecisionpath, only when the approval resolves approved. Denied → no-op. - 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.
- 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) withAction.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/platformroute already surfaces a governance deny as anisErrortool 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) intomiddleware.Identity.Clearancefor 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.TriggerWorkflowvia a new orchestrator→context-engine gRPC client (the one net-new wiring piece), stamped with the Quad agent's org +agent_idfrom 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-processapproval.Service.RecordDecisionon 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_idin 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_tokentwo-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
InterruptRequiredEventand it fixes the team-tool-32001dead-end, but its blast radius (worker interrupt/resume rebuild +-32001recognition +session_idtoken schema + resume re-gate break) is disproportionate for the platform write path, which B satisfies without any of it. Deferred to #2146 (the-32001fix 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 deterministicPlatformGovEngineverdict (D3).