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:

  1. Is approve configured on createKaji() at all? An absent handler always settles rejected.
  2. Does your approve handler return { approved: boolean }? Any other shape is treated as an invalid decision and settles rejected.
  3. Did your approve handler throw or reject? A handler error settles rejected, not unknown — 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:

  1. Use result.evidence (executionId, inputFingerprint) to look up whatever your application or downstream provider can tell you about this specific operation.
  2. If your capability’s execute function can reconcile the ambiguity itself — for example, checking the provider for an existing committed effect by idempotency key — do that before deciding to retry.
  3. 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 recorded unknown result rather than re-running execute. 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.