---
title: "When approval expires or changes"
description: "What happens to an approved governed action when the preview expires, the request changes, the action is edited, the approval is denied, or the provider's state moves on."
---

An approval of a governed action is an approval of one [preview](/execution/previews). The preview has an expiry and a `bindingDigest`. This page lists each way that approval can stop being valid, where the refusal happens, and what the caller sees.

There are three places a call can stop:

| Where | What the caller sees | Effect created |
| --- | --- | --- |
| **Admission**, when the invoke request arrives | HTTP 409 with an error code | No |
| **Dispatch**, after approval and immediately before the write | HTTP 200, `status: "failed"`, `grade: "refused"` | Yes, `stage: "rejected"`, zero attempts |
| **The provider**, after Anlyon sent the write | HTTP 200, `status: "failed"`, `grade: "failed"` | Yes, one attempt |

The responses below come from Anlyon's CI test runs.

## Preview expiry and approval expiry

A preview you create with `actions.preview` lasts one hour by default. Pass `ttlSeconds` to change that, from 60 seconds to 7 days.

An invocation with no `previewId` builds its own preview. That preview lasts as long as the approval window, which is 24 hours by default, so it does not shorten the time a reviewer has.

**The approval never outlives the preview.** When an approval is created for a governed effect, its expiry is the earlier of the approval window and the preview's expiry. A reviewer cannot approve after the reviewed evidence has expired.

When an approval expires undecided:

- the approval's status is `expired`, and a later attempt to decide it returns HTTP 409 `APPROVAL_ALREADY_DECIDED`
- the invocation's status is `expired`
- the effect is rejected with `rejectionReason: "approval_expired"`, which grades `refused`
- nothing was sent

Expiry is inside the digest. A preview whose stored expiry was altered no longer verifies, and dispatch refuses it.

To continue after an expiry, create a new preview and a new invocation. **Refreshing** a preview re-reads the provider into a new preview that supersedes the old one. Its binding differs, so an approval of the old preview never carries over.

```typescript TypeScript
const { data: fresh } = await agent.actions.refreshPreview('prv_01JABCDEF', { ttlSeconds: 600 });
console.log(fresh!.id, fresh!.supersedes, fresh!.bindingDigest);
```

```python Python
fresh = agent.actions.refresh_preview("prv_01JABCDEF", ttl_seconds=600).data
print(fresh["id"], fresh["supersedes"], fresh["bindingDigest"])
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/actions/previews/prv_01JABCDEF/refresh \
  -H "Authorization: Bearer $ANLYON_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ttlSeconds": 600 }'
```

## A changed request: `PREVIEW_MISMATCH`

An invocation that carries a reviewed `previewId` must match the reviewed request. If the destination, resource, amount or content differs, the invoke is refused at admission.

```typescript TypeScript
import { AnlyonError } from '@anlyonhq/sdk';

const { data: preview } = await agent.actions.preview('send-email', {
  emailId: 'welcome-1', to: ['a@example.com'], subject: 'Welcome',
});

try {
  // One more recipient than the reviewer saw.
  await agent.actions.invoke(
    'send-email',
    { emailId: 'welcome-1', to: ['a@example.com', 'b@example.com'], subject: 'Welcome' },
    { previewId: preview!.id },
  );
} catch (error) {
  if (error instanceof AnlyonError && error.status === 409) console.log(error.message);
}
```

```python Python
from anlyon import AnlyonError

preview = agent.actions.preview(
    "send-email", {"emailId": "welcome-1", "to": ["a@example.com"], "subject": "Welcome"}
).data

try:
    # One more recipient than the reviewer saw.
    agent.actions.invoke(
        "send-email",
        {"emailId": "welcome-1", "to": ["a@example.com", "b@example.com"], "subject": "Welcome"},
        preview_id=preview["id"],
    )
except AnlyonError as error:
    if error.status == 409:
        print(error)
```

```bash curl
curl -s -w '\n%{http_code}\n' -X POST https://api.anlyon.com/api/v2/actions/send-email/invoke \
  -H "Authorization: Bearer $ANLYON_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": { "emailId": "welcome-1", "to": ["a@example.com", "b@example.com"], "subject": "Welcome" },
    "previewId": "prv_01JABCDEF"
  }'
```

The response is HTTP **409**.

```json
{
  "success": false,
  "error": {
    "code": "PREVIEW_MISMATCH",
    "message": "Preview prv_01JABCDEF does not match this invocation: the destination, resource, amount or content differs."
  }
}
```

The body also carries a `requestId`. No effect is created, so there is no grade and no `effectId`. Nothing is sent to the provider.

`PREVIEW_MISMATCH` covers more than the request. The message after the colon says which part differs:

| What differs | Message ends with |
| --- | --- |
| The request | `the destination, resource, amount or content differs.` |
| The action | `it previews another action.` |
| The action version | `it reviewed <version>; this invocation runs <version>.` |
| The adapter version | `the adapter has changed since review.` |
| The stored binding | `its binding does not verify.` |

Two more codes come from the same check, both HTTP 409 at admission:

| Code | Message | Meaning |
| --- | --- | --- |
| `PREVIEW_EXPIRED` | `Preview <id> expired at <time>. Create a new preview.` | The reviewed preview is past its expiry. |
| `PREVIEW_ALREADY_USED` | `Preview <id> is already bound to <effect id>.` | A preview binds one effect. A second invocation with the same `previewId` is refused. |


## A changed action: `action_changed`

Every edit to an action's request, approval requirement, adapter or declarations is a new immutable version. An effect is bound to the version that was reviewed.

If the action is edited while an invocation waits for its approval, approving it dispatches nothing. The approval call itself succeeds, and the invocation inside it reports the refusal.

```typescript TypeScript
const { data: decided } = await operator.approvals.approve('apr_01JABCDEF', { note: 'Checked the order.' });
console.log(decided!.invocation?.status);   // failed

const { data: effect } = await operator.actions.effect('eff_01JABCDEF');
console.log(effect!.rejectionReason, effect!.grade);
```

```python Python
decided = operator.approvals.approve("apr_01JABCDEF", note="Checked the order.").data
print(decided["invocation"]["status"])   # failed

effect = operator.actions.effect("eff_01JABCDEF").data
print(effect["rejectionReason"], effect["grade"])
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/approvals/apr_01JABCDEF/approve \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Checked the order." }'

curl -s https://api.anlyon.com/api/v2/actions/effects/eff_01JABCDEF \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"
```

```json
{ "rejectionReason": "action_changed" }
```

Here `decided.invocation.status` is `failed`, and in a test run the destination saw no write. The evidence says which version was reviewed and which version the action resolves to now.

The same edit meets a reviewed `previewId` earlier. An invoke with a preview of the old version is refused at admission with `PREVIEW_MISMATCH`.

## Refusals at dispatch

Immediately before the write, in one transaction, Anlyon re-checks the binding and its expiry, the approval, the action version, the halt state, the requesting credential, current policy, dependencies and the limits. Any refusal rejects the effect. Each one has `stage: "rejected"`, `outcome: "not_dispatched"`, zero attempts and grade `refused`.

| `rejectionReason` | Cause |
| --- | --- |
| `binding_mismatch` | The stored preview, effect or approval no longer agree with the reviewed digest. |
| `binding_missing` | The effect has no reviewed preview. |
| `preview_expired` | The reviewed preview expired before dispatch. |
| `approval_expired` | The approval expired undecided. |
| `approval_missing` | The approval is not approved. |
| `approval_required` | Policy now requires approval and the effect was admitted without one. |
| `action_changed` | The action now resolves to another version. |
| `action_disabled` | The action is disabled or archived. |
| `adapter_changed` | The adapter version that would run differs from the one reviewed. |
| `environment_halted` | The environment is halted. |
| `authority_revoked` | The key that requested the effect is no longer active, or no longer holds `actions:invoke`. |
| `policy_denies` | A policy now denies the operation. |
| `dependency_unresolved` | An effect this one depends on is not in its required outcome. |
| `credential_unavailable` | The secret could not be resolved, or its host or placement binding no longer allows this request. |
| `impact_limit_exceeded` | A shared limit has no room. See [When the limit is hit](/execution/when-the-limit-is-hit). |
| `impact_unbounded` | A limit applies and the call gives no evaluable amount. |

For a refusal at dispatch, the invocation's `error` has the form `Dispatch refused (<reason>): <message>`. An approval that expires undecided sets the invocation's status to `expired` and writes no such error.

Two refusals and the values they return. A secret whose allowed host was changed after review:

```json
{ "stage": "rejected", "rejectionReason": "credential_unavailable", "grade": "refused" }
```

An approval whose stored digest is not the effect's digest:

```json
{
  "stage": "rejected",
  "outcome": "not_dispatched",
  "rejectionReason": "binding_mismatch",
  "grade": "refused",
  "attempts": []
}
```

Be precise about that second one. CI reaches `binding_mismatch` by altering a stored database row. The API offers no way to make the stored preview, effect and approval disagree, so you will not trigger it by hand. A changed request sent through the API is caught earlier, at admission, as `PREVIEW_MISMATCH`.

Duplicate approval callbacks cannot run the operation twice. Consuming the approval and claiming dispatch are one conditional database write.

## A denied approval

A denial is not a refusal by Anlyon. It has its own grade.

```json
{ "stage": "rejected", "rejectionReason": "approval_denied", "grade": "denied" }
```

The invocation's status is `denied`. The destination saw no request. A denied effect is final. Ask again with a new invocation.

```typescript TypeScript
await operator.approvals.deny('apr_01JABCDEF', { note: 'Wrong customer.' });

const { data: denied } = await operator.actions.effects({ grade: 'denied' });
```

```python Python
operator.approvals.deny("apr_01JABCDEF", note="Wrong customer.")

denied = operator.actions.effects(grade="denied").data
```

```bash curl
curl -s -X POST https://api.anlyon.com/api/v2/approvals/apr_01JABCDEF/deny \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Wrong customer." }'

curl -s "https://api.anlyon.com/api/v2/actions/effects?grade=denied" \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"
```

## When the provider's state changes

Everything above is about what Anlyon stored. A change at the provider after review is a different case. The digest covers the preconditions Anlyon observed at review. It does not watch the provider afterwards, so the binding check passes. What happens next depends on the adapter.

**`github.file_update`: the provider refuses a stale write.** The preview records the file's blob SHA. The write sends that SHA, and GitHub refuses it when the file has changed since. Anlyon did send the request, with the reviewed SHA attached, and GitHub said no.

```json
{ "outcome": "failed", "partialEffect": "none", "verification": "provider" }
```

This receipt grades **`failed`**, not `refused`. There is no `rejectionReason`. The evidence carries `details.staleVersion: true` with this summary:

```text
GitHub refused the write (HTTP 409): the file no longer matches the reviewed version.
```

In a test run the file kept the other writer's content. The accurate sentence is that the write carried the blob SHA the reviewer saw and GitHub refused it. It is not that Anlyon's digest detected the change.

**`stripe.refund`: the refundable amount is as of review.** The account is re-checked immediately before dispatch, and Stripe refuses a refund above what remains refundable. The preview labels this `freshness`.

**A declared action: the binding is the request.** A preview of a declared action shows the request and reads nothing from the provider. Provider state between review and dispatch is outside the binding. See [Govern any HTTP API](/guides/any-http-api).

## How it behaves

- **Governed means an adapter or a declaration.** A governed action gets a digest-bound approval that is checked again at dispatch. An action with no declaration gets a snapshot-bound approval.
- **The binding is the exact request.** A change in provider state is caught by the provider where it has a conditional write, as GitHub does.
- **The amount of a declared action is your expression.** You write what the action counts, and Anlyon enforces it before dispatch.
- **Any workspace member can decide an approval.** An API key needs `approvals:decide` and can never decide an approval it requested.
- **Capacity is checked at dispatch.** A limit can fill after review, and an approval holds no allowance.
- **A limit belongs to one environment.**
- **The approval and the receipt are database records with provider evidence.**
