paperclipai/paperclip · error · NativeInteractionBridgeError

native_interaction_self_approval

native_interaction_self_approval

Error message

native_interaction_self_approval

What it means

`materializeNativeInteractionResponses` rejects any attempt by an agent to resolve a governed `request_confirmation` tool review itself. If the interaction is accepted/rejected and it was resolved by an agent (`resolvedByAgentId` set) or by the same run submitting the response (`resolvedByRunId === input.runId`), the bridge throws `NativeInteractionBridgeError` with code `native_interaction_self_approval`. This enforces the approval-gate invariant: governed tool actions require a human decision, never agent self-approval.

Source

Thrown at server/src/services/native-runtime/native-interaction-bridge.ts:185

      eq(issues.id, input.issueId),
      eq(issues.companyId, input.companyId),
    )).limit(1).then((rows) => rows[0] ?? null),
  ]);
  if (!issue) throw new NativeInteractionBridgeError("native_interaction_binding_mismatch", "Issue binding not found");
  const responses: NativeInteractionResponseEnvelope[] = [];

  for (const interaction of interactions) {
    if (!requestedIds.has(interaction.id)) continue;
    if (interaction.companyId !== input.companyId || interaction.issueId !== input.issueId) {
      throw new NativeInteractionBridgeError(
        "native_interaction_binding_mismatch",
        `Interaction ${interaction.id} is not bound to the native company and issue`,
      );
    }
    if (interaction.kind === "request_confirmation" && interaction.payload.toolAction) {
      const action = interaction.payload.toolAction;
      if (["accepted", "rejected"].includes(interaction.status) && (interaction.resolvedByAgentId || interaction.resolvedByRunId === input.runId)) {
        throw new NativeInteractionBridgeError("native_interaction_self_approval", "Agents cannot resolve governed tool reviews");
      }
      const [request] = await input.db.select().from(toolActionRequests).where(and(eq(toolActionRequests.id, action.actionRequestId), eq(toolActionRequests.companyId, input.companyId), eq(toolActionRequests.issueId, input.issueId), eq(toolActionRequests.interactionId, interaction.id), eq(toolActionRequests.invocationId, action.invocationId)));
      const [invocation] = await input.db.select().from(toolInvocations).where(and(eq(toolInvocations.id, action.invocationId), eq(toolInvocations.companyId, input.companyId), eq(toolInvocations.issueId, input.issueId), eq(toolInvocations.agentId, input.agentId)));
      if (!request || !invocation || request.requestedByAgentId !== input.agentId || request.canonicalArgumentsHash !== action.argumentsHash) {
        throw new NativeInteractionBridgeError("native_interaction_governed_request_unresolved", "Tool review has no matching authoritative invocation");
      }
      if (["expired", "cancelled"].includes(request.status)) {
        if (interaction.status !== request.status && !(interaction.status === "accepted" && interaction.result?.toolAction?.status === "expired")) throw new NativeInteractionBridgeError("native_interaction_governed_result_mismatch", "Tool review lifecycle does not match its request");
        responses.push({ interactionId: interaction.id, kind: interaction.kind, response: { status: interaction.status, result: structuredClone(interaction.result), executionStatus: request.status } });
        continue;
      }
      if (!request.decidedByUserId || request.decidedByUserId !== interaction.resolvedByUserId || !["executed", "failed", "rejected"].includes(request.status) || (request.status === "rejected" ? interaction.status !== "rejected" : interaction.status !== "accepted")) {
        throw new NativeInteractionBridgeError("native_interaction_governed_request_unresolved", "Tool review must have a human decision and an authoritative terminal execution outcome");
      }
      const expectedInvocationStatus = request.status === "executed" ? "succeeded" : request.status === "rejected" ? "denied" : "failed";
      if (invocation.status !== expectedInvocationStatus || (request.status !== "rejected" && interaction.result?.toolAction?.status !== request.status)) throw new NativeInteractionBridgeError("native_interaction_governed_result_mismatch", "Tool review outcome does not match its invocation");
    }
    const interactionResult = record(interaction.result);

View on GitHub (pinned to 01ad858492)

Solutions

  1. Do not resolve governed tool reviews from agent code: surface the request_confirmation interaction to a human operator and let them accept/reject it.
  2. Check `interaction.resolvedByAgentId` / `resolvedByRunId` before submitting; if already resolved by an agent, treat the interaction as invalid rather than attempting to finalize it.
  3. If a human already decided, ensure the human's userId is recorded as `resolvedByUserId` (not the agent/run) before materializing the response.
  4. If the run is re-submitting after a retry, make the submission idempotent: skip interactions whose status is already terminal instead of re-materializing them.

Example fix

// before: agent tries to accept its own governed tool review
await materializeNativeInteractionResponses(input); // throws native_interaction_self_approval

// after: only human-resolved interactions are materialized; agent-side ones are skipped
const responses = input.responses.filter(
  (r) => !(r.status === "accepted" || r.status === "rejected") || r.resolvedByUserId,
);
await materializeNativeInteractionResponses({ ...input, responses });
Defensive patterns

Strategy: validation

Validate before calling

function canMaterialize(interaction) {
  if (interaction.kind !== "request_confirmation" || !interaction.payload.toolAction) return true;
  const terminal = ["accepted", "rejected"].includes(interaction.status);
  const selfResolved = Boolean(interaction.resolvedByAgentId) || interaction.resolvedByRunId === input.runId;
  return !(terminal && selfResolved);
}

Type guard

function isHumanResolved(i: { resolvedByAgentId?: string | null; resolvedByUserId?: string | null }): boolean {
  return i.resolvedByUserId != null && i.resolvedByAgentId == null;
}

Try / catch

try {
  await materializeNativeInteractionResponses(input);
} catch (err) {
  if (err instanceof NativeInteractionBridgeError && err.code === "native_interaction_self_approval") {
    // route the review to a human approver; never retry from the agent
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling `materializeNativeInteractionResponses` with a response payload for a `request_confirmation` interaction whose `payload.toolAction` is set, where `interaction.status` is "accepted" or "rejected" and either `resolvedByAgentId` is non-null or `resolvedByRunId` equals the submitting run's id.

Common situations: An agent adapter (or a buggy automation acting as the run) tries to mark its own tool-approval request as accepted instead of waiting for a human; a replayed/duplicated response from the same run after an agent-side resolution was recorded; a tool written against non-native interactions assumes agents may self-confirm governed actions.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/e465980585e3681c. Report an issue: GitHub.