Governed effects
One persisted business operation per request: reserved against shared limits, bound to its approval, graded, and recoverable when a response is lost.
An action with no declaration sends one HTTP request and records what came back. A governed effect goes further. Anlyon knows how much the operation changes, which exact request was reviewed, and how to read the result back. So it can reserve the impact against shared limits, bind the approval to exactly that request, and return a graded receipt.
You opt in per action, in one of two ways:
- Give the action an
adapter. Three adapters exist:stripe.refund,github.file_updateandresend.email_send. - Declare
impact,verifyorgoverned: trueon an HTTP action of your own. That is a declared action. See Actions and Govern any HTTP API.
Nothing about an existing action changes unless you give it an adapter or a declaration.
The three adapters and declared actions, side by side
stripe.refund |
github.file_update |
resend.email_send |
A declared action | |
|---|---|---|---|---|
| Operation | A refund of an explicit amount on a payment intent | An update to one existing text file on a designated branch | One email from a designated sending domain | The HTTPS request your definition describes |
| Counted as | money in the action’s currency, plus one resource_mutations |
One resource_mutations |
emails, one per recipient across to, cc and bcc |
Your impact.amount, in your dimension |
| “Succeeded” means | A Stripe Refund carrying this effect’s id reaches status succeeded |
A commit on that branch, created by this write, carries Anlyon-Effect: <effect id> |
Resend accepted the send and returned an email id | The provider answered the write with a 2xx |
confirmed means |
Every success is read from Stripe’s own record of the refund | Every success is read from GitHub’s own record of the commit | A read-back of the email id shows this effect’s tag | A read-back matched your verify rule |
| Provider idempotency | Yes. The adapter sends the effect’s Idempotency-Key and treats it as usable for 24 hours |
No. The adapter sends the reviewed blob SHA as a precondition instead | Yes. The adapter sends the effect’s Idempotency-Key. Resend keeps a key for 24 hours |
No |
| Stale-state protection | Not atomic. The adapter re-checks the account before dispatch and sends no conditional write | Atomic. The write carries the reviewed blob SHA | None. Resend has no conditional send | None. Anlyon does not know the API’s preconditions |
| Lookup after a lost response | By refund id, or by the anlyon_effect metadata on the payment intent |
By a commit whose message carries the effect trailer | By email id, or by a bounded scan of recent sent emails for the anlyon_effect tag |
By repeating the read-back, when the verify rule does not need the response |
| Same-key replay | Yes, within 24 hours | No | Yes, within 24 hours | No. A replay is refused before any write |
| Later completion | Tracked. A refund accepted as pending is read back up to 12 times. After that it stays unresolved until an operator resolves it |
The commit exists when GitHub answers | Not tracked. Delivery, bounces and complaints happen after acceptance | Not tracked. A 2xx is recorded as the result |
| Undo | No. A refund cannot be reversed through the Stripe API | Yes, by a new and separately reviewed file update | No. A sent email cannot be recalled | Not known to Anlyon. Define a separate governed action for the reversal |
Statements about Resend’s API on this page are as of 2026-10-03.
Define the action
An adapter builds the request. You give it an explicit target and a vault secret name. There is no URL template, and no default account, branch or sending domain that could change after review.
await anlyon.secrets.put('STRIPE_TEST_KEY', { value: process.env.STRIPE_TEST_KEY!, allowedHosts: ['api.stripe.com'] });
await anlyon.actions.create({
name: 'refund',
requiresApproval: true,
adapter: {
type: 'stripe.refund',
config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd' },
credentialSecret: 'STRIPE_TEST_KEY',
},
});
await anlyon.actions.create({
name: 'update-fixture',
adapter: {
type: 'github.file_update',
config: { owner: 'acme', repo: 'fixtures', branch: 'anlyon-test', pathPrefix: 'data/' },
credentialSecret: 'GITHUB_TOKEN',
},
});
await anlyon.actions.create({
name: 'send-receipt',
adapter: {
type: 'resend.email_send',
config: { domain: 'mail.example.com', fromAddresses: ['billing@mail.example.com'] },
credentialSecret: 'RESEND_API_KEY', // a full access key: a sending access key cannot read back
},
});anlyon.secrets.put("STRIPE_TEST_KEY", value=os.environ["STRIPE_TEST_KEY"], allowed_hosts=["api.stripe.com"])
anlyon.actions.create(
name="refund",
requires_approval=True,
adapter={
"type": "stripe.refund",
"config": {"account": "acct_1Nxyz", "mode": "test", "currency": "usd"},
"credentialSecret": "STRIPE_TEST_KEY",
},
)
anlyon.actions.create(
name="update-fixture",
adapter={
"type": "github.file_update",
"config": {"owner": "acme", "repo": "fixtures", "branch": "anlyon-test", "pathPrefix": "data/"},
"credentialSecret": "GITHUB_TOKEN",
},
)
anlyon.actions.create(
name="send-receipt",
adapter={
"type": "resend.email_send",
"config": {"domain": "mail.example.com", "fromAddresses": ["billing@mail.example.com"]},
"credentialSecret": "RESEND_API_KEY", # a full access key: a sending access key cannot read back
},
)- The Stripe refund adapter takes
mode: "live"ormode: "test"and a Stripe key that matches it:sk_live_orrk_live_for live,sk_test_orrk_test_for test. The examples on this page use a test key. Amounts are exact integer minor units in the action’s one currency. github.file_updaterefuses the repository’s default branch unless the config saysallowDefaultBranch: true. It refuses files it cannot read as UTF-8 text or that exceed 64 KB. It changes one existing file per effect.resend.email_sendneeds an explicit sendingdomain. Everyfromaddress must be on it, andfromAddressesnarrows that to a list. It refuses a credential that is not a Resend API key (re_...).totakes 1 to 50 addresses, andccandbcctake up to 50 each. The input needshtml,textor both.
A declared action is defined like any other HTTP action, with its declarations. See Actions.
Invoke it
const { data } = await agent.actions.invoke(
'refund',
{ paymentIntent: 'pi_3Pabc', amount: 1250, currency: 'usd' },
{ operationKey: 'order-4411-refund' },
);
console.log(data!.status); // pending_approval, succeeded, running (pending), unknown, failed
console.log(data!.effectId); // eff_...result = agent.actions.invoke(
"refund",
{"paymentIntent": "pi_3Pabc", "amount": 1250, "currency": "usd"},
operation_key="order-4411-refund",
)
print(result.data["status"], result.data["effectId"])Operation identity. Repeating the same operationKey with the same request returns the existing operation, and nothing is sent again. Reusing it with a different request is refused with 409 OPERATION_KEY_REUSE. When you do not pass one, the Idempotency-Key is used, then a fresh key. Two different keys do not make two requests different business intent: two refunds of the same payment under two keys are two refunds. What bounds that is an impact limit, not identity.
Stage and outcome are different things
An effect has a stage (where it is in Anlyon’s pipeline) and an outcome (what happened in the world):
| Outcome | Meaning |
|---|---|
pending |
The provider accepted the work and has not finished it (a Stripe refund in pending). |
succeeded |
Evidence establishes the declared result. |
failed |
Evidence establishes that the declared result did not happen. partialEffect says whether some of it might have (possible) or did (confirmed): a failure is not proof of zero impact. |
unknown |
The available evidence cannot establish the result. |
verification says who established the outcome: provider (the provider’s own evidence), manual (an operator’s resolution, never shown as provider verification), unverified, or not_dispatched.
Every effect also carries a grade, derived from the stage, the outcome and the verification: confirmed, acknowledged, unknown, failed, refused, denied or pending. See Receipts and grades.
When the response is lost
A timeout, a reset connection, a 5xx, or a worker that dies after sending all end the same way. The outcome is unknown, never failed, and nothing is retried. Anlyon schedules a reconciliation with backoff. It reads and never writes, and it asks the provider:
- Stripe: by refund id when the response arrived, otherwise by the
anlyon_effectmetadata every refund carries. Finding the refund settles the effect. Not finding it proves nothing, because metadata can be removed after the fact, so the effect staysunknownwith its allowance held. The adapter offers a same-key replay for 24 hours, the time it treats the idempotency key as usable. After that, an operator resolves it with evidence. - GitHub: by a commit on the branch whose message carries the effect trailer. Finding one confirms success. Not finding one never proves the write failed: a force-push can remove a commit that landed, and the search is bounded. So a GitHub effect whose commit cannot be found stays
unknownuntil an operator resolves it with evidence. Matching file content alone is never taken as proof that Anlyon made the change. - Resend: by email id when the response arrived. After a lost response Resend offers no lookup by idempotency key, so Anlyon lists recent sent emails and reads candidates by id for the
anlyon_effecttag. The scan is bounded at 3 pages and 20 reads. Finding the email settles the effect. Not finding it proves nothing, so the effect staysunknownwith its allowance held. A same-key replay inside 24 hours is the other recovery, and a send recovered that way gradesacknowledged. - A declared action: by repeating the read-back you declared. A match confirms the write. No match proves nothing, so the effect stays
unknown. The write is never repeated. A verify rule that needs the provider response cannot run after that response was lost, and an action with no verify rule has nothing to read. Both stayunknownuntil an operator resolves them.
See When the outcome is unknown.
const { data: open } = await operator.actions.effects({ unresolved: true });
const { data: effect } = await operator.actions.effect('eff_01JABCDEF');
for (const item of effect!.evidence) console.log(item.observedAt, item.source, item.summary);
await operator.actions.reconcileEffect('eff_01JABCDEF'); // read-only, now
await operator.actions.addEffectNote('eff_01JABCDEF', 'Asked Stripe support, ticket 81233.');
await operator.actions.resolveEffect('eff_01JABCDEF', { // manual, labelled as such
outcome: 'failed',
reason: 'No refund exists for pi_3Pabc in the Stripe dashboard.',
});open_effects = operator.actions.effects(unresolved=True)
effect = operator.actions.effect("eff_01JABCDEF")
operator.actions.reconcile_effect("eff_01JABCDEF")
operator.actions.add_effect_note("eff_01JABCDEF", "Asked Stripe support, ticket 81233.")
operator.actions.resolve_effect(
"eff_01JABCDEF",
outcome="failed",
reason="No refund exists for pi_3Pabc in the Stripe dashboard.",
)Evidence is append-only: every observation keeps its source, time, provider ids and redacted details, and a later observation never erases an earlier one. A confirmed outcome is never rewritten, including when the file or payment changes later.
Rehearse it: the Stripe fault drill
You cannot make Stripe lose a response on demand, so stripe.refund can do it for you. The drill needs mode: "test". Give an action faultDrill: "drop_response_after_send" and Anlyon sends the refund to Stripe as usual, then discards Stripe’s answer, exactly as a connection lost after the write would. The effect is recorded unknown, its allowance stays held, nothing is sent again, and the reconciler settles it from Stripe’s record on its normal schedule (the first lookup is about 30 seconds later).
await anlyon.actions.create({
name: 'refund-drill',
adapter: {
type: 'stripe.refund',
config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd', faultDrill: 'drop_response_after_send' },
credentialSecret: 'STRIPE_TEST_KEY',
},
});
The drill is part of the action’s versioned config and every preview of it says “Fault drill”, so nobody approves a drill without seeing it is one. A replay of a drilled effect is drilled too. Keep drills on their own action: every invocation of a drilled action loses its response.
Replay (replayEffect) repeats a lost write under the same provider identity. It needs a provider idempotency key, so two adapters offer it: Stripe and Resend, each within a 24-hour window. GitHub file updates do not. The reviewed blob SHA cannot tell a file someone restored from one that was never written, so a repeat could apply the change twice. A declared action does not either. It has no provider idempotency key, so a replay is refused with replay_unsupported before anything is sent.
A replay is a write, and it passes every control an original dispatch passes. The reviewed preview must still be current. The approval must hold. The action must still resolve to the reviewed version. The environment must not be halted. The requesting credential must still be active, and current policy must still allow it. A refusal from these checks answers 409 REPLAY_REFUSED, with a message that begins Replay refused (<reason>):. It is recorded in the effect’s evidence and sends nothing.
Two refusals come earlier and are not recorded on the effect. During a halt the call answers 409 ENVIRONMENT_HALTED. The key that requested the effect cannot replay it and gets 403. Replaying needs actions:resolve.
A replay that is admitted but fails describes that attempt, not the original write. It may have failed to reach the provider, or the provider may have refused it. The effect stays unknown and its allowance stays held. One write may be in flight at a time, and a replay never creates a second effect.
Across every adapter, after the first dispatch only the provider’s own record of the operation in a failed state settles an effect failed. Failing to find the operation, or failing to repeat it, never does: releasing allowance for an operation that may have happened is the error Anlyon is built to avoid. Reconcile, notes and resolve stay available while the environment is halted. Replay does not.
An invocation governed by an effect is resolved through its effect, never through POST /actions/invocations/{id}/resolve, which refuses it with EFFECT_RESOLUTION_REQUIRED: settling only the invocation would leave the effect unknown and its allowance held.
Reconciliation, notes, resolution and replay need actions:resolve for keys (reconcile needs only actions:invoke). The key that requested an effect can never resolve or replay it.
Dependent work
Where one operation must only follow another, say so. Pending and unknown outcomes never satisfy a dependency:
await agent.actions.invoke('update-fixture', { path: 'data/refunds.txt', content: 'order-4411: refunded\n', message: 'Record refund' }, {
dependsOn: [{ effect: 'eff_01JABCDEF', outcome: 'succeeded' }],
});agent.actions.invoke(
"update-fixture",
{"path": "data/refunds.txt", "content": "order-4411: refunded\n", "message": "Record refund"},
depends_on=[{"effect": "eff_01JABCDEF", "outcome": "succeeded"}],
)A compensation (undoing an effect) is a new governed effect with its own preview, approval, impact and outcome. Invoke the compensating action with compensates: 'eff_...'.
How it behaves
stripe.refund
- One currency per action, and every refund carries an explicit amount.
- The refundable amount in a preview is as of review. The adapter re-checks the account before dispatch.
github.file_update
- One existing UTF-8 text file per effect, up to 65536 bytes.
- The write carries the reviewed blob SHA as its precondition.
- A commit that lookup cannot find leaves the effect
unknownuntil an operator resolves it with evidence.
resend.email_send
- One sending domain per action, and one email per effect.
- The vault secret is a full access key. The read-back and the lookup use it.
- Recovery after a lost response is a bounded search or a same-key replay inside 24 hours.
- Confirmed is Resend’s record of the send, not inbox delivery.
A declared action
- The write is sent once, with no provider idempotency key. A replay is refused before anything is sent.
- A
2xxwith a matching read-back gradesconfirmed. A2xxwith noverifyrule gradesacknowledged. - A verify URL that reads
{{response.*}}needs the provider response. After a lost response an operator resolves that effect. - The declared amount is evaluated as you wrote it.
- A verify rule can match a resource that already existed. Write a rule that distinguishes this write.
Every governed effect
- A lost response that lookup cannot find stays
unknown, with its allowance held, until a replay inside the provider’s window or an operator’s resolution. An operator’s resolution is labelledmanualand is never shown as provider verification. - The Stripe fault drill is an injected fault. Anlyon discards a response that did arrive.
- A receipt is a database record with provider evidence.
- A governed effect comes from an action with an adapter or a declaration.

