From 4fd537fd099f5fb48dc860c2b73c3cdc50da4786 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:08:53 +0000 Subject: [PATCH] [docs] Document hookForceClaim for World authors and spec version 8 Follow-up to #4193: add the hookForceClaim capability and its contract to the World author guides, update the default minted spec version to 8, note WORKFLOW_SEALED_LOG=0's effect on hook takeover, list Hook.claimedFrom in the storage reference, and point defineHook().create() at experimental_force. Co-Authored-By: Claude Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> --- .../docs/v5/api-reference/workflow-runtime/world/storage.mdx | 1 + docs/content/docs/v5/api-reference/workflow/define-hook.mdx | 2 ++ docs/content/docs/v5/configuration/runtime-tuning.mdx | 1 + docs/content/worlds/v5/building-a-world.mdx | 5 +++-- docs/content/worlds/v5/upgrading-to-v5.mdx | 3 ++- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx index 3257bc5f06..be496cd7eb 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx @@ -359,6 +359,7 @@ const result = await world.hooks.list({ // [!code highlight] | `environment` | `string` | Deployment environment | | `metadata` | `object` | Custom metadata attached to the hook | | `isWebhook` | `boolean` | Whether this is a webhook-style hook | +| `claimedFrom` | `{ runId: string; hookId: string } \| undefined` | Set when this hook took its token from another run with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). Names that run and its hook | --- diff --git a/docs/content/docs/v5/api-reference/workflow/define-hook.mdx b/docs/content/docs/v5/api-reference/workflow/define-hook.mdx index aa1feb5e20..cb1617173e 100644 --- a/docs/content/docs/v5/api-reference/workflow/define-hook.mdx +++ b/docs/content/docs/v5/api-reference/workflow/define-hook.mdx @@ -211,6 +211,8 @@ export async function slackBotWorkflow(channelId: string) { } ``` +`create()` accepts the same options as `createHook()`. If a newer run should replace one that still holds the token, pass [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) to take the token over instead of getting [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error). + ## Related functions - [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow. diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 57e329b655..0ee3fa0a96 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -129,6 +129,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap. - The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow or advancing the deterministic clock. Its timestamp belongs to whichever reader sealed it, not to the run. - Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems. +- Setting `0` also stamps new runs below spec version 8, so another run can't take their hook tokens over with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). A forced hook in another run gets `HookConflictError` instead. Runs created with `0` can still take tokens over from others. - Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting. - A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That includes every runtime on this release train, but a runtime that pins its own accepted spec range separately, such as the Python runtime, has to catch up before it can read these runs. Switch this off in an environment where it has not. - Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec. diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index f30797f7c4..b4f5ac1cc7 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -42,6 +42,7 @@ interface WorldCapabilities { active: boolean; }; maxConcurrency?: boolean; + hookForceClaim?: boolean; } interface World extends Storage, Queue, Streamer { @@ -60,7 +61,7 @@ interface World extends Storage, Queue, Streamer { `specVersion` is required. See [Declaring the spec version](#declaring-the-spec-version). -The optional `capabilities` object advertises additional behavior, and every capability **fails closed**. A missing member means "unsupported," and the runtime keeps its conservative behavior. Set `hookRetention.active` to `true` only when the World implements hook token retention. Set `maxConcurrency` only when the World's queue supports `maxConcurrency`-limited consumption, which `WORKFLOW_SEQUENTIAL_REPLAYS=1` uses. +The optional `capabilities` object advertises additional behavior, and every capability **fails closed**. A missing member means "unsupported," and the runtime keeps its conservative behavior. Set `hookRetention.active` to `true` only when the World implements hook token retention. Set `maxConcurrency` only when the World's queue supports `maxConcurrency`-limited consumption, which `WORKFLOW_SEQUENTIAL_REPLAYS=1` uses. Set `hookForceClaim` only when the World implements [hook token takeover](/worlds/upgrading-to-v5#new-optional-surface). Without it, `createHook({ experimental_force: true })` fails the workflow when it registers the hook. [Slot-numbered event IDs](#event-id-allocation) are a contract requirement rather than a capability, so there is no flag or fallback path. @@ -195,7 +196,7 @@ Spec version 7 supports one alternative to allocate-at-commit for Worlds whose s The runtime skips `noop` events during replay. It never delivers them to a consumer or uses them to advance the deterministic clock, so a sealed log replays identically to one whose writers filled the holes. `noop` isn't user-creatable and is never sent to `events.create()`. Only your read path may write one. Worlds that allocate at the commit, through a synchronous counter or unique-constraint append, maintain perfect density and don't need sealing. `world-local` and `world-postgres` never seal, and a World that allocates at the commit is spec 7 compliant without additional work. -The version a World stamps comes from `mintedSpecVersion()`: 7 by default or the slot-identity version when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) disables it. Declare `mintedSpecVersion()` instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand `noop` before anything stamps 7 in that environment. +The version a World stamps comes from `mintedSpecVersion()`: 8 by default or the slot-identity version (6) when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) disables it. Declare `mintedSpecVersion()` instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand `noop` before anything stamps 7 in that environment. `events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt `eventCount + 1`. When that position is taken, **do not reject the write**. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in `events` with a matching `cursor` and `hasMore`. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel. diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx index a71e2c26ec..3b5c766a78 100644 --- a/docs/content/worlds/v5/upgrading-to-v5.mdx +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -115,7 +115,7 @@ None of this is required. Each entry is a hook the runtime uses if your World pr | Member | What it buys | | --- | --- | -| `capabilities` | Advertises `hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, and `maxConcurrency`. See the contract note above about failing closed. Event ID allocation is *not* in here: it is a requirement, not a capability. | +| `capabilities` | Advertises `hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, and `maxConcurrency`. See the contract note above about failing closed. Event ID allocation is *not* in here: it is a requirement, not a capability. | | `analytics` | A metadata-only read namespace for observability surfaces. Payload-bearing reads stay on `runs`, `steps`, `events`, and `hooks`. | | `runs.experimentalSetAttributes` | Backs `setAttributes()` from application code. Without it, run attributes are unavailable. | | `runs.cancelMany` | Bulk cancellation: up to 500 unique run IDs per request (`BULK_CANCEL_MAX_RUN_IDS`), an optional `cancelReason` of at most 512 characters, and a per-run outcome for every ID. Without it, the runtime falls back to bounded-concurrency individual cancels. | @@ -129,6 +129,7 @@ None of this is required. Each entry is a hook the runtime uses if your World pr | `streams.streamFlushIntervalMs` | Sets the stream flush window. The v5 default is `0`, so the first chunk flushes immediately; set a value to coalesce writes again. | | Hook token retention | `Hook.tokenRetentionUntil` marks the earliest time a token may become available after its run ends. Keep the owning run readable at least that long, and honor `hook_disposed` as an immediate release. Declare `capabilities.hookRetention.active` only once this is implemented, since the runtime otherwise rejects retained hooks before registration. | | Hook resume dedup | `resumeHook()` writes `hook_received` and dispatches the queue message in parallel when the backend collapses concurrent writes carrying the same `(runId, resumeId)` onto one committed event. Declare `capabilities.hookResumeDedup` only if you enforce that constraint **and** `events.list()` returns the committed event's top-level `resumeId`. A World that omits either guarantee must leave the flag unset, which keeps the sequential path. See [Durable hook resume](/docs/changelog/lazy-hook-resume). | +| Hook token takeover | Backs [`createHook({ experimental_force: true })`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). When a forced `hook_created` names a token another live run holds, append `hook_disposed` with `forceClaimedBy: { runId, hookId }` to the holder's log and refuse later `hook_received` writes to it, then re-point the token to the claimer and record `Hook.claimedFrom`. Refuse the takeover with an ordinary `hook_conflict` carrying `forceRefusedReason: 'victim-spec-version'` when the holder was stamped below spec version 8, and answer a `hook_received` refused by a takeover with `HookForceClaimedError` so `resumeHook()` can follow the token. Declare `capabilities.hookForceClaim` only once you give these guarantees. Without it, the runtime fails a forced `createHook()` at registration. | ## If you also maintain a build integration