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

When the outcome is unknown

What unknown means for a governed action, why Anlyon does not send the request again, why the allowance stays held, and the four ways an unknown outcome settles.

Sometimes a write leaves Anlyon and no usable answer comes back. The provider may have applied it. Anlyon records that outcome as unknown. It does not record failed, and it does not send the request again.

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

What unknown means

unknown means the available evidence cannot establish the result. It happens when:

  • the request timed out or the connection was reset after the request was sent
  • the provider answered with a 5xx
  • the worker handling the dispatch stopped before it recorded an outcome

It is not a failure. A request that provably never left Anlyon is failed, and a provider’s 4xx is usually failed. The exception is a 409 to stripe.refund or resend.email_send, which is unknown: it can mean the idempotency key is still in use, which proves nothing either way. unknown is reserved for a write that may have happened.

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

if (data!.status === 'unknown') {
  // Do not invoke again. Keep the effect id and read it later.
  console.log(data!.grade, data!.effectId, data!.error);
}
result = agent.actions.invoke(
    "send-email",
    {"emailId": "welcome-1", "to": ["a@example.com", "b@example.com"], "subject": "Welcome"},
)

if result.data["status"] == "unknown":
    # Do not invoke again. Keep the effect id and read it later.
    print(result.data["grade"], result.data["effectId"], result.data["error"])
curl -s -X POST https://api.anlyon.com/api/v2/actions/send-email/invoke \
  -H "Authorization: Bearer $ANLYON_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-1" \
  -d '{ "input": { "emailId": "welcome-1", "to": ["a@example.com", "b@example.com"], "subject": "Welcome" } }'

The HTTP status is 200.

{ "status": "unknown", "grade": "unknown" }

The invocation’s error carries this text:

The provider may have applied effect <effect id>; Anlyon is reconciling it. Do not retry; inspect the effect.

The effect, read while unknown:

{
  "outcome": "unknown",
  "stage": "reconciling",
  "verification": "unverified",
  "unresolvedExposure": true
}

On the same effect reconciliation.nextAt is set: a lookup is already scheduled.

From the CLI, an invoke whose outcome is unknown exits 13.

A request is sent once

Nothing in the effect lifecycle retries a write. A dispatch is claimed once, and the database refuses a second dispatch attempt for one effect. A worker that dies after sending leaves the effect unknown. The effect is reconciled, never dispatched again.

The reason is the cost of being wrong. A repeated refund is a second refund. A repeated send is a second email. A lookup costs one read.

The allowance stays held

The effect reserved its impact before dispatch. While the outcome is unknown, that reservation is neither consumed nor released. It is held.

{ "consumed": 0, "reserved": 2, "unknownExposure": 2 }

Two emails are held against the limit. available is limit - consumed - reserved, so other callers have that much less room. The hold survives restarts and period boundaries. Releasing allowance for an operation that may have happened is the error this design avoids. See When the limit is hit.

A dependent effect does not start either. unknown and pending never satisfy a dependsOn.

How it settles

There are four ways out of unknown.

1. The scheduled read-back

Anlyon schedules a lookup when it records the unknown outcome. The lookup is a read. It asks the provider what happened to this operation and never writes.

  • A declared action repeats its verify rule: one GET, compared with the declared status and checks.
  • stripe.refund looks the refund up by id, or by the anlyon_effect metadata every refund carries.
  • github.file_update looks for a commit on the branch whose message carries the effect trailer.
  • resend.email_send reads the email by id, or scans recent sent emails for the anlyon_effect tag. The scan is bounded at 3 pages and 20 reads.

A match settles the effect. For the declared action above:

{
  "stage": "settled",
  "outcome": "succeeded",
  "grade": "confirmed",
  "verification": "provider",
  "attempts": [{ "kind": "dispatch" }, { "kind": "reconcile" }]
}

The destination saw one write and one read. The limit then read:

{ "consumed": 2, "reserved": 0, "unknownExposure": 0 }

The settled amount goes into the period the reservation was made in, even when the answer arrives in a later period.

2. The on-demand reconcile endpoint

You do not have to wait for the schedule. Ask for the same lookup now.

const { data: effect } = await operator.actions.reconcileEffect('eff_01JABCDEF');
console.log(effect!.outcome, effect!.grade, effect!.verification);
effect = operator.actions.reconcile_effect("eff_01JABCDEF").data
print(effect["outcome"], effect["grade"], effect["verification"])
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/eff_01JABCDEF/reconcile \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"

This needs actions:invoke. It stays available while the environment is halted, so a halt never strands held exposure.

{ "outcome": "succeeded" }

That call returned HTTP 200 during a halt. An effect that is already succeeded or failed answers HTTP 409: a settled outcome is not reconciled again. If another lookup or write for the effect is in flight, the call also answers 409 and you try again shortly.

3. A same-key replay, where the adapter supports it

A replay repeats the lost write under the same provider identity. It is safe when the provider deduplicates on an idempotency key, and inside that key’s window.

  • stripe.refund supports it for 24 hours. The adapter sends the same idempotency key again and treats that key as usable for 24 hours.
  • resend.email_send supports it for 24 hours. Resend keeps an idempotency key for 24 hours, as of 2026-10-03. A send recovered that way grades acknowledged.
  • github.file_update does not.
  • A declared action does not. It sends no provider idempotency key, so a repeated write could apply twice.

A replay is a write. It passes every control a dispatch passes, and it needs actions:resolve. The key that requested the effect cannot replay it.

await operator.actions.replayEffect('eff_01JABCDEF');
operator.actions.replay_effect("eff_01JABCDEF")
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/eff_01JABCDEF/replay \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"

On a declared action the replay is refused before anything is sent. The response is HTTP 409.

{
  "success": false,
  "error": {
    "code": "REPLAY_REFUSED",
    "message": "Replay refused (replay_unsupported): ..."
  }
}

The message names the reason. The hosted API does not return error.details. The refusal is recorded in the effect’s evidence, and the outcome stays unknown. In a test run the destination still showed one write.

A replay that is admitted and then fails says nothing about the original write. The effect stays unknown and its allowance stays held.

4. An operator’s resolution, labelled manual

When no lookup can settle it, a person checks the provider and records what they found.

await operator.actions.addEffectNote('eff_01JABCDEF', 'Asked the mailer support desk, ticket 81233.');

const { data: resolved } = await operator.actions.resolveEffect('eff_01JABCDEF', {
  outcome: 'succeeded',
  reason: 'Checked the mailer dashboard: the message was sent.',
});
console.log(resolved!.grade, resolved!.verification);
operator.actions.add_effect_note("eff_01JABCDEF", "Asked the mailer support desk, ticket 81233.")

resolved = operator.actions.resolve_effect(
    "eff_01JABCDEF",
    outcome="succeeded",
    reason="Checked the mailer dashboard: the message was sent.",
).data
print(resolved["grade"], resolved["verification"])
curl -s -X POST https://api.anlyon.com/api/v2/actions/effects/eff_01JABCDEF/resolve \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "succeeded", "reason": "Checked the mailer dashboard: the message was sent." }'
{ "outcome": "succeeded", "grade": "acknowledged", "verification": "manual" }

A manual succeeded grades acknowledged, never confirmed. It keeps verification: "manual" everywhere it is shown. A manual failed releases the allowance.

Resolving and adding notes are for admins in the console, or a key holding actions:resolve, which no key receives by default. The reason must be at least 10 characters. The key that requested an effect can never resolve it. A confirmed outcome is not rewritten: resolving an effect that is already settled is refused.

Not finding it never becomes failed

A lookup that finds nothing is absence of evidence. Metadata can be removed, a commit can be force-pushed away, and a provider’s list can lag. So a lookup that does not find the operation leaves the effect unknown, with its allowance held.

In a test run a declared write timed out and three read-backs then found nothing:

{ "outcome": "unknown", "grade": "unknown", "unresolvedExposure": true }
{ "consumed": 0, "reserved": 1, "unknownExposure": 1 }

The evidence for each lookup reads:

The read-back did not match the declared verify rule. That does not prove the write failed, so the outcome stays unknown.

After dispatch, one thing settles an effect failed without a person: the provider’s own record of this operation in a failed state. A Stripe refund that Stripe reports as failed is that. Failing to find the operation is not. Failing to replay it is not.

A verify URL that needs the response

Some APIs choose the resource id and return it in the response. The verify URL then uses {{response.id}}. That rule works when the response arrives. It cannot run when the response is lost, because the id was in the lost response.

In a test run that action confirmed a normal send. Then a send lost its response, and reconciliation was requested:

{ "outcome": "unknown", "grade": "unknown" }

Reconciliation sent no request at all. The evidence reads:

The verify rule needs the provider response, which was lost. The outcome stays unknown until an operator resolves it.

This is stated before the write. The preview’s limitations block carries an unsupported item about read-back after a lost response. An operator’s resolution is the way out for such an effect.

To avoid it, let the caller choose the id. An action that writes to /v1/emails/{{input.emailId}} and verifies at the same URL can be read back after a lost response. An action with no verify rule cannot be read back at all, and a lost response on it also waits for an operator.

Backoff, the attempt cap, then reconcile exhausted

Scheduled lookups back off. The delay starts at 30 seconds and doubles after each attempt, up to one hour. After 12 attempts automatic reconciliation stops.

{ "reconciliation": { "exhausted": true, "nextAt": null } }

The effect’s evidence gains an entry that begins “Automatic reconciliation stopped”. The outcome is still unknown and the allowance is still held. Nothing times out into failed.

An exhausted effect needs a person. An on-demand reconcile still works and runs one more lookup. Otherwise resolve it with evidence. Find these effects with the unresolved filter:

const { data: open } = await operator.actions.effects({ unresolved: true });
const { data: unknown } = await operator.actions.effects({ grade: 'unknown' });

for (const effect of open ?? []) {
  console.log(effect.id, effect.outcome, effect.reconciliation.attempts, effect.reconciliation.exhausted);
}
open_effects = operator.actions.effects(unresolved=True).data
unknown = operator.actions.effects(grade="unknown").data

for effect in open_effects:
    print(effect["id"], effect["outcome"], effect["reconciliation"]["attempts"], effect["reconciliation"]["exhausted"])
curl -s "https://api.anlyon.com/api/v2/actions/effects?unresolved=true" \
  -H "Authorization: Bearer $ANLYON_OPERATOR_KEY"

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

From the CLI: anlyon effects list --unresolved and anlyon effects list --grade unknown.

What the caller should do

  1. Do not retry. A new invocation under a new key is a new operation. If the lost write landed, a retry is a duplicate.
  2. Keep the effectId. It is in the invoke response.
  3. Read the effect. Poll GET /actions/effects/{id}, or ask for a reconcile now. Wait for outcome to leave unknown.
  4. Repeat the same call if you must repeat something. The same Idempotency-Key returns the existing invocation in its current state. The same operationKey with the same request returns the existing effect. Neither sends anything.
  5. Do not start dependent work. Pass dependsOn and Anlyon refuses the dependent call with DEPENDENCY_UNRESOLVED until the outcome is settled.
  6. Escalate when reconciliation.exhausted is true. An operator resolves it with evidence.

An invocation governed by an effect is resolved through its effect. POST /actions/invocations/{id}/resolve refuses it with EFFECT_RESOLUTION_REQUIRED.

How it behaves

  • Governed means an adapter or a declaration. A governed action has an effect, a read-back and a held allowance. The invocation of an action with no declaration can still be unknown, and you reconcile that one yourself.
  • A declared action recovers by the read-back or an operator. Its write is sent once.
  • confirmed on a declared action means your own verify rule matched. A resource that already existed can match, so write a rule that distinguishes this write. This matters most after a lost response, when the read-back is the evidence.
  • A lookup is bounded. Past its bounds the effect stays unknown until a person resolves it.
  • The Stripe replay window is 24 hours. That is how long the adapter treats Stripe’s idempotency key as usable.
  • A limit belongs to one environment. Held exposure in one environment leaves the others unchanged.
  • A receipt is a database record with provider evidence. A manual resolution is a person’s statement, labelled as one.
  • Any workspace member can decide an approval. Resolving an effect is a separate permission, actions:resolve.

Was this page helpful?