`runtime.ts` computes a source-map-remapped `errorStack` when a run fails and
passes it to a log whose framing line — "Error while running workflow" — has
no body of its own. `composeLogLine` drops `errorStack` unconditionally, on
the assumption (true for the step executor and the combined runtime, which
render `${framing}\n${stack}`, false here) that the message already carries
it. The stack never reaches the console; #4021 noted this at the call site
and worked around it by adding `errorMessage`, but the stack itself is still
discarded.
- `composeLogLine` promotes the `errorStack` field into the stack body when
the message carries no body of its own, so it goes through the same frame
trimming and is still never duplicated when the caller did embed one. This
also lets #4021's existing `body.includes(errorMessage)` check suppress the
now-redundant `error` row at this call site.
- The header row is skipped when it would be a lone class name that the
promoted stack header already states. A badge still always renders —
attribution is the one thing the stack cannot express — so the step
executor and combined-runtime sites are untouched, as is a stack naming a
different class than `errorName`.
Adds a runtime-level regression test that drives a throwing workflow through
`workflowEntrypoint` and asserts the emitted `console.error` line carries the
message and a stack frame, plus formatter unit tests for the promote,
don't-duplicate, badge and different-class cases. No existing snapshot
changes: the composition is identical everywhere except the run-failure log.
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
The changeset body is copied verbatim into the changelog, where the
rationale paragraphs from #4070 are noise. Keep the line that says what
changed and where it matters; the reasoning is already in the commit,
the PR and issue #3991.
This was reviewed on #4070 and fixed on #4140, a CI mirror of that PR.
#4070 is the one that merged and #4140 was closed, so the fix never
landed. The changeset is still unconsumed on `main`, so correcting the
file is enough — the open release PR regenerates from it and no
CHANGELOG.md has been written yet.
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
A workflow name is derived from the module path it is defined in, so Next.js
App Router conventions end up in the name verbatim. `SAFE_WORKFLOW_NAME_PATTERN`
permitted alphanumerics, `_`, `-`, `.`, `/` and `@`, but not parentheses or
square brackets. Any workflow inside a route group (`app/(dashboard)/...`) or a
dynamic segment (`app/[teamId]/...`, `app/[...slug]/...`) threw
Invalid workflow name "workflow//./app/(group)/workflows/": must only
contain alphanumeric characters, ...
before it could be enqueued, and the generated name cannot be overridden.
The pattern exists to keep unsafe characters out of the queue name it is
interpolated into. These four are inert there: `ValidQueueName` accepts any
suffix after its prefix, and the only other consumers of the name are OpenTelemetry
span names. It is never placed in a URL or a SQL identifier.
Fixes#3991
Signed-off-by: Matias Torsello <23641125+torsello@users.noreply.github.com>
Co-authored-by: Matias Torsello <23641125+torsello@users.noreply.github.com>
Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>
* Carry immutable run identity on step-dispatch messages; drop the blocking runs.get from the consumer prologue
Closes#3456. Every queued step execution paid a runs.get round trip
before its step_started claim — one RTT per branch on the TTLS-critical
path, and under a 256-branch fan-out burst the read amplification drove
that read to p90 ~5.1s (durabench parallel sweeps), smearing branch
starts.
The dispatch sites (node dispatch loop, delayed retries, the suspension
handler's resilient publish, and the quickjs engine's queueStepMessage)
now stamp WorkflowInvokePayload.runContext with the fields the consumer
actually needs — deploymentId, specVersion, startedAt, rootRunId — all
immutable for the life of a run and known from the run row the producer
already holds. A consumer that receives it skips the run fetch: the
run-status early exit is enforced by the step_started claim itself
(RunExpired → gone, terminal step → skipped), guardDeployment takes the
carried identity, and only the fan-out's LAST completer fetches the full
run row, lazily, for its inline replay — once per fan-out instead of
once per branch. The deployment-mismatch re-route now also preserves
stepInput/runContext on the re-enqueued payload.
Messages without runContext (older producers) keep the legacy prologue;
messages are deployment-pinned, so mixed handling within one run cannot
occur.
* Address review: terminal-only lazy status gate, terminal-run start fence in local worlds, prologue telemetry, last-completer coverage
- The last completer's lazy runs.get result is now gated on
isTerminalWorkflowRunStatus (with a debug log): a stale 'pending' read
— a run with completed steps has necessarily started — no longer
silently abandons the fan-out's continuation; it falls through to the
inline replay, whose next entity write is fenced server-side if the
run truly ended meanwhile.
- world-local / world-postgres now reject step_started on terminal runs
even when the step row still reads 'running' (a redelivered start a
previous delivery claimed): starting work on a finished run is never
valid, and previously the body re-ran with its outcome unconsumable.
In-flight steps still write their terminal events unchanged. This
closes the adapter gap behind the fetch-free prologue's reliance on
the step_started claim as the run-liveness check, and the prologue
comment now states the contract precisely.
- workflow.step.dispatch_prologue span attribute ('run_context' |
'runs_get') makes fetch-free adoption and the saved round trip
observable during version-skew windows.
- Restated why the eager redelivery re-ensure survives on the
fetch-free path (no run fetch to overlap; still cheaper than the
in-band recovery's failed-start round trip).
- New two-phase fan-out coverage: a real replay emits the queued step
message (asserting the stamped runContext), then its redelivery runs
as the LAST completer — zero reads before the step, exactly one lazy
runs.get, run completed; plus the stale-'pending' fall-through and
the genuinely-terminal skip.
* perf(core): commit pre-claimed inline pairs in their own batch chunk
The batched fan-out fold sorted the pre-claimed inline
[step_created, step_started] pairs to the front and then filled the same
chunk with plain step/wait creates up to MAX_BATCH_FANOUT_EVENTS. Only
that chunk gates the inline bodies, so the bodies waited on a 32-row
commit (a 61-item DynamoDB transaction on the Vercel backend) when all
they needed was the pairs' own rows.
Chunk the pair rows and the plain creates separately: the pairs get their
own leading chunk(s), still adjacent and never split across a boundary,
and the plain creates fill the subsequent chunks of 32, committing
concurrently beside the pair chunk and gating only their own queue
publishes. Eligibility, the batch-of-one rule, pairCommits gating,
per-chunk publishes and the foreign-interleaving diagnostic are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(core): fold pairs only with two inline steps; guard a lone plain create
With the pairs in a chunk of their own, plain creates alongside them share
no round trip with the pairs, so "company" no longer makes a lone pair
worth folding. `inlinePairFoldEligible` now requires two or more inline
steps: a lone inline step keeps the lazy `step_started` (one row,
optimistic-start capable, bump-and-report) while the eager creates beside
it still batch.
A plain partition of exactly one entry is the batch-of-one case again, so
it takes the guarded single create (slot-snapshot params, bump-and-report,
same conflict tolerance and `createdStepCorrelationIds` bookkeeping)
instead of a one-row createBatch, and its queue publish still waits for
that create.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
## Summary & Motivation
Job conclusions hide tests that pass on retry, and the evidence only lived in overwritten PR comments and 7-day artifacts, so recurring flakes couldn't be ranked against how often they ran.
- retry sidecars are now named after the Vitest report they came from, so lanes, VMs, and worlds no longer overwrite each other when artifacts merge — and the aggregate comment can attribute a flake to an exact lane
- `generate-e2e-flake-history.js` publishes a bounded 30-run history to gh-pages: one series per (lane, app, world, vm, platform) × test, carrying both an executed count and a passed-on-retry count so a rate has a denominator
- the reporter always writes the sidecar, which is what lets a clean run be distinguished from a lane that reported no retry telemetry at all
## Test Plan
Unit tests added for dimension parsing, denominators, schema validation, and the 30-run window; verified every current report filename maps to explicit dimensions, and a one-run history built from a full artifact set came out around 100 KiB.
On a fan-out that runs some steps inline, the orchestrator invocation
publishes the queued siblings' step-execution messages, runs its inline
steps, falls back into the replay loop, reloads the log, and its
pending-step dispatch pass publishes every one of those messages again
(measured 19-28 re-sends per pass on a 32-branch fan-out, ~400 ms after
the originals). The queue dedupes them by idempotency key, but the sends
still cost round-trips on the shared connection pool and hold the
invocation open past its useful work.
Track, per delivery, the correlation ids this invocation has already
published a step message for (the suspension handler's resilient
publishes plus the dispatch pass's own immediate enqueues) and skip the
immediate re-enqueue for those on later passes, unless a step_retrying
has been observed since (a new schedule). The set is invocation-scoped
and never derived from the log: a step_created does not prove the message
was ever sent, so a different delivery still re-enqueues unconditionally.
Reported as a debug log and the `workflow.dispatch.republish_skipped`
span attribute. The QuickJS engine already keeps the equivalent
invocation-scoped `queuedStepIds` set, so it is unchanged.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The only thing a World does with `eventCount` is bump-and-report: when the
write lands above the position named, it reads the events in between and
returns them so the writer can merge them without a second round-trip. The
replay loop and the suspension handler merge that page into their loaded
log. The step executor has no log to merge into, so it took the page's
highest position and discarded the rest.
In production that discarded read fell on a third of all `step_started`
writes (10.8M of 13.4M skipped-slot report reads per day were on executor
event types), each a strongly consistent DynamoDB query on the run
partition with resolved refs, on the response path. This removes the
executor's `knownSlot` / `observeSlot` machinery, the `slotSnapshot`
executor param, and the `batchCommittedSlotCeiling` the suspension handler
computed only to seed it. The loop's and the suspension handler's own
snapshots are unchanged; they consume their reports.
The World contract already describes omitting the count for a caller with
no loaded log to be stale against; the executor now matches it.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* perf(world-vercel): batch a fan-out's step-execution queue publishes
A `Promise.all` fan-out dispatched one queue message per branch. Those
publishes ride the shared default undici agent (8 connections, HTTP/1.1,
`pipelining: 1` — see `getQueueDispatcher`), and `handleSuspension` is
awaited in full before the first inline step body runs, so an N-branch
fan-out paid ~N/8 serialized round trips straight onto time-to-first-step.
The `step_created` writes were already batched and HTTP/2-multiplexed; the
publishes were the remaining per-branch round trip.
Adds an optional `Queue.queueBatch`, implemented on `@vercel/queue`'s
`experimental_sendBatch` (0.5.1), and uses it for the batched fan-out fold's
publishes. Each commit chunk now publishes in one request instead of up to
32.
`queueBatch` reports per-entry outcomes rather than throwing, because a
batch can partially fail. `queueMessages` in core keeps the previous
all-or-nothing behavior for this call site: it rejects if any entry failed,
so the delivery is redelivered and republishes the set, deduped by the
per-step `idempotencyKey` the caller already passed. Worlds without
`queueBatch` fall back to concurrent single sends.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(core): reject a short queueBatch result set instead of reading it as success
`queueMessages` only inspected `error`, so a World whose `queueBatch`
returned fewer results than it was given messages reported success for the
whole batch. The omitted entries were never published and nothing raised:
`handleSuspension` resolved, the delivery was acked, and those steps were
never dispatched, so the run stalls with no error recorded anywhere.
Reproduced at 64 branches against a World returning half its results: 32 of
63 steps silently lost.
world-vercel guards this internally and `@vercel/queue` length-checks its
own response, so it was not reachable through the world added here. It is
reachable through the interface `building-a-world` opens to third-party
worlds, which is where the check belongs. Documented on the interface and
in the guide alongside it.
Also notes that the batch grouping degenerates to one request per message
under WORKFLOW_SEQUENTIAL_REPLAYS=1 (per-step physical topics are one of
the routing dimensions groups split on), and corrects the comment claiming
the error's `retryable` flag is consumed downstream: nothing reads it yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(world-vercel): carry trace context on each batched queue message
`experimental_sendBatch` injects the active trace context into the multipart
REQUEST headers, and the per-part headers it builds never see it. VQS stores
headers per message and re-emits a stored `traceparent` at delivery as
`x-vercel-queue-traceparent`, which is what lets a consumer attach a span link
back to its producer, so a batched message arrived with no producer context
and its `vqs.process` span got no link. `send()` is unaffected: for a single
message the request headers ARE that message's headers.
At 64 branches that was 63 of 64 step dispatches losing the transport-level
producer link. The run's own step tracing was never affected: that carrier
travels in the message payload (`WorkflowInvokePayload.traceCarrier`), which
is what the consumer builds its trace context from, not a header.
Injects the active context into each entry's headers in `queueBatch` — last,
so it wins over caller-supplied `opts.headers` exactly as the SDK's own
injection does — and honors VERCEL_QUEUE_TRACE_PROPAGATION so that kill
switch still covers both paths. `getTraceContextHeaders()` is factored out of
`injectTraceContextIntoHeaders` so the two share one source.
Verified on the wire against a stub VQS speaking the real batch endpoint:
`traceparent` carrying the producer's traceId/spanId lands on all 64
multipart parts through the real SDK, with the per-message idempotency keys
still alongside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>
## Summary & Motivation
- Manifest coverage now declares an entry for every matrix app, uses real Vitest skips instead of silent early returns, and fails when a targeted app's manifest is missing, unknown, or unparseable.
- Retries are scoped to deployment e2e runs (`DEPLOYMENT_URL` set), so a flaky unit or integration test can no longer be hidden by a second attempt.
- The stop-workflow cookbook parks on a sleep between iterations, giving the hook an observable barrier to race instead of a fixed delay, and the AbortController hook test waits on queue state rather than a 10ms timer.
- The world-postgres direct-storage fixture drives its run to a terminal state so the conformance worker doesn't recover and replay an unregistered workflow.
- Generated e2e result sidecars are ignored and the committed copies removed; they're CI artifacts, not fixtures.
## Test Plan
Existing coverage runs in CI. With retries disabled: the cookbook agent suite passed 8/8, the two stop-workflow tests passed 10/10, and the AbortController hook replay test passed 25/25 under `CI=1`. The manifest suite skips 52 apps explicitly when nothing is built, and fails on unknown or missing targeted apps. The Docker-backed Postgres spec could not run locally (Testcontainers found no container runtime); `@workflow/world-postgres` typechecks.
## Summary & Motivation
Keeps WebSocket setup off the first stream group, then starts the background upgrade when the second HTTP request is dispatched. Later groups continue over HTTP without waiting until the socket is OPEN, when the serialized writer switches transports at a confirmed request boundary. One-group streams never create a socket.
An ambiguous HTTP outcome poisons the writer and retires any provisional socket before it can carry a frame. Initial upgrades have a dedicated 10-second background timeout; the existing 250ms bound remains scoped to reconnects after an established socket closes. WS write spans include chunk sequence and count for direct takeover analysis.
## Test Plan
Tests cover dispatch overlap, continued HTTP writes while connecting, one-group streams, close during background connection, ambiguous HTTP outcomes, timeout and late OPEN behavior, and trace propagation. The world-vercel suite and typecheck pass locally.
## Summary & Motivation
While the first socket is still connecting, complete groups go over HTTP instead of parking on the handshake; the socket takes over once it opens. An HTTP-first write that fails poisons the writer rather than falling back, since its outcome may be unknown and replaying it over WS could duplicate a group.
## Test Plan
Tests added for transport switching, ordering against close, and the poisoned-writer path; the world-vercel suite passes locally.
## Summary & Motivation
### Situation
- Workflow stream writers are expected to call `releaseLock()` when a step finishes writing so another step can acquire the stream.
- The runtime observes that release and drains the server sink, but `step_completed` currently races the overall stream operation against 500ms.
- A slow PUT can therefore continue under `waitUntil` while the next step starts and reads a stale tail.
- Release can also happen while native `writer.write()` promises remain unsettled, leaving frames upstream of the server sink when a naive drain runs.
- Writers intentionally kept locked must remain non-blocking so producer and consumer steps can overlap.
### Fix
- Treat a writer released before step return as an implicit durable handoff boundary.
- At step end, acquire the unlocked stream with a temporary writer and enqueue an internal checkpoint behind all writes queued by the released writer.
- Once the checkpoint crosses serialization, wait for those frames to reach the server sink and drain the group-commit PUT before `step_completed`.
- If the writer remains locked, do not wait for durability; preserve the existing 500ms inline-loop heuristic and background `waitUntil` lifecycle.
- Drain failures or the 30-second safety timeout fail/retry the step. Client disconnect errors remain non-fatal.
## Test Plan
- Covers released and held locks, release with unsettled writes, delayed first writer acquisition, forwarded writable arguments, drain timeout/failure, and multiple streams.
- `pnpm --filter @workflow/core build`
- `pnpm --filter @workflow/core typecheck`
- `pnpm --filter @workflow/core test` — 2,405 passed, 3 expected failures, 1 skipped
## Summary & Motivation
Adds three bounded trace attributes so a sampled trace says how a `step_started` claim was made: `workflow.step_start.strategy` on the step span (`awaited` / `optimistic` / `batch_preclaimed`, set before the write so a losing claim keeps it after its 409 reconciles to `skipped`), and `workflow.step_start.mode` plus `workflow.step_start.owner_stamped` on the world-vercel write spans across the http, batch, and ws paths. Purely additive telemetry — no change to execution behavior.
## Test Plan
Test added covering a stamped lazy claim's attributes on the per-write span; existing coverage runs in CI. Local run of 81 focused core and world-vercel tests passed; full core typecheck is blocked by unrelated workspace resolution issues.
## Summary & Motivation
Tags the first write of a session, and the first write on each reconnect, with phase timings on the existing `workflow.stream.write` span — token/config resolution, connect, wait-for-open, send, and ack round trip — so cold WebSocket write latency can be attributed to a client-side phase before changing first-chunk transport behavior. Later writes keep the two attributes they had, since the per-phase clocks only mean anything while a connection is being established.
## Test Plan
Unit tests added; existing world-vercel suite and typecheck pass.
## Summary & Motivation
Implements the client half of `workflow-stream-ws/v1` behind the existing default-off `WORKFLOW_STREAMS_TRANSPORT=ws` gate, populating the `createWriteSession` seam only when opted in.
- Writes and closes are serialized over one socket per writer lifetime; groups above the v1 per-request chunk cap are split without resetting writer-local sequence.
- Any failure before the upgrade is accepted (declined upgrade, proxy, load error, a dispatch that beats the handshake) falls back to HTTP for the rest of the writer's life.
- Once a write is on the socket, a missing or uncorrelatable reply poisons the session rather than replaying over HTTP, since a duplicate append cannot be ruled out.
- Idle clean closes reconnect with the same writer id, capped at three attempts so a draining server can't hot-loop.
- The handshake gets a `workflow.stream.ws.connect` span and each frame a synthesized `http POST` span, so per-event tracing survives the non-`fetch` transport.
## Test Plan
New unit tests cover the lifecycle, fallback, and poisoning paths; 598 `@workflow/world-vercel` tests plus package build/typecheck and workspace lint/format pass. Root build/typecheck couldn't run locally (missing Rust toolchain for the unrelated `@workflow/swc-plugin`).
* [core] Make `hook.metadata` a lazy Promise getter
Hydrating a hook's metadata is a decrypting READ: it needs the owning
run's payload keys, and resolving those costs a run fetch plus a
`run-key` API round trip (~350ms). `getHookByToken()` did that work
eagerly on every lookup that found a metadata-bearing hook, so callers
that only wanted `runId`/`token` — and hook resumption, which never
reads metadata at all — paid for it anyway.
`metadata` is now a getter returning a memoized Promise, the same shape
as `run.returnValue`. The lookup is one read again; hydration and the
key resolution behind it happen on first access, or never.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
* docs: surface the lazy hook.metadata change in What's new, the migration skill, and the resumeHook reference
Adds the breaking-change row to the v5 What's new page and puts that page
in the sidebar as the first visible entry (the /v5/docs redirect to
getting-started is unchanged). Teaches the migrating-workflow-v4-to-v5
skill the `await hook.metadata` rewrite and bumps its version. Points the
resumeHook reference at HookWithLazyMetadata, and notes on the World
storage page that world.hooks.getByToken() returns raw serialized
metadata.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* [core] Export the lazy-metadata hook type as `Hook` from `workflow/api`
`getHookByToken()` and `resumeHook()` return `Hook`, not a separate
`HookWithLazyMetadata`: one public hook type whose `metadata` is a lazy
Promise, mirroring `Run` for runs. The World-level record from
`@workflow/world` is unchanged and is referenced as `WorldHook` inside the
runtime.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* [core] Define the lazy `metadata` getter in place; tighten changeset and docs wording
Review feedback: the hook record a World returns is a fresh object per
lookup and the eager path mutated it anyway, so define the getter on it
directly instead of copying it with Object.create(). The changeset is one
sentence, and the docs describe hydration as extra network round trips
rather than decryption, since not every World encrypts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
## Summary & Motivation
Gives one in-memory stream writer a stable identity and its own sequence space, so a transport can preserve chunk ordering across a mid-stream HTTP/WebSocket transition. `Streamer.streams.createWriteSession` is optional — Worlds that don't implement it keep using `write`/`writeMulti`/`close` unchanged.
Abort disposes the session rather than closing it, since a producer failure is transport cleanup, not stream completion.
## Test Plan
Tests added, plus the full `@workflow/world-vercel` suite and package builds/typecheck pass. Root build/typecheck is blocked locally by a missing Rust toolchain for the unrelated `@workflow/swc-plugin`.
* fix(world-vercel): honor WORKFLOW_NODE_HTTP on the queue transport
getQueueDispatcher was the one dispatcher getter that ignored the flag. The
reasoning was that `undefined` cannot move the queue client onto node:http
(QueueClient takes a dispatcher and no fetch override), so returning it would
only drop this package's pool tuning and fall back to undici's global agent.
That misses what the flag is actually for. The deployments that need it are the
ones where the undici copy *this package bundles* is unusable, and `undefined`
does move the request off that copy: global fetch dispatches on the runtime's
own undici instead. On such a deployment every other request survives while the
queue client keeps dispatching through the broken copy, and an
acknowledgeMessage that never resolves means the message is redelivered for as
long as the platform keeps killing the invocation holding it.
Losing pool tuning is the correct trade under a flag whose premise is that the
bundled undici is not usable here. An explicit config.dispatcher still wins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct the queue-send note for WORKFLOW_NODE_HTTP
The queue client is a partial exception to the flag, not a full one: it cannot
move to node:http, but it does honor the flag by dispatching through the
runtime's own copy of the HTTP client library instead of the copy the World
bundles. That distinction is the whole point when the bundled copy is what does
not work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Summary & Motivation
`WORKFLOW_STREAMS_TRANSPORT=ws` advertises client support for `workflow-stream-ws/v1` on stream writes; anything else keeps HTTP. It's a capability signal only — the server decides each upgrade, so there's no version or tenant-policy heuristic on the client side.
## Test Plan
Unit tests for the gate's accepted values; typecheck, build, and lint pass locally.