paperclipai/paperclip · error · PhotonAnswerValidationError

Photon poll already claimed

Error message

Photon poll already claimed

What it means

Inside publishPhotonPrompt, before creating a native poll, the code performs a compare-and-swap on adapter.state: if the current record is already phase "creating" and retryUnknown is falsy, another concurrent attempt has claimed the send, so a PhotonAnswerValidationError("Photon poll already claimed") is thrown. This is a concurrency guard ensuring only one poll-creation attempt is in flight per stateKey.

Solutions

  1. Serialize publishing per publicationId (queue or lock) so only one attempt runs at a time.
  2. After reconciling the unknown-outcome prior attempt, reset state to prepared and retry with retryUnknown: true.
  3. Check for duplicate triggers (double delivery of the same interaction) and deduplicate upstream.
  4. If a stale creating record is the cause, manually update state to {schema:1, phase:"prepared"} before retrying.

Example fix

// before — concurrent duplicate call
publishPhotonPrompt(input); publishPhotonPrompt(input); // second throws
// after
const once = memoizePerPublication(publishPhotonPrompt);
await Promise.all([once(input), once(input)]); // single create
Defensive patterns

Strategy: try-catch

Validate before calling

const claimed = (await adapter.state.read<{phase:string}>(`poll:${publicationId}:${qIndex}`))?.phase === "creating";
if (claimed) await reconcileBeforePublish(publicationId);

Type guard

function isUnclaimed(current: {phase: string} | undefined): boolean {
  return current?.phase !== "creating";
}

Try / catch

try {
  await publishPhotonPrompt(input);
} catch (e) {
  if (e instanceof PhotonAnswerValidationError && e.message === "Photon poll already claimed") {
    // another attempt owns the send; back off and poll state until created/prepared
    await waitForPollSettled(`poll:${input.publicationId}:${qIndex}`);
  } else throw e;
}

Prevention

When it happens

Trigger: Two concurrent invocations of publishPhotonPrompt for the same publicationId/questionIndex both reach the adapter.state.update(stateKey) CAS; the loser sees current.phase === "creating" and throws. Also thrown when a stale creating record from a crashed prior attempt is hit during the CAS despite the earlier read check passing.

Common situations: Duplicate webhook/redelivery triggering two publishPhotonPrompt calls in parallel; retried request racing the original still in flight; a crashed process leaving phase creating and a later attempt bypassing the read check via race timing.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/4d78e3b4559898f8. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/photon/interactions.ts:291

    type PollSend = {
      schema: 1;
      phase: "prepared" | "creating" | "created";
      receipt?: {
        pollMessageGuid: string;
        options: Array<{ optionIdentifier: string; text: string }>;
      };
    };
    let saved = await adapter.state.read<PollSend>(stateKey);
    if (saved?.phase === "creating" && !input.retryUnknown)
      throw new PhotonError(
        "delivery_unknown",
        "Photon poll creation is unknown; reconcile this publication before retrying",
      );
    if (!saved || saved.phase !== "created") {
      await assertCurrent();
      await adapter.state.update<PollSend>(stateKey, (current) => {
        if (current?.phase === "creating" && !input.retryUnknown)
          throw new PhotonAnswerValidationError("Photon poll already claimed");
        return { schema: 1, phase: "creating" };
      });
      const poll = await adapter.client.polls
        .create(
          adapter.decodeThreadId(input.threadId).chatGuid,
          title,
          choices.map((choice) => choice.label),
          {
            clientMessageId: createHash("sha256")
              .update(`${adapter.state.scope.endpointId}:${stateKey}`)
              .digest("hex"),
          },
        )
        .catch(async (error) => {
          const failure = photonFailure(error, true);
          if (failure.code !== "delivery_unknown")
            await adapter.state.update<PollSend>(stateKey, () => ({
              schema: 1,

View on GitHub (pinned to 3f1d897a7c)