Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

When approval expires or changes

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

const { data: fresh } = await agent.actions.refreshPreview('prv_01JABCDEF', { ttlSeconds: 600 });
console.log(fresh!.id, fresh!.supersedes, fresh!.bindingDigest);
fresh = agent.actions.refresh_preview("prv_01JABCDEF", ttl_seconds=600).data
print(fresh["id"], fresh["supersedes"], fresh["bindingDigest"])
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.

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);
}
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)
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.

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

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);
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"])
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"
{ "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.
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:

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

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

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

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

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

const { data: denied } = await operator.actions.effects({ grade: 'denied' });
operator.approvals.deny("apr_01JABCDEF", note="Wrong customer.")

denied = operator.actions.effects(grade="denied").data
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.

{ "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:

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.

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.

Was this page helpful?