From 83c8b4ad5e4e056057b1b48271dee4edcb568eef Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 12 Aug 2026 11:28:12 -0700 Subject: [PATCH 1/9] [docs] Document duplicate-event handling in the event sourcing guide #3381 changed replay to ignore an event whose class it has already consumed for an entity, instead of reporting a divergence that ends the run with CORRUPTED_EVENT_LOG. The v5 event sourcing guide still only described the write-path terminal-state guard, which is a different layer and does not cover the duplicates the write path permits. Adds a "Duplicate Events" section covering the class table, the types that belong to no class, the two invariants (consumers are offered the event first, skipped events do not advance the deterministic clock), and the info/error logging split. Co-Authored-By: Claude Opus 5 --- .../docs-event-sourcing-duplicate-events.md | 4 ++ .../docs/v5/how-it-works/event-sourcing.mdx | 40 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/docs-event-sourcing-duplicate-events.md diff --git a/.changeset/docs-event-sourcing-duplicate-events.md b/.changeset/docs-event-sourcing-duplicate-events.md new file mode 100644 index 0000000000..c05a24fef4 --- /dev/null +++ b/.changeset/docs-event-sourcing-duplicate-events.md @@ -0,0 +1,4 @@ +--- +--- + +Document how replay handles duplicate events in the event sourcing guide. diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 31febb849a..4d9c5da60a 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -94,7 +94,7 @@ flowchart TD - `cancelled`: Reserved for future use (not currently emitted) -The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured. See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work. +The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured, and also when concurrent replays each commit one (see [Duplicate Events](#duplicate-events)). See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work. When present, the `step_retrying` event moves a step back to `pending` state and records the error that caused the retry. This provides two benefits: @@ -225,6 +225,44 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach Attempting to create an event that would transition an entity out of a terminal state will result in an error. This prevents inconsistent state and ensures the integrity of the event log. +That guard sits on the write path. A duplicate the write path does permit — a second `step_created` for a step that is not yet terminal, for example — is handled during replay instead, described next. + +## Duplicate Events + +Concurrent invocations replaying the same run share one event log and append to it without a currency guard. An invocation working from a prefix that predates another invocation's write can commit its own `step_created`, `step_started`, or `wait_created` for an entity the log already records one of. + +Those duplicates are committed but inert. The outcome was decided by the first event of its kind at a lower position in the log, and every replay reads that same event at that same position, so a later copy cannot change what the workflow observes. + +To keep an inert copy from failing an otherwise healthy run, the runtime groups event types into **classes** and tracks, per entity, which classes the current replay has already consumed. When an event is offered to every registered consumer and none wants it, and its class is already recorded for that entity, the replay steps over it instead of reporting a [replay divergence](/docs/errors/replay-divergence) — which, once the recovery budget is exhausted, ends the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). + +| Class | Event types | +|-------|-------------| +| `run_started` | `run_started` | +| `step_created` | `step_created` | +| `step_started` | `step_started` | +| `step_retrying` | `step_retrying` | +| `step_terminal` | `step_completed`, `step_failed` | +| `wait_created` | `wait_created` | +| `wait_completed` | `wait_completed` | +| `hook_created` | `hook_created` | +| `hook_disposed` | `hook_disposed` | + +Types that share a class are the mutually exclusive outcomes of one decision: a step either completes or fails, and the first outcome recorded is the one that counts. Classes are independent of one another, so passing over one does not suppress another. A step whose result is already in the log has still recorded exactly one `step_created`, which is what makes a second one ignorable on its own terms. + +The remaining event types belong to no class and are never skipped: + +- `hook_received` and `hook_conflict` are deliveries rather than decisions. A hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything. +- `attr_set` is written on every attribute write. +- `run_created` precedes every replay and is always consumed. +- `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them. + +Two properties hold for every skipped event: + +- **A live consumer always wins.** The class check runs only after every registered consumer has declined the event. A retry's `step_started` still reaches the step's consumer and still counts as an attempt. Only the copies nobody claims are skipped. +- **The deterministic clock does not move.** Skipped events do not advance replay's notion of the current time, so a log that happens to contain a duplicate produces the same timestamps as one that does not. + +Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing. + ## Event Correlation Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance: From 582ad88393ab0662087015594cc96b87b2d6ace3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 12 Aug 2026 11:42:32 -0700 Subject: [PATCH 2/9] Apply suggestions from code review Co-authored-by: Peter Wielander Signed-off-by: Peter Wielander --- docs/content/docs/v5/how-it-works/event-sourcing.mdx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 4d9c5da60a..02710d7f4f 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -229,7 +229,7 @@ That guard sits on the write path. A duplicate the write path does permit — a ## Duplicate Events -Concurrent invocations replaying the same run share one event log and append to it without a currency guard. An invocation working from a prefix that predates another invocation's write can commit its own `step_created`, `step_started`, or `wait_created` for an entity the log already records one of. +Concurrent invocations replaying the same run share one event log and _may_ append to the log _before_ verifying that the transition is valid, in certain cases. We recommend validating transitions atomically with event inserts, otherwise duplicate events might be recorded in the log. Those duplicates are committed but inert. The outcome was decided by the first event of its kind at a lower position in the log, and every replay reads that same event at that same position, so a later copy cannot change what the workflow observes. @@ -251,15 +251,12 @@ Types that share a class are the mutually exclusive outcomes of one decision: a The remaining event types belong to no class and are never skipped: -- `hook_received` and `hook_conflict` are deliveries rather than decisions. A hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything. +- `hook_received`: a hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything. +- `hook_conflict`: hook creation must be idempotent, so getting a `hook_conflict` at any position indicates a failure to acquire the hook and can't come after `hook_created` for the same hook - `attr_set` is written on every attribute write. - `run_created` precedes every replay and is always consumed. - `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them. -Two properties hold for every skipped event: - -- **A live consumer always wins.** The class check runs only after every registered consumer has declined the event. A retry's `step_started` still reaches the step's consumer and still counts as an attempt. Only the copies nobody claims are skipped. -- **The deterministic clock does not move.** Skipped events do not advance replay's notion of the current time, so a log that happens to contain a duplicate produces the same timestamps as one that does not. Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing. From 9b998b8614de04d5bb7d6e51e282bb4483d63836 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 13 Aug 2026 14:54:00 -0700 Subject: [PATCH 3/9] [core] Describe webhook token generation accurately Copied from #3444 by @anir0y, without its changeset. createWebhook() rejects an explicit token outright, and the token it mints is not random: createCreateHook falls back to ctx.generateNanoid(), whose PRNG is seeded on `${runId}:${workflowName}:${deploymentId}` (workflow.ts:353) so URLs stay stable across replays and concurrent invocations of one run. The docstring called it randomly generated and attributed webhook-endpoint security to that randomness. Co-Authored-By: anir0y Co-Authored-By: Claude Opus 5 --- packages/core/src/create-hook.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index e24a528e23..44b76d2907 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -117,13 +117,21 @@ export interface HookOptions { * the token with the information it has available. * * Deterministic tokens are intended for use with `createHook()` and - * server-side `resumeHook()` only. For webhooks (`createWebhook()`), - * tokens are always randomly generated to prevent unauthorized access - * to the public webhook endpoint. + * server-side `resumeHook()` only. For webhooks (`createWebhook()`), an + * explicit token is not accepted — one is always generated for you. + * + * Generated webhook tokens are drawn from the run's deterministic sequence, + * based on the run ID, the workflow name, and the deployment ID, which are not + * trivial to guess but should not be considered secure. This is done so URLs stay + * stable across replays and across concurrent invocations of the same run. + * + * We recommend authenticating webhook requests themselves — a signature + * header, a shared secret, or an auth check inside the handler — rather + * than relying on URL secrecy alone. * * If provided, the token must be a non-empty string; passing an empty - * string throws. If not provided (or `undefined`), a randomly generated - * token will be assigned. + * string throws. If not provided (or `undefined`), a token is generated + * for you. * * @example * From d0d8aede4bd2a9bbc6b553f835b08a6f7e5560f1 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 13 Aug 2026 14:54:41 -0700 Subject: [PATCH 4/9] Collapse the blank line left by the review-suggestion edit --- docs/content/docs/v5/how-it-works/event-sourcing.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 02710d7f4f..6e01eae08f 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -257,7 +257,6 @@ The remaining event types belong to no class and are never skipped: - `run_created` precedes every replay and is always consumed. - `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them. - Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing. ## Event Correlation From 91f8236859570b363e04aa7d79377ff1c81e1b0a Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:09:17 +0000 Subject: [PATCH 5/9] Fix: Error message in `createWebhook()` inaccurately claims webhook tokens are "always randomly generated" when they are actually drawn from the run's deterministic seeded PRNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/core/src/workflow/create-hook.ts:65 ## Bug In `packages/core/src/workflow/create-hook.ts`, when a `token` option is passed to `createWebhook()`, it throws: ``` `createWebhook()` does not accept a `token` option. Webhook tokens are always randomly generated. Use `createHook()` with `resumeHook()` for deterministic token patterns. ``` The clause **"Webhook tokens are always randomly generated"** is factually incorrect. Tokens come from the run's deterministic seeded PRNG: - `packages/core/src/workflow/hook.ts`: `const token = options.token ?? ctx.generateNanoid();` - `packages/core/src/workflow.ts`: `generateNanoid` uses `nanoid.customRandom(... vmGlobalThis.Math.random())`, where the VM seed is `${runId}:${workflowName}:${deploymentId}`. - `packages/core/src/vm/index.ts`: `g.Math.random = seedrandom(seed)`. So generated tokens are stable across replays and concurrent invocations of the same run — the opposite of "randomly generated". This also contradicts the corrected JSDoc in the sibling declaration file `packages/core/src/create-hook.ts`, which now says the token is "always generated for you" and "drawn from the run's deterministic sequence, based on the run ID, the workflow name, and the deployment ID". ## Impact User-facing, misleading error message that is inconsistent with the corrected documentation in the same PR. The throw behavior itself (rejecting an explicit token) is correct. ## Fix Reworded the message to `Webhook tokens are always generated for you.`, matching the JSDoc phrasing and removing the false claim of randomness. Co-authored-by: Vercel Co-authored-by: VaguelySerious --- packages/core/src/workflow/create-hook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/workflow/create-hook.ts b/packages/core/src/workflow/create-hook.ts index 96e31d1b9f..bc773c67d2 100644 --- a/packages/core/src/workflow/create-hook.ts +++ b/packages/core/src/workflow/create-hook.ts @@ -62,7 +62,7 @@ export function createWebhook( if (token !== undefined) { throw new Error( - '`createWebhook()` does not accept a `token` option. Webhook tokens are always randomly generated. Use `createHook()` with `resumeHook()` for deterministic token patterns.' + '`createWebhook()` does not accept a `token` option. Webhook tokens are always generated for you. Use `createHook()` with `resumeHook()` for deterministic token patterns.' ); } From ff9b2e4f6a278d75a1bb057e222e7b36776b44a1 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 13 Aug 2026 15:13:50 -0700 Subject: [PATCH 6/9] [core] Align the remaining token wording with the corrected docstring The error-message change in the previous commit left `hook.test.ts` asserting the old string, which fails the unit suite. Update the assertion, and apply the same correction to the places that still describe a generated token as random: the `createHook()` empty-token guard and its comment, the hooks and testing guides, the `resumeWebhook()` reference, and the vitest workbench comments. The v5 hooks guide additionally attributed webhook-endpoint security to that randomness. A generated token is drawn from the run's seeded sequence, so the guide now points at authenticating the request instead of relying on URL secrecy. --- .../docs/v5/api-reference/workflow-api/resume-webhook.mdx | 2 +- docs/content/docs/v5/foundations/hooks.mdx | 4 ++-- docs/content/docs/v5/testing/index.mdx | 4 ++-- packages/core/src/workflow/hook.test.ts | 4 ++-- packages/core/src/workflow/hook.ts | 4 ++-- workbench/vitest/test/workflow.test.ts | 2 +- workbench/vitest/workflows/webhook.ts | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx b/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx index adf4fc8a52..5d16ba8fd2 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/resume-webhook.mdx @@ -61,7 +61,7 @@ Throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found- ## Usage Note -In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL. +In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL. For server-side hook resumption with deterministic tokens, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) with [`createHook()`](/docs/api-reference/workflow/create-hook) instead. diff --git a/docs/content/docs/v5/foundations/hooks.mdx b/docs/content/docs/v5/foundations/hooks.mdx index 348f31da8c..722f977d76 100644 --- a/docs/content/docs/v5/foundations/hooks.mdx +++ b/docs/content/docs/v5/foundations/hooks.mdx @@ -116,7 +116,7 @@ Calling `createHook()` on its own does not register the hook — registration is ### Custom Tokens for Deterministic Hooks -By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events. +By default, hooks generate their own token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events. For example, imagine a Slack bot where each channel should have its own workflow instance: @@ -480,7 +480,7 @@ This pattern is especially valuable in larger applications where the workflow an ### Token Design -Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always use randomly generated tokens to prevent unauthorized access to public webhook endpoints. +Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own token, drawn from the run's deterministic sequence so the URL stays stable across replays and across concurrent invocations of the same run. A generated token is not trivial to guess, but it is not a secret either: authenticate webhook requests themselves with a signature header, a shared secret, or a check inside the handler rather than relying on URL secrecy. When using custom tokens with `createHook()`: diff --git a/docs/content/docs/v5/testing/index.mdx b/docs/content/docs/v5/testing/index.mdx index ad1f87483f..36a69fda11 100644 --- a/docs/content/docs/v5/testing/index.mdx +++ b/docs/content/docs/v5/testing/index.mdx @@ -256,7 +256,7 @@ import { createWebhook } from "workflow"; export async function ingestWorkflow(endpointId: string) { "use workflow"; - // Webhook tokens are always randomly generated + // Webhook tokens are always generated for you using webhook = createWebhook(); // [!code highlight] const request = await webhook; // [!code highlight] @@ -282,7 +282,7 @@ describe("ingestWorkflow", () => { it("should process webhook data", async () => { const run = await start(ingestWorkflow, ["ep-1"]); - // Discover the randomly generated webhook token + // Discover the generated webhook token const hook = await waitForHook(run); // [!code highlight] // Resume the webhook with a Request object diff --git a/packages/core/src/workflow/hook.test.ts b/packages/core/src/workflow/hook.test.ts index bdaa3e5442..c6c59727db 100644 --- a/packages/core/src/workflow/hook.test.ts +++ b/packages/core/src/workflow/hook.test.ts @@ -1264,7 +1264,7 @@ describe('createCreateHook', () => { const createHook = createCreateHook(ctx); expect(() => createHook({ token: '' })).toThrow( - '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a randomly generated one.' + '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a generated one.' ); // The rejected hook must not be registered in the invocations queue. @@ -1290,7 +1290,7 @@ describe('createCreateHook', () => { describe('createWebhook', () => { it('should throw when a token option is passed', () => { expect(() => (createWebhook as any)({ token: 'anything' })).toThrow( - '`createWebhook()` does not accept a `token` option. Webhook tokens are always randomly generated. Use `createHook()` with `resumeHook()` for deterministic token patterns.' + '`createWebhook()` does not accept a `token` option. Webhook tokens are always generated for you. Use `createHook()` with `resumeHook()` for deterministic token patterns.' ); }); }); diff --git a/packages/core/src/workflow/hook.ts b/packages/core/src/workflow/hook.ts index d5d5bed8ec..bcb714d89d 100644 --- a/packages/core/src/workflow/hook.ts +++ b/packages/core/src/workflow/hook.ts @@ -66,14 +66,14 @@ function createConflictingRun( export function createCreateHook(ctx: WorkflowOrchestratorContext) { return function createHookImpl(options: HookOptions = {}): Hook { // Reject an explicit empty-string token. A token must either be omitted - // (or `undefined`/`null`) to get a randomly generated one, or be an + // (or `undefined`/`null`) to get a generated one, or be an // explicit non-empty string. An empty string is almost always an // accidental value (e.g. an unset variable) and would otherwise slip // through the `??` below — which only falls back for nullish values — and // be used as a meaningless, non-deterministic token. if (options.token === '') { throw new Error( - '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a randomly generated one.' + '`createHook()` was called with an empty string token. Pass a non-empty token, or omit the `token` option to use a generated one.' ); } diff --git a/workbench/vitest/test/workflow.test.ts b/workbench/vitest/test/workflow.test.ts index 176037b2ff..414f232008 100644 --- a/workbench/vitest/test/workflow.test.ts +++ b/workbench/vitest/test/workflow.test.ts @@ -87,7 +87,7 @@ describe('webhook workflow', () => { it('should resume when webhook receives data via resumeWebhook', async () => { const run = await start(webhookWorkflow, ['endpoint-1']); - // Webhook tokens are randomly generated, so discover via waitForHook + // Webhook tokens are generated for you, so discover via waitForHook const hook = await waitForHook(run); await resumeWebhook( diff --git a/workbench/vitest/workflows/webhook.ts b/workbench/vitest/workflows/webhook.ts index 22c24fe374..2719f991a8 100644 --- a/workbench/vitest/workflows/webhook.ts +++ b/workbench/vitest/workflows/webhook.ts @@ -8,7 +8,7 @@ async function processPayload(body: string) { export async function webhookWorkflow(endpointId: string) { 'use workflow'; - // createWebhook() does not accept a token — tokens are randomly generated. + // createWebhook() does not accept a token — one is always generated for you. // Use waitForHook() in tests to discover the token. using webhook = createWebhook(); From e5ab9140e88d99171fa905e9325982ca85d4044a Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 13 Aug 2026 15:40:00 -0700 Subject: [PATCH 7/9] [docs] Note the observability UI's duplicate marking #3467 greys out duplicates in the events views. What it can identify is narrower than what the runtime skips, because it reads the log without consumer state, so the page says where the two differ. --- docs/content/docs/v5/how-it-works/event-sourcing.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 6e01eae08f..e44fc3d4a2 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -259,6 +259,8 @@ The remaining event types belong to no class and are never skipped: Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing. +The observability UI greys out the events it can identify this way, which is a narrower set than the runtime's. It reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once a terminal event for the same entity sits earlier in the log, which is the point past which no consumer remains. + ## Event Correlation Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance: From 47bf1c911ae861aa3df48297b3beaeccf1d32cbe Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 14 Aug 2026 14:07:55 -0700 Subject: [PATCH 8/9] Apply suggestions from code review Co-authored-by: Pranay Prakash Signed-off-by: Peter Wielander --- docs/content/docs/v5/foundations/hooks.mdx | 2 +- docs/content/docs/v5/how-it-works/event-sourcing.mdx | 6 +++--- packages/core/src/create-hook.ts | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/v5/foundations/hooks.mdx b/docs/content/docs/v5/foundations/hooks.mdx index 722f977d76..eda54f8e6a 100644 --- a/docs/content/docs/v5/foundations/hooks.mdx +++ b/docs/content/docs/v5/foundations/hooks.mdx @@ -480,7 +480,7 @@ This pattern is especially valuable in larger applications where the workflow an ### Token Design -Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own token, drawn from the run's deterministic sequence so the URL stays stable across replays and across concurrent invocations of the same run. A generated token is not trivial to guess, but it is not a secret either: authenticate webhook requests themselves with a signature header, a shared secret, or a check inside the handler rather than relying on URL secrecy. +Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens A generated token is not trivial to guess, but it is not a strong security contract either - which allows attackers to invoke unintended webhook resumptions. To completely prevent unauthenticated run resumptions, prefer using **hook** over the **webhook** convenience and implement your own authentication on the route that calls `resumeHook`. When using custom tokens with `createHook()`: diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index e44fc3d4a2..994234616f 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -229,7 +229,7 @@ That guard sits on the write path. A duplicate the write path does permit — a ## Duplicate Events -Concurrent invocations replaying the same run share one event log and _may_ append to the log _before_ verifying that the transition is valid, in certain cases. We recommend validating transitions atomically with event inserts, otherwise duplicate events might be recorded in the log. +Concurrent invocations replaying the same run share one event log. An invocation working from a stale prefix — one that predates another invocation's write — can commit its own `step_created`, `step_started`, or `wait_created` for an entity the log already records one of. These writes pass the terminal-state guard above, so the write path commits them even when a backend validates transitions atomically with the insert. Those duplicates are committed but inert. The outcome was decided by the first event of its kind at a lower position in the log, and every replay reads that same event at that same position, so a later copy cannot change what the workflow observes. @@ -252,14 +252,14 @@ Types that share a class are the mutually exclusive outcomes of one decision: a The remaining event types belong to no class and are never skipped: - `hook_received`: a hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything. -- `hook_conflict`: hook creation must be idempotent, so getting a `hook_conflict` at any position indicates a failure to acquire the hook and can't come after `hook_created` for the same hook +- `hook_conflict`: records a failed acquisition of the hook's token. Acquisition is idempotent — a run re-creating a hook it already owns converges on the existing `hook_created` rather than conflicting — so a `hook_conflict` cannot follow a `hook_created` for the same hook. - `attr_set` is written on every attribute write. - `run_created` precedes every replay and is always consumed. - `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them. Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing. -The observability UI greys out the events it can identify this way, which is a narrower set than the runtime's. It reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once a terminal event for the same entity sits earlier in the log, which is the point past which no consumer remains. +The observability UI greys out the events it can identify this way, which is a narrower set than the runtime's. It reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log. ## Event Correlation diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index 44b76d2907..5daad41420 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -121,8 +121,9 @@ export interface HookOptions { * explicit token is not accepted — one is always generated for you. * * Generated webhook tokens are drawn from the run's deterministic sequence, - * based on the run ID, the workflow name, and the deployment ID, which are not - * trivial to guess but should not be considered secure. This is done so URLs stay + * seeded on the run ID, the workflow name, and the deployment ID. The + * resulting tokens are not trivial to guess, but should not be treated as + * secret. This is done so URLs stay * stable across replays and across concurrent invocations of the same run. * * We recommend authenticating webhook requests themselves — a signature From 3a89807f47692e3f1f7478d69826fade13a465a9 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 14 Aug 2026 14:35:12 -0700 Subject: [PATCH 9/9] Log ignored duplicates at debug, cover hook duplicates, fix docs review feedback - `onDuplicateEvent` logs both branches at `debug`. A duplicate is permanent in the log, so every later replay re-reads it and would re-log it, and there is nothing to act on either way. - Shared fixtures and real-primitive tests for a second `hook_disposed` and a `hook_created` behind a disposal, so both hook classes are pinned on the runtime and observability sides. - Docs: correct the `hook_conflict` bullet (a conflict can follow a creation under a different token claim; its consumer absorbs repeats), state the hook write-path behavior, match the logging paragraph to the code, link `attr_set`, drop the now-wrong duplicate-completion scenario from `corrupted-event-log`, and unify "recovery budget". - Tighten the `HookOptions.token` tsdoc and the hooks Token Design note. --- .changeset/duplicate-event-log-level.md | 7 +++ .../docs/v5/errors/corrupted-event-log.mdx | 9 ++- .../docs/v5/errors/replay-divergence.mdx | 2 +- docs/content/docs/v5/foundations/hooks.mdx | 2 +- .../docs/v5/how-it-works/event-sourcing.mdx | 10 +-- packages/core/src/create-hook.ts | 13 ++-- packages/core/src/duplicate-events.test.ts | 61 +++++++++++++++++++ packages/core/src/workflow.ts | 26 +++++--- .../test-support/duplicate-event-fixtures.ts | 20 ++++++ 9 files changed, 121 insertions(+), 29 deletions(-) create mode 100644 .changeset/duplicate-event-log-level.md diff --git a/.changeset/duplicate-event-log-level.md b/.changeset/duplicate-event-log-level.md new file mode 100644 index 0000000000..663193260e --- /dev/null +++ b/.changeset/duplicate-event-log-level.md @@ -0,0 +1,7 @@ +--- +'@workflow/core': patch +'@workflow/world': patch +'workflow': patch +--- + +Log ignored duplicate events at `debug` instead of `info`/`error`, so a straggler no longer prints on every replay of the run. diff --git a/docs/content/docs/v5/errors/corrupted-event-log.mdx b/docs/content/docs/v5/errors/corrupted-event-log.mdx index f8d31bdb05..d57f6b53f7 100644 --- a/docs/content/docs/v5/errors/corrupted-event-log.mdx +++ b/docs/content/docs/v5/errors/corrupted-event-log.mdx @@ -2,14 +2,14 @@ title: corrupted-event-log description: The workflow's event log contains an event that no consumer can process, indicating corruption or invalid state. type: troubleshooting -summary: Resolve corrupted event log errors caused by duplicate or orphaned events. +summary: Resolve corrupted event log errors caused by orphaned or unattributable events. prerequisites: - /docs/foundations/workflows-and-steps related: - /docs/foundations/errors-and-retries --- -This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as duplicate or orphaned events, or that a runtime determinism bug persists across retry attempts. +This error occurs when the Workflow runtime repeatedly cannot replay events in the event log. This usually means the event log is in an invalid state, such as an orphaned event or one no consumer can attribute to anything the workflow did, or that a runtime determinism bug persists across retry attempts. This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover. @@ -29,10 +29,9 @@ Before failing, the runtime retries a divergent replay and surfaces this termina Common scenarios that produce this error: -1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, and the second resolves something already resolved, so no later consumer can claim it. +1. **An unclaimed event that repeats nothing** — A duplicate of a kind the log already records for that entity is read past rather than failing the run, so a second `step_completed` or `wait_completed` is not this error (see [Duplicate Events](/docs/how-it-works/event-sourcing#duplicate-events)). What fails is an unclaimed event with no earlier counterpart to defer to: a `step_started` behind a `step_completed` on a log that never recorded a `step_started`, for instance. No consumer remains for the step, and there is no earlier event of that kind the replay could be reading instead. 2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code, so the replay reaches its end still holding it. -3. **Events after terminal state** — An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`). -4. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check). +3. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check). ## What To Do diff --git a/docs/content/docs/v5/errors/replay-divergence.mdx b/docs/content/docs/v5/errors/replay-divergence.mdx index 0fadb48e48..5b24d86bd6 100644 --- a/docs/content/docs/v5/errors/replay-divergence.mdx +++ b/docs/content/docs/v5/errors/replay-divergence.mdx @@ -20,7 +20,7 @@ A single divergent replay does not prove that persisted history is corrupted. Fo The runtime automatically queues another replay when an invocation reports `REPLAY_DIVERGENCE`. No terminal `run_failed` event is written during these recovery attempts. -If recovery replays continue to diverge after the retry budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis. +If recovery replays continue to diverge after the recovery budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis. ## What To Do diff --git a/docs/content/docs/v5/foundations/hooks.mdx b/docs/content/docs/v5/foundations/hooks.mdx index eda54f8e6a..531eaa29ef 100644 --- a/docs/content/docs/v5/foundations/hooks.mdx +++ b/docs/content/docs/v5/foundations/hooks.mdx @@ -480,7 +480,7 @@ This pattern is especially valuable in larger applications where the workflow an ### Token Design -Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens A generated token is not trivial to guess, but it is not a strong security contract either - which allows attackers to invoke unintended webhook resumptions. To completely prevent unauthenticated run resumptions, prefer using **hook** over the **webhook** convenience and implement your own authentication on the route that calls `resumeHook`. +Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens. A generated token is not trivial to guess, but it is not a strong security contract either, so anyone who obtains the URL can invoke an unintended webhook resumption. To prevent unauthenticated run resumptions entirely, prefer a **hook** over the **webhook** convenience and implement your own authentication on the route that calls `resumeHook()`. When using custom tokens with `createHook()`: diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 994234616f..14c3bcf321 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -249,17 +249,19 @@ To keep an inert copy from failing an otherwise healthy run, the runtime groups Types that share a class are the mutually exclusive outcomes of one decision: a step either completes or fails, and the first outcome recorded is the one that counts. Classes are independent of one another, so passing over one does not suppress another. A step whose result is already in the log has still recorded exactly one `step_created`, which is what makes a second one ignorable on its own terms. +The two hook classes cover the same shape of duplicate, and replay reaches them less often because the write path resolves most hook duplicates before they reach the log: a run re-creating a hook it already owns converges on the existing `hook_created` rather than appending a second one, and a second `hook_disposed` for the same hook is refused as an idempotent no-op. A log that holds either anyway is read past like any other repeat. + The remaining event types belong to no class and are never skipped: - `hook_received`: a hook legitimately receives many payloads under one ID, so a second `hook_received` is not a repeat of anything. -- `hook_conflict`: records a failed acquisition of the hook's token. Acquisition is idempotent — a run re-creating a hook it already owns converges on the existing `hook_created` rather than conflicting — so a `hook_conflict` cannot follow a `hook_created` for the same hook. -- `attr_set` is written on every attribute write. +- `hook_conflict`: records a failed acquisition of a hook's token, which the same run can hit repeatedly over its lifetime as other runs take and release that token. The hook's own consumer stays registered and claims every copy it is offered, so a repeat is consumed rather than reaching the class check. +- `attr_set`: written on every [`setAttributes()`](/docs/api-reference/workflow/set-attributes) call, so a second write of the same key is a new fact rather than a repeat. - `run_created` precedes every replay and is always consumed. - `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them. -Duplicates are visible in the run's logs. A repeat of the same event type is logged at `info`. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — is logged at `error`. Ignoring it is still correct and still deterministic, but unlike a re-commit of the same outcome there is no reading in which both writers were right, so the discarded outcome is worth surfacing. +Both kinds of skip are logged at `debug`, so neither reaches the console unless you run with `DEBUG=workflow:runtime:*`. A duplicate is a permanent feature of the log: every later replay re-reads it and lands on the same check, so anything printed unconditionally would print once per replay for the life of the run, and there is nothing to act on either way. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — gets its own message, because unlike a re-commit of the same outcome there is no reading in which both writers were right. -The observability UI greys out the events it can identify this way, which is a narrower set than the runtime's. It reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log. +The observability UI greys out the events it can identify this way, with the reason on hover. Its set is narrower than the runtime's: it reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log. ## Event Correlation diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index 5daad41420..dce83c4b2d 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -120,15 +120,10 @@ export interface HookOptions { * server-side `resumeHook()` only. For webhooks (`createWebhook()`), an * explicit token is not accepted — one is always generated for you. * - * Generated webhook tokens are drawn from the run's deterministic sequence, - * seeded on the run ID, the workflow name, and the deployment ID. The - * resulting tokens are not trivial to guess, but should not be treated as - * secret. This is done so URLs stay - * stable across replays and across concurrent invocations of the same run. - * - * We recommend authenticating webhook requests themselves — a signature - * header, a shared secret, or an auth check inside the handler — rather - * than relying on URL secrecy alone. + * A generated token is not trivial to guess but is not a security + * contract, so authenticate webhook requests themselves rather than + * relying on URL secrecy: + * https://workflow-sdk.dev/docs/foundations/hooks#token-design * * If provided, the token must be a non-empty string; passing an empty * string throws. If not provided (or `undefined`), a token is generated diff --git a/packages/core/src/duplicate-events.test.ts b/packages/core/src/duplicate-events.test.ts index 5466a386dc..6224b4dbfc 100644 --- a/packages/core/src/duplicate-events.test.ts +++ b/packages/core/src/duplicate-events.test.ts @@ -8,6 +8,7 @@ import { runWithDiscontinuation, setupWorkflowContext, } from './test-support/orchestrator-context.js'; +import { createCreateHook } from './workflow/hook.js'; import { createSleep } from './workflow/sleep.js'; /** @@ -120,6 +121,66 @@ describe('events repeating a class already in the log', () => { expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'wait_created'); }); + it('ignores a second hook_disposed for a hook already disposed', async () => { + const onDuplicateEvent = vi.fn(); + const events = [ + event(0, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }), + event(1, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }), + // Both writers of a concurrent teardown reached the dispose. The write + // path rejects the second one, so a log holding it is rare, and this is + // what replay does when it holds one anyway. + event(2, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }), + event(3, 'step_created', `step_${CORR_IDS[1]}`, { + stepName: 'afterHook', + }), + ]; + const ctx = setupWorkflowContext(events, { onDuplicateEvent }); + const createHook = createCreateHook(ctx); + const useStep = createUseStep(ctx); + + const { error } = await runWithDiscontinuation(ctx, async () => { + const hook = createHook({ token: 'a-token' }); + hook.dispose(); + await useStep('afterHook')(); + return 'done'; + }); + + expect(WorkflowSuspension.is(error)).toBe(true); + expect(pendingStepNames(ctx)).toEqual(['afterHook']); + expect(onDuplicateEvent).toHaveBeenCalledTimes(1); + expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'hook_disposed'); + }); + + it('ignores a hook_created that lands after the hook was disposed', async () => { + const onDuplicateEvent = vi.fn(); + const events = [ + event(0, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }), + event(1, 'hook_disposed', `hook_${CORR_IDS[0]}`, { token: 'a-token' }), + // A replay working from a prefix that predates the disposal re-created + // the hook. Creation has a class of its own, so this is read past rather + // than reviving a hook whose scope already exited. + event(2, 'hook_created', `hook_${CORR_IDS[0]}`, { token: 'a-token' }), + event(3, 'step_created', `step_${CORR_IDS[1]}`, { + stepName: 'afterHook', + }), + ]; + const ctx = setupWorkflowContext(events, { onDuplicateEvent }); + const createHook = createCreateHook(ctx); + const useStep = createUseStep(ctx); + + const { error } = await runWithDiscontinuation(ctx, async () => { + const hook = createHook({ token: 'a-token' }); + hook.dispose(); + await useStep('afterHook')(); + return 'done'; + }); + + expect(WorkflowSuspension.is(error)).toBe(true); + expect(pendingStepNames(ctx)).toEqual(['afterHook']); + expect(onDuplicateEvent).toHaveBeenCalledTimes(1); + expect(onDuplicateEvent).toHaveBeenCalledWith(events[2], 'hook_created'); + }); + it('still reports divergence for an event repeating nothing in the log', async () => { const result = await dehydrate('a-result'); const onDuplicateEvent = vi.fn(); diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 1ceca96e0d..98b8728269 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -429,6 +429,14 @@ async function createWorkflowSession({ ) ); }, + // Both branches log at `debug`, so neither reaches the console unless + // `DEBUG` matches. A duplicate is a permanent feature of the log: every + // later replay re-reads it and lands here again, so anything the logger + // prints unconditionally would print once per replay for the life of the + // run. There is also nothing to act on. Ignoring the event is the correct + // outcome, not a degraded one, and no user change makes the straggler go + // away. What the levels are for is diagnosis after the fact, which is + // exactly what `DEBUG=workflow:runtime:debug` is for. onDuplicateEvent: (event, firstEventType) => { const details = { workflowRunId: workflowRun.runId, @@ -443,19 +451,19 @@ async function createWorkflowSession({ // is still correct and still deterministic (replay reads the first one // at the same position every time), but unlike a re-commit of the same // outcome there is no reading of this where both writers were right, - // so the discarded outcome is worth an error in the run's logs. - runtimeLogger.error( - 'Ignoring event that decides an already-decided outcome differently', + // so the discarded outcome gets its own message. + runtimeLogger.debug( + 'Ignoring inert event that decides an already-decided outcome differently', details ); return; } - // Not an error: the first event of this class decided the outcome at a - // lower log position and replay reads that one. Logged because a - // straggler is still evidence of two replays writing for the same - // entity, which is worth seeing when diagnosing a run. - runtimeLogger.info( - 'Ignoring event that repeats a class already in the event log', + // The first event of this class decided the outcome at a lower log + // position and replay reads that one. Recorded because a straggler is + // still evidence of two replays writing for the same entity, which is + // worth seeing when diagnosing a run. + runtimeLogger.debug( + 'Ignoring inert event that repeats a class already in the event log', details ); }, diff --git a/packages/world/src/test-support/duplicate-event-fixtures.ts b/packages/world/src/test-support/duplicate-event-fixtures.ts index dbe6947b16..ea77f1deaf 100644 --- a/packages/world/src/test-support/duplicate-event-fixtures.ts +++ b/packages/world/src/test-support/duplicate-event-fixtures.ts @@ -131,6 +131,26 @@ export const DUPLICATE_EVENT_FIXTURES: readonly DuplicateEventFixture[] = [ ], ignoredIndices: [], }, + { + name: 'second disposal of one hook', + why: 'A hook closes on disposal, and both writers of a concurrent teardown can reach it. The write path rejects the second one, so the class entry is what covers a log that holds it anyway.', + events: [ + { eventType: 'hook_created', entity: 'hook_a' }, + { eventType: 'hook_disposed', entity: 'hook_a' }, + { eventType: 'hook_disposed', entity: 'hook_a' }, + ], + ignoredIndices: [2], + }, + { + name: 'hook recreated after it was disposed', + why: 'Creation has a class of its own, so a stale replay re-creating a hook whose scope already exited is read past rather than treated as a new hook.', + events: [ + { eventType: 'hook_created', entity: 'hook_a' }, + { eventType: 'hook_disposed', entity: 'hook_a' }, + { eventType: 'hook_created', entity: 'hook_a' }, + ], + ignoredIndices: [2], + }, { name: 'two steps in flight', why: 'Classes are tracked per entity, so sibling steps running the same shape never collide.',