Skip to content

fix(core): don't assume a 64-character idempotency key is pre-hashed on reset - #4626

Open
claude[bot] wants to merge 2 commits into
mainfrom
fix/idempotency-reset-64-char-key
Open

fix(core): don't assume a 64-character idempotency key is pre-hashed on reset#4626
claude[bot] wants to merge 2 commits into
mainfrom
fix/idempotency-reset-64-char-key

Conversation

@claude

@claude claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Requested by Matt Aitken · Slack thread

idempotencyKeys.reset() now honours an explicitly passed scope even when the key material happens to be 64 characters long.

Before: resetIdempotencyKey treated any 64-character string as an already-computed hash and sent it to the API verbatim. That short-circuit ran before the scope logic, so if your key material is itself a 64-character digest — a common pattern when you hash your own dedup identity — the scope you passed was silently discarded and the un-hashed material went on the wire. The server stores the hash, so the reset matched no run and returned 404 every single time. Key material of any other length worked fine, which made this look arbitrary.

After: a 64-character string is only passed through when there is evidence it is already a hash. Otherwise an explicit scope is honoured and the hash is derived, at any key length.

How

The pass-through now requires positive evidence rather than a length guess:

  • the idempotency key catalog recognises the string, so it came from idempotencyKeys.create(), or
  • no scope was passed, so there is nothing to derive a hash from and the length is the only available signal.

An explicit scope is an explicit request to derive the hash, so it is never short-circuited past.

Both existing behaviours are preserved:

  1. A key from idempotencyKeys.create() is still forwarded unchanged (catalog hit), including when a scope is also passed — it is never hashed twice.
  2. 64-character material passed straight to trigger() is stored un-hashed, and resetting it with no scope still sends it verbatim.

isIdempotencyKey is deliberately left alone: it applies the same length rule on the trigger path, but it is self-consistent there, and changing it would invalidate already-stored keys.

The attachedOptions?.key / attachedOptions?.scope fallbacks below the guard were unreachable — every catalog entry is a 64-character digest, so it always hit the short-circuit first — and re-deriving from them produces the identical hash anyway. They are removed rather than left as dead code.


✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works

Testing

New tests in packages/core/src/v3/idempotencyKeys.test.ts drive the real resetIdempotencyKey against a local HTTP server and assert on the exact value that reaches the wire — nothing is mocked. They cover:

  • 64-character material + explicit { scope: "global" } resolves to the same value idempotencyKeys.create() produces (fails without this change)
  • 64-character material + { scope: "run", parentRunId } derives the run-salted hash (fails without this change)
  • a key from idempotencyKeys.create() is forwarded unchanged, with and without a scope
  • a 64-character key with no scope is still forwarded unchanged
  • ordinary short material is still hashed
pnpm run test ./src/v3/idempotencyKeys.test.ts --run   # 7 passed
pnpm run build --filter @trigger.dev/core              # clean
pnpm run format && pnpm run lint                       # clean

Reverting only the source change turns the two new scope tests red and leaves the three compatibility tests green.


Changelog

idempotencyKeys.reset() now works when your idempotency key is itself 64 characters long. Previously any 64-character key was assumed to be already hashed, so passing one along with a scope silently ignored the scope and the reset never found a matching run.


Follow-ups (not in this PR)

  • docs/idempotency.mdx describes the idempotencyKey parameter of reset() as "the 64-character hash string" in one place while showing raw material plus { scope: "global" } a few lines later. Worth reconciling.
  • No surface currently exposes the stored hash that the reset endpoint matches on — ctx.run.idempotencyKey, the run page and the idempotency_key query column all show the user-provided key. That is what leads people to send a value reset cannot match.

…on reset

`resetIdempotencyKey` treated any 64-character string as an already-computed
hash and sent it to the API verbatim. That short-circuit ran before the scope
logic, so a user key that is itself a 64-character digest had an explicitly
passed `scope` silently discarded and was sent un-hashed, matching no run.

A 64-character string is now only passed through when there is evidence it is
already a hash: the idempotency key catalog recognises it (so it came from
`idempotencyKeys.create()`), or no `scope` was passed and the length is the
only signal available. An explicit `scope` is an explicit request to derive
the hash, so it is always honoured.

This keeps both existing behaviours intact: a key from `idempotencyKeys.create()`
is still forwarded unchanged, and 64-character key material passed straight to
`trigger()` and reset without a scope is still sent verbatim. `isIdempotencyKey`
is deliberately untouched, since the trigger path is self-consistent and
changing it would invalidate already-stored keys.

Co-Authored-By: Claude <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6016987

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/sdk Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@trigger.dev/sso Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@internal/testcontainers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@6016987

trigger.dev

npm i https://pkg.pr.new/trigger.dev@6016987

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@6016987

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@6016987

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@6016987

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@6016987

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@6016987

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@6016987

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@6016987

commit: 6016987

@matt-aitken
matt-aitken marked this pull request as ready for review August 15, 2026 09:26

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment on lines +237 to +244
// A 64-char key is only assumed pre-hashed if the catalog knows it, or there's no scope to hash with
if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined;

// Try to extract options from an IdempotencyKey created with idempotencyKeys.create()
const attachedOptions =
typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined;
if (isCreatedKey || options?.scope === undefined) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Resetting a generated idempotency key stops working when the reset happens outside the run that created it and a scope is given

A key produced by the key-creation helper is now re-hashed instead of being sent as-is (generateIdempotencyKey at packages/core/src/v3/idempotencyKeys.ts:276) whenever the process that resets it no longer remembers the key and a scope was also passed, so the reset matches nothing and fails.
Impact: Users who reset a previously created key from another run, process, or backend while also passing a scope will find the reset silently does nothing.

Catalog-miss + explicit scope now falls through to hashing an already-hashed value

Before this change any 64-character string was forwarded verbatim, so a key from idempotencyKeys.create() always reset correctly regardless of scope and regardless of which process performed the reset. Now the pass-through at packages/core/src/v3/idempotencyKeys.ts:238-244 requires either a catalog hit or options.scope === undefined.

The catalog is in-process and is cleared at every run boundary by the workers (packages/cli-v3/src/entryPoints/managed-run-worker.ts:354, packages/cli-v3/src/entryPoints/dev-run-worker.ts:382). So for the documented flow "create the key in a task, reset it later" (docs/idempotency.mdx:388-402) combined with an explicit { scope: "global" } — e.g. resetting from backend code or from a later run on a warm worker — getIdempotencyKeyOptions() returns undefined, the guard fails, and the already-hashed 64-char digest is hashed a second time (plus scope suffix). The server stores the first digest, so the reset never matches. The new test at packages/core/src/v3/idempotencyKeys.test.ts:119-124 only passes because it does not reset the catalog between create and reset.

Prompt for agents
resetIdempotencyKey in packages/core/src/v3/idempotencyKeys.ts now only forwards a 64-character key verbatim when the in-process idempotency key catalog recognises it, or when no scope was passed. The catalog is process-local and is cleared at each run boundary by the CLI workers (managed-run-worker.ts / dev-run-worker.ts), so a key created with idempotencyKeys.create() and reset later from a different process or a different run — while also passing an explicit scope — is no longer recognised. It then falls through to generateIdempotencyKey and gets hashed a second time, producing a digest the server never stored, so the reset silently matches nothing (404). This is a regression from the previous length-based pass-through, which handled that case correctly. Consider a way to distinguish already-hashed keys from 64-char user material that does not rely on the process-local catalog (for example an explicit option such as `preHashed`/`raw`, or requiring the caller to opt in to hashing), or at minimum document/handle the created-key-plus-scope combination so it is not double hashed. Also reconcile with the docs in docs/idempotency.mdx which show resetting a created key and separately show passing an explicit scope.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +237 to +244
// A 64-char key is only assumed pre-hashed if the catalog knows it, or there's no scope to hash with
if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
const isCreatedKey = getIdempotencyKeyOptions(idempotencyKey) !== undefined;

// Try to extract options from an IdempotencyKey created with idempotencyKeys.create()
const attachedOptions =
typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined;
if (isCreatedKey || options?.scope === undefined) {
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Trigger path and reset path can now disagree on 64-char material

isIdempotencyKey (packages/core/src/v3/idempotencyKeys.ts:52-61) still treats any 64-char string as pre-hashed on the trigger path, so trigger({ idempotencyKey: <64-char material> }) stores the raw value with no scope salt. The new reset logic, when given the same 64-char material plus { scope: "global" }, derives sha256(material) instead — which will not match what trigger stored. The PR's test at packages/core/src/v3/idempotencyKeys.test.ts:101-108 asserts this derived value equals what idempotencyKeys.create() produces, which is the correct target for keys created via create(), but users who passed 64-char material directly to trigger() and then pass a scope to reset() will now get a different (still non-matching) value than before. Worth confirming which of the two user flows the fix is intended to serve, since the length rule remains split across the two paths.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant