# 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:

```graphql
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:

```graphql
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.

```graphql
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:

```graphql
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.

```graphql
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:

```graphql
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

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:

```sql
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.

```graphql
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

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

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`:

```graphql
mutation BadArchive($input: ArchiveProjectInput!) {
  archiveProject(input: $input) {
    outcome
  }
}
```

```json
{
  "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.
