Ambiguity and reconciliation
Classify unknown external outcomes, confirm them out of band, and reconcile both executions and recovery attempts safely.
An external request can succeed even when your process never receives the response. A payment provider might accept a charge and then time out, or a recovery call might complete just before its connection drops. Retrying in either case could duplicate the change.
Effects distinguish an unknown outcome from an ordinary known failure. The
receipt becomes unknown, automatic rollback reports the unit as ambiguous,
and Crux waits for external evidence instead of guessing.
Classify an unknown outcome
Throw EffectOutcomeUnknownError only when the provider may have committed the
operation and retrying could duplicate it. The error tells Crux why the receipt
cannot settle and lets you retain safe provider identifiers for investigation.
Ordinary thrown errors continue to mean the operation is known to have failed.
import {
effect,
EffectOutcomeUnknownError,
type EffectReceiptRef,
} from "@use-crux/core/effect";
let receipt: EffectReceiptRef | undefined;
const chargeCustomer = effect(
"billing.charge",
async (input: ChargeInput, context) => {
receipt = {
kind: "effect.receipt",
id: context.receiptId,
effectId: "billing.charge",
};
try {
return await billing.charge(input, {
idempotencyKey: context.idempotencyKey,
});
} catch (cause) {
if (!billing.mayHaveAccepted(cause)) throw cause;
throw new EffectOutcomeUnknownError(
"The provider did not confirm the charge outcome",
{ providerOperationId: input.operationId },
{ cause },
);
}
},
{ recover: refundCharge },
);The executor receives receiptId before the external call, which lets
application code associate the Crux receipt with a provider operation ID even
when no response arrives. Keep that association in application state, or rely
on the durable receipt row when a Runtime Effects store is configured. Without
a store, the Effect ledger is process-local.
Do not classify every timeout as unknown. Use the provider's documented semantics. If it guarantees that a particular error means no operation was created, throw the ordinary error so the receipt settles as failed.
Reconcile after external confirmation
Use reconcileEffect() only after provider logs, an idempotent status endpoint,
or authorized operator evidence establishes the actual outcome. It settles the
ambiguous receipt, retains the required audit reason, and is the only supported
way to move a receipt out of unknown.
import { reconcileEffect } from "@use-crux/core/effect";
if (receipt) {
await reconcileEffect(receipt, {
outcome: "failed",
reason: "The provider confirms that no charge was created",
});
}Reconciliation validates the receipt identity and rejects receipts that are missing, mismatched, or already settled. It never rewrites the original attempt or erases its ambiguous history.
Confirm a successful execution
Choose outcome: "succeeded" when the provider confirms that the external
change exists. Supply the exact known JSON-safe executor output because future
recovery may depend on it.
const charge = await billing.getCharge(providerOperationId);
await reconcileEffect(receipt, {
outcome: "succeeded",
output: {
chargeId: charge.id,
amount: charge.amount,
currency: charge.currency,
},
reason: `Provider confirms charge ${charge.id}`,
});Confirmed success settles the receipt as succeeded and activates the recovery unit that Crux prepared before execution. The output must match the recovery contract for this Effect version. Do not invent placeholder output merely to clear the ambiguity.
Confirm a failed execution
Choose outcome: "failed" when the provider confirms that no external change
was made. No output is required because there is nothing to recover.
await reconcileEffect(receipt, {
outcome: "failed",
reason: "The provider confirms that the request was rejected before commit",
});Confirmed failure settles the receipt and removes its prepared recovery unit. A later rollback will not attempt compensation for a change that evidence says never happened.
Reconcile an unknown recovery attempt
Recovery calls can have the same response-loss window as execution. If a
recovery handler throws EffectOutcomeUnknownError, Crux records a distinct
unknown recovery attempt and leaves the original unit ambiguous. It does not
retry automatically because providing an idempotency key does not prove that a
custom handler forwarded it.
After checking the provider, call reconcileEffect() with the original receipt
whose recovery is ambiguous. Crux resolves its sole linked unknown recovery
attempt for a single-receipt unit. A direct recovery-attempt receipt also works
when the surrounding runtime retains one. With a Runtime Effects store, that
settlement is durable and survives process loss.
await reconcileEffect(originalReceipt, {
outcome: "succeeded",
output: null,
reason: "The provider confirms that the refund completed",
});Confirming recovery success atomically settles the attempt and marks the original unit recovered. Confirming recovery failure marks the attempt failed and returns the original unit to an active, retryable state:
await reconcileEffect(originalReceipt, {
outcome: "failed",
reason: "The provider confirms that the refund was not created",
});A known failed recovery may be retried under application policy. An unknown recovery must be reconciled first.
Design for the uncertainty window
No SDK can guarantee exactly-once changes across an arbitrary external system and a separate receipt store. Build every high-impact Effect around the gap:
- Forward the provided idempotency key when the provider supports it.
- Retain a provider operation ID that can be queried later.
- Return JSON-safe output containing the identifiers recovery needs.
- Classify only genuinely uncertain errors with
EffectOutcomeUnknownError. - Require external evidence and an audit reason before reconciliation.
Devtools reflects the same contract. Ambiguous Effect cards stay visible and do not show a misleading recovery affordance. Use Runs to inspect the receipt and provider details, then use application code with appropriate authorization to reconcile it.
A crash while execution or recovery is still running has the same shape as an
unknown outcome: reconstruction projects the interrupted work as unknown or
ambiguous and does not retry it. Confirm externally, then reconcile. Details
are in
Durability and restarts.