Troubleshooting
Diagnose common capability, executor, and execution-store failures.
Start with the first error you can reproduce, then apply the matching fix.
capability() requires a non-empty name.
capability({ name }) must be a non-empty string. This is thrown at
construction time, before the capability is ever executed.
capability() requires an authorize function.
Every capability must declare authorize. There is no default policy —
Kaji does not decide who may run an action, so an omitted authorize fails
fast instead of defaulting to allow or deny.
ExecutionRequest.principalId must be a non-empty string. / ...idempotencyKey must be a non-empty string.
Both fields are required on every call to kaji.execute(). Kaji never
infers a principal or generates an idempotency key on the caller’s behalf —
supply both explicitly from your request.
A retried request returns a failed conflict instead of running again
error: new Error('Idempotency key "..." was already used for a different request.');
The idempotency key was already claimed with a different input, capability, or principal. One idempotency key identifies exactly one intended operation; reusing it for a materially different request is a conflict, not a retry. Generate a new idempotency key for a genuinely new operation.
A request I expected to execute returns the previous result instead
This is expected, not a bug: a repeated execution with the same capability,
principal, idempotency key, and input fingerprint returns the recorded
outcome instead of running execute again. If you intended a new
operation, use a new idempotency key.
execute() settles rejected
Approval was required (approval returned true) and was not granted.
Check, in order:
- Is
approveconfigured oncreateKaji()at all? An absent handler always settlesrejected. - Does your
approvehandler return{ approved: boolean }? Any other shape is treated as an invalid decision and settlesrejected. - Did your
approvehandler throw or reject? A handler error settlesrejected, notunknown— approval failures are never ambiguous about whether the action ran, because it hasn’t yet.
execute() settles denied
authorize returned false, or it threw before returning. Kaji does not
provide an authorization system — the decision is entirely your
application’s authorize function. Add logging inside authorize itself
if you need to know why a specific request was denied.
execute() settles unknown and I want to retry
Read unknown as “Kaji cannot prove whether the side effect happened,” not
as an ordinary failure. Do not retry it automatically. Instead:
- Use
result.evidence(executionId,inputFingerprint) to look up whatever your application or downstream provider can tell you about this specific operation. - If your capability’s
executefunction can reconcile the ambiguity itself — for example, checking the provider for an existing committed effect by idempotency key — do that before deciding to retry. - Only call
kaji.execute()again with the same idempotency key once you’re ready to accept its behavior: a repeated call with the same key and input still returns the recordedunknownresult rather than re-runningexecute. To force a genuinely new attempt, use a new idempotency key, and only after you’ve confirmed the original operation did not commit.
My capability’s own errors are always unknown, never failed
Kaji treats every thrown or rejected execute call as unknown by
default — it has no way to know whether your side effect committed before
the error surfaced. If your application code can prove a specific failure
never committed a side effect (for example, a payment provider that
confirms a charge was declined before any charge occurred), throw
knownFailure(cause) for that specific case:
import { knownFailure } from "@irogane/kaji";
execute: async (input, context) => {
try {
return await provider.charge(input);
} catch (cause) {
if (isDefinitelyDeclined(cause)) throw knownFailure(cause);
throw cause; // stays unknown: the side effect may have committed
}
},
Kaji never infers this from an error’s class, message, or status code —
only throw knownFailure() when your own code has proven no side effect
occurred.
Cancellation didn’t stop my side effect
Cancellation is cooperative. If your capability’s execute function
doesn’t check context.signal (or pass it to an operation that does), Kaji
cannot force it to stop. Once execute has been called, an aborted signal
can no longer prevent the action — it can only ask the capability to stop,
and the resulting outcome settles unknown rather than cancelled.
timeoutMs elapsed but the remote operation still ran
A timeout means Kaji stopped waiting — it is not a rollback and is not
proof the operation didn’t happen. The corresponding execution settles
unknown. If you need to know the real outcome, reconcile with the
downstream system using the execution’s idempotency key, the same as any
other unknown result.
My custom ExecutionStore throws during claim() or record()
A claim() failure causes kaji.execute() itself to throw — Kaji cannot
establish the execution boundary at all in that case, since no execution
record exists yet. A record() failure after execution instead settles the
returned result as unknown: Kaji cannot prove the outcome was durably
recorded, so it can’t tell the caller the capability’s real result.
memoryStore() is process-local and non-durable; it is meant for local
development, tests, and examples, not production. A production deployment
needs its own durable ExecutionStore implementation satisfying the same
claim/record contract.