8 min read

How GraphQL mutations for AI agents resist destructive calls

GraphQL mutations for AI agents need typed inputs, narrow scopes, real previews, and conditional writes to prevent destructive generated requests.

How GraphQL mutations for AI agents resist destructive calls

Generated GraphQL requests fail in a predictable way: the model has enough information to produce valid syntax, but not enough resistance to make a dangerous action difficult. A mutation named updateProject with an optional archived flag looks flexible to a human. To an agent assembling a request from partial context, it is an invitation to change project state as a side effect of a routine edit.

Put the resistance in the schema and resolver, not in a prompt that asks the agent to be careful. Typed inputs, narrow action authority, meaningful previews, conditional writes, and errors with a recovery path make correct requests easier to generate than damaging ones. That design also helps ordinary client developers. Agents simply expose the shortcuts that loose APIs have tolerated for years.

A valid request can still express the wrong action

GraphQL checks whether a request matches the schema. It does not establish that the caller selected the right customer, understood the record state, or meant to remove anything. Teams often confuse type safety with action safety, then place a field named delete, archive, or status inside a broad update mutation.

Consider this common schema:

input ProjectPatchInput {
  name: String
  description: String
  archived: Boolean
  ownerId: ID
}

type Mutation {
  updateProject(id: ID!, input: ProjectPatchInput!): Project!
}

This mixes harmless edits with ownership transfer and a lifecycle transition. An agent asked to "clean up old projects" can reasonably infer that setting archived: true is appropriate. An agent asked to fix a project name can accidentally retain an archived field from an earlier generated object. The type system accepts both requests because both requests are well formed.

Do not hide destructive behavior inside a flexible patch. Give each action a name that states its consequence and an input that carries only the evidence needed for that consequence:

type Mutation {
  renameProject(input: RenameProjectInput!): RenameProjectPayload!
  archiveProject(input: ArchiveProjectInput!): ArchiveProjectPayload!
  transferProjectOwnership(input: TransferProjectOwnershipInput!): TransferProjectOwnershipPayload!
}

input RenameProjectInput {
  projectId: ID!
  expectedVersion: Int!
  name: String!
}

input ArchiveProjectInput {
  projectId: ID!
  expectedVersion: Int!
  reason: ArchiveReason!
  confirmation: String!
  idempotencyKey: String!
}

This is not ceremony for its own sake. A narrow mutation limits what a generated request can say. It creates a useful difference between "edit a label" and "remove this from normal use." Tool descriptions can explain that difference, but the schema should enforce it.

The GraphQL specification helps here in a limited but useful way. Its input object validation rejects input fields that the schema does not define. If ArchiveProjectInput does not include ownerId, a client cannot smuggle an ownership change into an archive call. Treat that property as a guardrail, not a security boundary. The resolver still decides whether the actor may archive this specific project.

Avoid a generic action: String! field such as mutateProject(action: "ARCHIVE"). It looks compact until every action needs different fields, validation, authorization, preview data, and error handling. The result becomes a private RPC protocol trapped inside an input object, with less help from GraphQL tooling.

Inputs must name the target and the boundary

A destructive input should identify exactly what will change, which version the caller examined, and which limit prevents an expanding selection. IDs alone do not carry enough intent when a resolver can fan out to child records, external systems, or a tenant-wide query.

Start with an object that identifies a single resource in the caller's tenant. Do not accept an arbitrary filter in a deletion mutation unless the product genuinely needs bulk work. A filter such as where: { status: INACTIVE } invites ambiguity: inactive according to which timestamp, which tenant, and which hidden default? A model may supply it because the field exists, not because it has reviewed the resulting set.

For one-record operations, carry a version token in the input. An integer is easy to inspect, but an opaque revision string also works. The resolver compares it with the stored version in the same transaction that writes the change. If they differ, it returns a conflict and changes nothing.

input ArchiveProjectInput {
  projectId: ID!
  expectedVersion: Int!
  reason: ArchiveReason!
  confirmation: String!
  idempotencyKey: String!
}

enum ArchiveReason {
  CUSTOMER_REQUEST
  DUPLICATE
  END_OF_LIFE
}

The reason enum does more than improve reporting. It prevents a request from inventing a free-form justification that later automation treats as meaningful. Use free text for a note when people need it, but keep operational categories enumerable.

A confirmation field should bind to the actual target. Requiring the literal string ARCHIVE catches only careless construction. Requiring archive acme-project-42 forces the client to resolve and repeat a resource identifier. It does not stop a malicious client, and it should never replace authorization. It does catch a large class of generated requests that attached the right action to the wrong ID.

Do not ask for confirmation on routine mutations such as changing a display name. Excessive confirmation trains agents and people to fill every field mechanically. Reserve it for actions with a material or hard-to-reverse result: deletion, publication, financial movement, credential revocation, and changes that affect other users.

For a bulk operation, make the upper bound explicit and return a preview token tied to the exact selection. This input says far more than a raw filter:

input DeleteDormantProjectsInput {
  previewToken: ID!
  expectedCount: Int!
  confirmation: String!
  idempotencyKey: String!
}

The execution resolver must reject a token that expired, belongs to another actor, describes a different tenant, or yields a count different from expectedCount. Otherwise an agent can preview five records and execute a moving query that now matches fifty.

Authority should follow the mutation, not the noun

A scope called projects:write is usually too broad for autonomous work. It lets a caller rename, archive, transfer, delete, and perhaps alter billing-related project settings under one permission because all actions touch a project. That grouping follows the database noun, not the risk of the operation.

Issue authority that describes an action. For example, a service token for release automation might carry project:rename and project:archive, while a support workflow carries neither. A separate project:delete scope should remain uncommon. If your identity system cannot issue scopes that narrow, add a server-side capability check associated with the mutation name and record it in the authorization decision.

Scope alone never settles access. Each resolver needs several checks in a deliberate order:

  1. Authenticate the caller and identify its tenant and principal.
  2. Check that the principal has authority for this mutation.
  3. Load the target inside the tenant boundary, rather than loading globally and checking later.
  4. Check the record state and any role relationship required by the business rule.
  5. Perform the conditional write and append an audit event in the same transaction.

Loading inside the tenant boundary matters. A resolver that calls findProjectById(id) before it verifies tenancy may leak existence through timing or error wording. It may also hand a globally loaded object to a helper that assumes authorization already happened. Make tenant membership part of the lookup predicate.

Do not infer permission from the agent's claimed task. A request header that says X-Agent-Goal: cleanup is evidence for an audit trail, not a grant of authority. Prompts, task labels, and model identity can help a human review an action, but any client can forge them.

The same distinction applies to tool access. An agent may have permission to call a GraphQL endpoint while lacking permission for a particular mutation. Describe read tools and action tools separately where your agent runtime permits it. Keep the final decision in the API, because a client can bypass tool metadata and send the HTTP request itself.

A dry run must build the same plan as execution

A dry run is useful only if it answers, "What would this exact request do right now?" A fake preview that counts rows with a simplified query gives agents a false sense of safety. The eventual mutation may apply different eligibility rules, use a different authorization branch, or trigger an external action that the preview never considered.

Build a shared planning function. It receives the authenticated actor and the input, validates every condition, resolves targets, calculates side effects, and produces an immutable plan. The preview returns a sanitized representation of that plan. The execution path consumes the plan only after the caller presents its short-lived token and confirmation.

type Mutation {
  previewArchiveProject(input: PreviewArchiveProjectInput!): ArchivePreviewPayload!
  archiveProject(input: ArchiveProjectInput!): ArchiveProjectPayload!
}

input PreviewArchiveProjectInput {
  projectId: ID!
  expectedVersion: Int!
  reason: ArchiveReason!
}

type ArchivePreviewPayload {
  previewToken: ID!
  project: Project!
  affectedMemberCount: Int!
  plannedEffects: [ArchiveEffect!]!
  expiresAt: DateTime!
}

enum ArchiveEffect {
  PROJECT_HIDDEN_FROM_DEFAULT_LISTS
  PENDING_INVITATIONS_CANCELLED
}

A plan should include the target IDs, their versions, the actor identity, tenant, input digest, planned effects, and expiry time. Store it server-side or sign an opaque token that references stored state. Do not put the full plan in a client-controlled JSON blob and trust it on execution.

The execution input should refer to the preview token, not repeat a loose selector:

input ArchiveProjectInput {
  previewToken: ID!
  confirmation: String!
  idempotencyKey: String!
}

This two-call flow adds friction. That is the point for consequential actions. Do not impose it on every mutation. A good rule is simple: require a preview when an operation affects more than one record, has an irreversible external effect, or makes a resource unavailable to other users.

Preview responses also need access control. Returning a list of affected records can leak data just as surely as executing the mutation. Apply the same tenant and role rules during planning. A preview may omit fields that the actor cannot read while still returning the count and effect categories needed to decide.

Idempotency and versions solve different failures

Stop handing tokens to models
Sallyport injects bearer, basic, or custom-header credentials without exposing them to the model.

Idempotency prevents duplicate application of the same request. A version check prevents application to state that changed after the caller inspected it. Teams frequently add one and assume they have solved both problems.

An agent can retry because an HTTP connection closed after the server committed a mutation. Without idempotency, the second request may create a second refund, duplicate a message, or invoke the same external API twice. Give every effectful mutation an idempotencyKey supplied by the caller. The server should store it with the authenticated actor, mutation name, a digest of normalized input, and the completed payload or stable error.

When the server sees the same actor, mutation, key, and input digest again, it returns the original result. When it sees the same key with a different digest, it returns IDEMPOTENCY_KEY_REUSED and performs nothing. Accepting changed input under a reused key destroys the property clients rely on during retries.

A version check handles a different sequence. An agent reads project version 7, prepares an archive preview, and a human renames the project or restores an invitation. When the archive executes, the resolver compares version 7 with the current stored version. If the value is now 8, it returns a conflict. The agent must fetch current state, reconsider its intent, and produce a new preview if needed.

The GraphQL specification executes top-level fields in one mutation operation serially. That ordering does not serialize separate HTTP requests. Two agents can still submit two mutation operations at nearly the same time. Use a database conditional update, row lock, or transactional constraint. A check in application memory followed by a separate write leaves a race window.

A SQL-shaped conditional update makes the intended invariant plain:

UPDATE projects
SET archived_at = CURRENT_TIMESTAMP,
    version = version + 1
WHERE id = :project_id
  AND tenant_id = :tenant_id
  AND version = :expected_version
  AND archived_at IS NULL;

If this affects zero rows, inspect the current record under the tenant boundary and return a specific outcome: missing, forbidden, already archived, or version conflict. Do not report every zero-row result as a generic server error. Agents need to know whether retrying is harmful, useful, or pointless.

Error payloads should tell an agent what to do next

GraphQL's top-level errors array is appropriate for parse errors, validation failures, and resolver failures. It is a poor place to force clients to scrape English messages for business outcomes. Put expected mutation outcomes in a typed payload with a stable code and structured details.

type ArchiveProjectPayload {
  outcome: ArchiveProjectOutcome!
  project: Project
  error: MutationError
}

enum ArchiveProjectOutcome {
  ARCHIVED
  VERSION_CONFLICT
  CONFIRMATION_REQUIRED
  PREVIEW_EXPIRED
  FORBIDDEN
  IDEMPOTENCY_KEY_REUSED
}

type MutationError {
  code: String!
  message: String!
  currentVersion: Int
  requiredConfirmation: String
}

Use transport and GraphQL execution errors for conditions where the client could not execute the operation correctly. Use a typed outcome for a request that executed normally but did not change state because the business rule rejected it. Pick one convention and document it. Mixing errors.extensions.code for some conflicts with payload enums for others makes agent behavior brittle.

An agent should be able to map an outcome to a safe action. VERSION_CONFLICT means reread the object and reconsider. PREVIEW_EXPIRED means create a fresh preview. CONFIRMATION_REQUIRED means show the required phrase to a human or ask for it, not guess. FORBIDDEN means stop. IDEMPOTENCY_KEY_REUSED means generate a new key only after the caller explicitly intends a different operation.

Do not return internal policy names, SQL fragments, or authorization graph details. Stable external codes can be precise without exposing implementation internals. Keep a correlation ID in the response extensions and a matching audit record on the server. That gives an operator something concrete to investigate when an agent reports failure.

Success payloads need enough information to end uncertainty. Return the resulting record state, new version, operation ID, and the effects that actually occurred. A bare Boolean forces a client to make another query and leaves room for a stale read. It also makes human review much harder than it needs to be.

Deletion needs a lifecycle, not a Boolean switch

Keep GraphQL secrets outside agents
Sallyport keeps API credentials in its encrypted vault and executes the HTTP call outside the agent.

Hard deletion is popular because it makes a table look tidy. It is also the action most likely to produce an unrecoverable support problem when an agent misunderstands a request. Many products should archive first, preserve an undo period under server control, and permanently purge through a separate restricted workflow.

Do not call an archive operation deleteProject if it merely hides a record. Names teach clients what state they can expect. archiveProject should return ARCHIVED; purgeProject should mean that the data will no longer be available. When an API uses delete for every lifecycle stage, an agent cannot reliably distinguish a reversible cleanup from a permanent removal.

A permanent purge needs stricter input and authorization than an archive. It may require that the resource has already remained archived for a retention period, that no legal or billing hold exists, and that an operator with a distinct scope approves it. The resolver must enforce each condition. A client-side countdown or tool instruction has no authority.

External side effects deserve the same treatment. If archiving cancels invitations, removes a remote environment, or triggers a webhook, return those effects in the preview and final payload. Do not quietly attach them to a generic update resolver. The person reviewing an agent request needs to see the consequences before approval, and the agent needs facts it can report after execution.

For financial or credential actions, do not offer a pretend dry run that calls the provider's live endpoint and hopes for no effect. Use the provider's documented preview or authorization mechanism when it exists. Otherwise label the result as a local estimate and list what the server could not verify. Pretending certainty is worse than requiring a human decision.

Resolver checks make schema promises real

Schema design limits malformed intent. Resolver design stops an authorized-looking request from crossing a real boundary. Keep those layers separate in code so a future refactor cannot replace a permission check with a comment in a tool definition.

A resolver for a destructive action should follow a sequence that makes denial cheap and writes late. First authenticate the request, then resolve the actor's tenant, validate the input, load the target within that tenant, verify scope and state, validate the preview token and confirmation, claim the idempotency record, and execute the conditional transaction. The order may change for your storage model, but do not perform an external effect before you know the transaction can commit.

An idempotency record needs careful handling when work spans a database and an external provider. Marking a key complete before the external call risks claiming success when the call failed. Calling the provider first risks duplication if the process dies before storing completion. Use an outbox pattern or provider-side idempotency support where available. Record a durable pending operation, commit the local decision, and dispatch the external effect with an operation ID that survives retries.

Audit records should identify the authenticated principal, agent run when applicable, mutation name, normalized target, input digest, authorization result, preview reference, idempotency key, outcome, and resulting version. Redact notes and fields that contain sensitive content according to your retention rules. An audit event that says only "mutation succeeded" is nearly useless during an incident.

For agents that act through credentialed HTTP or SSH calls, keep the credential outside the model process where you can. Sallyport routes supported actions through its local vault and records individual calls, which is useful when a GraphQL mutation needs human-visible authorization beyond what a bearer token alone can provide.

Test the generated request, not only the resolver

Keep SSH keys in the vault
Route SSH commands through Sallyport's stateless sp-ssh helper instead of giving keys to the agent.

Unit tests that call a resolver with carefully constructed objects miss the failure mode you care about. Generated clients send omitted fields, nulls, stale IDs, aliases, repeated requests, and variables assembled from earlier tool output. Test the public GraphQL boundary with the same shapes.

Build a mutation test matrix around behavior rather than around code branches. At minimum, cover a caller from another tenant, a caller with read access but no action scope, an expired preview, a changed target version, a wrong confirmation string, a replayed idempotency key, and two concurrent calls using the same expected version. Assert both the response and the durable state after each test.

This request should fail during GraphQL validation because the input does not define ownerId:

mutation BadArchive($input: ArchiveProjectInput!) {
  archiveProject(input: $input) {
    outcome
  }
}
{
  "input": {
    "projectId": "prj_42",
    "expectedVersion": 7,
    "reason": "DUPLICATE",
    "confirmation": "archive prj_42",
    "idempotencyKey": "run-18-archive-42",
    "ownerId": "usr_9"
  }
}

The expected response belongs in the top-level GraphQL error format because the document supplied an invalid input object. That test proves the schema keeps unrelated capability out of the mutation. A separate test must prove that a well-formed archive request still fails when the actor belongs to another tenant.

Run concurrency tests against the real transaction behavior, not an in-memory fake. Send two archive requests with the same ID and expected version, then assert that one returns ARCHIVED and the other returns a conflict or an idempotent replay outcome. If both calls report success with different operation IDs, your conditional write is not doing its job.

Test audit verification too. If your action gateway produces a tamper-evident encrypted audit trail, make verification part of incident drills rather than a command nobody has used. Sallyport exposes sp audit verify for offline verification of its hash chain without a vault key; run it against a copied journal and make sure operators know what a failed check means.

Generated tools need fewer choices, not longer warnings

Agents perform better when a tool schema presents the smallest safe action that matches the task. A giant mutation catalog with generic filters, flags, and optional side effects forces the model to infer policy from field names. A compact catalog of explicit read, preview, execute, and recovery operations gives it a path it can follow.

Expose read operations that return the identifiers, versions, status, and names an agent needs before it proposes a mutation. If the agent must invent an ID from a human label, the mutation design cannot save you. Return stable IDs clearly and make ambiguous search results explicit rather than picking one result silently.

Write tool descriptions that state a precondition and a consequence, but keep the server as the enforcement point. For example: "Archives one project after a successful preview. It cancels pending invitations listed in the preview." That is better than "Use with caution," which tells an agent nothing operational.

Do not solve every risk by adding a human approval dialog. Approval is appropriate when a person owns the decision, but repetitive prompts become background noise. Put routine protection in scope, tenant checks, versions, and idempotency. Ask a person to review the small set of actions where intent cannot be inferred from data, where the consequence is permanent, or where the action crosses an organizational boundary.

Start with the mutation that would hurt most if an agent called it twice, called it on stale state, or called it against the wrong tenant. Split its input, add a real preview where the action warrants one, make the write conditional, and write the replay test. That work will expose whether your API models an action clearly or merely exposes database fields.

FAQ

Does GraphQL validation make destructive mutations safe?

No. GraphQL validates request shape, field arguments, and scalar coercion, but it does not know whether an operation is sensible in the current business state. Your resolver must still enforce authorization, state transitions, tenant boundaries, and concurrency rules.

When should I create a separate GraphQL mutation input?

Use a mutation-specific input when an action changes state in a distinct way, especially for deletion, suspension, refunds, transfers, or publication. A generic patch input is acceptable for ordinary editable fields, but it should not carry lifecycle actions disguised as booleans.

What should a GraphQL dry run actually do?

A dry run should perform the same validation, authorization, target selection, and planning as the real mutation, then stop before the external write or database commit. If it takes a different code path, agents will learn a preview that does not match production behavior.

How do idempotency keys work in GraphQL mutations?

An idempotency key lets the server recognize a retry of the same intended action and return the original result instead of applying the action twice. Store the key with the actor, mutation name, normalized input digest, outcome, and expiry policy; reject reuse with different input.

Should GraphQL scopes map to resources or mutations?

A resource-wide scope such as write:projects usually grants too much authority to an agent that only needs to archive a project. Scope destructive capabilities to the mutation or action, then enforce ownership and tenant checks in the resolver as a separate decision.

How should an AI agent safely delete many records with GraphQL?

Treat bulk deletion as a separate operation with a target count, a bounded selection rule, explicit confirmation text, and a preview. Never turn a single-object delete field into an accidental loop just because an agent can construct a long list of IDs.

What errors should destructive GraphQL mutations return to an agent?

Return machine-readable codes, a stable human message, and structured details such as the current version or required confirmation phrase. Do not make an agent parse prose to decide whether it may retry, refresh state, or ask a person.

Can two GraphQL mutation requests race each other?

GraphQL serializes top-level fields within one mutation operation, but separate requests can still race. Put the expected version in the input and make the write conditional, then return a conflict when the stored version changed.

Can an agent prompt enforce GraphQL mutation safety?

No. The schema is a public interface and clients can call the API directly, bypassing any agent prompt or tool description. The server must verify the caller identity, scope, tenant relationship, state, and confirmation before it changes anything.

How can I keep agent credentials out of generated API requests?

Keep long-lived API and SSH credentials outside the agent process, approve the agent run when appropriate, and record each outbound action. Sallyport is one macOS option for routing supported HTTP and SSH actions through a local encrypted vault rather than handing secrets to the agent.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov