518978bc52
<!-- ccr-slack-attribution --> _Requested by **Matt Aitken** · [Slack thread](https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1786741966214949?thread_ts=1786741966.214949&cid=C045W9WM3E1)_ `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 key with an explicit `scope` is sent verbatim first and, only when that attempt comes back a definitive not-found, retried as the derived scope hash. Every call that worked before behaves identically, and the previously impossible case now resolves on the fallback. ## How A 64-character string is forwarded unchanged, exactly as before, when: - the idempotency key catalog recognises it (it came from `idempotencyKeys.create()` in this process), or - no `scope` was passed, so there is nothing to derive a hash from, or - the scope hash cannot be derived (e.g. `scope: "run"` outside a task context with no `parentRunId`). Otherwise the key is ambiguous: it may be raw material the caller wants hashed with the scope, or it may already be the stored hash. Reset sends the verbatim value first because that is what every previous version sent, so anything that resolved before still resolves with the same single request, the same target run, and the same errors. The derived hash is the new behaviour, so it only runs once the verbatim attempt has failed with a 404, a definitive "no run under this key". Any other error (a 503, a connection error) leaves the verbatim key's state unknown, and resetting a different key on unknown state would be an untargeted write the caller never asked for, so those errors surface unchanged. That has an honest cost: when the endpoint answers 503 for a miss it cannot confirm, the caller sees the 503 and retries rather than silently falling through to the derived key. When both attempts miss, the verbatim attempt's 404 is surfaced, again matching what previous versions threw. A side benefit of this order: a key from `idempotencyKeys.create()` reset with a `scope` from a cold process resolves in a single request, because the created key is itself the stored value. `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 old 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 - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing Tests in `packages/core/src/v3/idempotencyKeys.test.ts` drive the real `resetIdempotencyKey` against a local HTTP server and assert on the exact values that reach the wire, in order. Nothing is mocked. They cover: - 64-character material + explicit `scope` derives the global- and run-scoped hash once the verbatim key misses (fails without this change) - the verbatim key wins when runs exist under both the verbatim value and the derived hash, so the pre-existing target is preserved - keys from `idempotencyKeys.create()` are forwarded unchanged: catalog hit, no scope, and scope with a cold catalog (the last now a single request) - a transient failure of the verbatim attempt surfaces its error without ever touching the derived key - error surfacing: a double miss reports the key the caller passed, and a non-404 from the fallback is not swallowed - ordinary short material is still hashed, and underivable run/attempt scopes still send a 64-character key verbatim while still throwing for shorter material ``` pnpm run test ./src/v3/idempotencyKeys.test.ts --run # 18 passed pnpm run build --filter @trigger.dev/core # clean pnpm run format && pnpm run lint # clean ``` --- ## 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. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Matt Aitken <matt@mattaitken.com>