Skip to content

[core] Check a restarted replay against the counts the world reported - #3267

Draft
VaguelySerious wants to merge 14 commits into
mainfrom
peter/event-count-guard-restart-evidence
Draft

[core] Check a restarted replay against the counts the world reported#3267
VaguelySerious wants to merge 14 commits into
mainfrom
peter/event-count-guard-restart-evidence

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Stacked on #3145.

#3145 gives an in-process replay restart a classification: after a stale-snapshot rejection it records whether the reload came back unchanged, grew, or shrank. That is a measurement, but the restart does nothing with it — it spends the next restart regardless, and a run whose reload cannot produce a different write burns the full budget before escalating.

This PR closes that loop using evidence the world already has.

A rejecting world may echo the comparison

A 412 can now carry the numbers it rejected on, on the error's details:

  • recordedAtOrBelow — how many events the world had recorded at or below the client's watermark
  • stateUpdatedAt — the watermark that count was taken at

Both or neither. decodePreconditionDetails in the v4 error path picks them up alongside the inline delta (each is decoded independently, so an unusable delta does not discard usable counts), and preconditionExpectation reads them back off PreconditionFailedError. Additive on both sides: a world that reports nothing leaves each reload unchecked, never unsatisfied, and a client shipped today already drops fields it does not know.

A restart that cannot progress escalates immediately

After a full cursor-less reload, the runtime counts the reloaded events at or below that watermark (countEventsAtOrBelow). A run's log only grows and the watermark is fixed, so if the reload has not reached recordedAtOrBelow, re-deriving the replay produces the same snapshot and earns the same rejection. Two cases short-circuit the remaining in-process restarts:

stalledReason meaning
reload-unchanged the authoritative reload returned the same log
reload-short-of-recorded it grew, but still holds fewer events at or below the watermark than the world recorded

Either one warns once (skipping further in-process restarts) and hands straight to the delayed re-invocation, which is a fresh invocation and possibly a different region. The escalation ladder is unchanged in shape — the bound is now a ceiling rather than a quota.

Deliberately narrow:

  • Only a full reload counts as evidence. A delta-fed restart proves nothing about the log as a whole — the world only proved its delta against the snapshot it rejected.
  • unchanged outranks short. An unchanged reload is the stronger statement, and it holds whether or not the world echoed anything.
  • Fail open throughout. Undecodable event ids count as at-or-below (lowering the bar the reload must clear); absent, partial, or out-of-range counts mean unchecked.

Tests

  • helpers.test.tscountEventsAtOrBelow (equality inclusive, empty log, undecodable id) and preconditionExpectation (both fields, zero accepted, one-of-two / negative / fractional / string / no details / wrong error type all null)
  • events-v4.test.ts — 412 counts surfaced with no delta attached; counts kept when the delta beside them is unusable; incomplete or out-of-range counts ignored
  • precondition-guard-replay.test.ts — the rejection harness can now grow its log per rejection and echo counts, which separates the two behaviours: a static log stops after one restart with reload-unchanged; a log that grows but stays short stops with reload-short-of-recorded; a log that reaches the echoed count keeps restarting to the bound, with every reload asserting satisfied and outcome: 'grew'

packages/core (1690) and packages/world-vercel (325) suites pass.

🤖 Generated with Claude Code

VaguelySerious and others added 12 commits July 27, 2026 19:04
…process

A replay-context event creation previously described its snapshot with a
single watermark, which only proves no event landed above it. It cannot
detect a *missing* event below it, so a replay working from a log with a
hole still committed events derived from that hole — and because
correlation IDs are positional ordinals of one seeded sequence, a
one-event difference renames every downstream entity and corrupts the log.

Creations now also send the snapshot's event count and its cursor, and a
rejection restarts the replay inside the same invocation instead of
re-posting the rejected payload (whose IDs the corrected log invalidates)
or paying a queue round trip. A world may attach the missing events to
its 412, in which case the first restart needs no event-log request.

Also guards the suspension `attr_set` write, and re-sorts a merged event
log by event ID when an append arrives out of order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also makes the v4 event tests derive their mock origin from the override
like the rest of the file already does, so a non-empty override does not
fail unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The matching world-vercel guard shipped and is live in production, so the e2e
lanes exercise both halves against the default endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the overlap with #3110, which introduced the same event-log merge
consolidation this branch had added as `mergeEvents`: `appendUniqueEvents`
now carries the optional id set from main plus the out-of-order re-sort and
warning, and `mergeEvents` is gone. Main's `withPreconditionRetry` edit drops
out with the function itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The event-log merge no longer re-sorts by event id. A World's canonical
order is its own: world-vercel orders by event id, but world-local orders
by (createdAt, eventId) and deliberately re-mints keys (dominant-event and
claim canonicalization) so the two diverge. Re-sorting by event id there
produced an order no ordered load would ever return, reordering a terminal
event ahead of an accepted hook and breaking concurrent hook-token
arbitration.

The merge was only sorting so the snapshot could read its watermark off the
tail, so read the maximum ULID time across the log instead. That removes
the ordering dependency entirely and is exact rather than merely safe:
every loaded event is at or below the maximum, so stateEventCount is still
events.length whatever order the World returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A 412's delta is untrusted data on a failure path, and the restart it feeds
has to reload the log in full whenever that delta cannot be proven to
complete it:

- Reject a delta carrying an event of another run. It is merged straight
  into the replay's log, so a foreign event there is a corrupt log rather
  than a corrected one.
- Reject a delta whose events carry a payload the JSON error body mangled.
  Payload fields are Uint8Array everywhere else in the client, but a 412
  body is JSON, so resolved bytes arrive as a Buffer-shaped object that
  EventSchema happily accepts and the runtime would hydrate garbage from.
- Bypass the delta at the inline step-claim site when a sibling claim in the
  same batch was accepted: that sibling wrote step events of its own,
  possibly after the World computed the delta.
- Report `source: 'full-reload'` when a delta parsed but there was no cached
  log to merge it into.

Beyond the delta:

- Settle every write in a suspension phase before a rejection escapes.
  Promise.all leaves siblings in flight, and a sibling create landing after
  a 412 escaped commits an event minted from the abandoned replay's
  correlation-id sequence while racing the restart's reload. A 412 is
  preferred over a sibling rejection because it has a cheap recovery.
- Bound the re-invocation chain per run on the queue message. A fresh
  enqueue resets the queue's delivery count, so a permanently fenced run
  had no run-level bound; it now fails with RUNTIME_ERROR once the budget
  is spent.
- Label restarts from the main replay catch `replay-write`: it receives 412s
  for both run_completed and wait_completed and cannot tell them apart.

Docs: the guard's fail-open behaviour (it rejects only on evidence, so 412
volume describes a workload rather than a deployment's health), the delta's
same-run requirement for World authors, and the two re-invocation env vars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… restart

The replay payload cache resumes prewarming from the number of events it has
already scanned, which is only sound while the log grows by appending. A
restart after a 412 replaces the log with a corrected one, so the missing
events appear below that length and were never scanned; the restart's
full-reload branch now calls resetScan().

A restart also only logged that it happened. It now reports what the reload
found, so a log that grew (the fence working) is distinguishable from one that
came back unchanged, which means client and world disagree about the same set
of events and every subsequent restart will be rejected too.
Both guard inputs are derived from the loaded set alone, so they cannot
distinguish two replays that consumed the same events in different orders.
That is what makes the one-sided-safety argument hold, and it bounds what 412
volume can report.
Resolved three conflicts against main's #3186 (computeInstanceId on
step_started) and adapted its test to this branch's preconditionSnapshot
API:

- core/runtime/step-executor.ts: fold the three snapshot fields into
  main's bundled `startEventParams` object, spread as a unit at both
  step_started call sites.
- core/runtime/step-executor.test.ts: the compute-instance test now
  passes the guard via `preconditionSnapshot` and asserts the whole
  snapshot survives alongside computeInstanceId.
- world-vercel/src/events.ts: keep both main's computeInstanceId spread
  and this branch's three state* spreads.
- core/runtime/helpers.test.ts: drop main's imports of
  `withPreconditionRetry` and `MutableEventLog`, both deleted here.
A stale-snapshot rejection can carry the comparison it was made on:
`recordedAtOrBelow` (events the world had recorded at or below the
client's watermark) and the `stateUpdatedAt` that watermark was. The
v4 error decoder now picks those up alongside the inline delta, and
`preconditionExpectation` reads them back off the error.

After a full cursor-less reload, the runtime counts how many of the
reloaded events sit at or below that watermark. Two outcomes mean the
next restart would re-derive the same snapshot and earn the same
rejection, so it escalates to a fresh invocation immediately instead:
the reload came back unchanged, or it grew but is still short of what
the world recorded.

Only a full reload is treated as evidence. A delta-fed restart proves
nothing about the log as a whole, and absent or malformed counts leave
the reload unchecked rather than unsatisfied.

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

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9338543

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

This PR includes changesets to release 20 packages
Name Type
workflow Minor
@workflow/core Minor
@workflow/world-vercel Minor
@workflow/world Minor
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

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

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Building Building Preview Jul 31, 2026 8:48pm
example-nextjs-workflow-webpack Ready Ready Preview Jul 31, 2026 8:48pm
example-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-astro-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-express-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-fastify-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-hono-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-nestjs-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-nitro-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-nuxt-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-sveltekit-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-tanstack-start-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workbench-vite-workflow Ready Ready Preview Jul 31, 2026 8:48pm
workflow-docs Ready Ready Preview, v0 Jul 31, 2026 8:48pm
workflow-swc-playground Ready Ready Preview Jul 31, 2026 8:48pm
workflow-tarballs Ready Ready Preview Jul 31, 2026 8:48pm
workflow-web Ready Ready Preview Jul 31, 2026 8:48pm

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (4 failed)

example (1 failed):

  • error handling error propagation workflow errors cross-file imports preserve message and stack trace

express (1 failed):

  • outputStreamWorkflow negative startIndex (reads from end)

hono (1 failed):

  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_41KYWZGTVB0GKFQ03C583X2DAD | 🔍 observability

nuxt (1 failed):

  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_41KYWZHDPR0GRCY90YM3EXS6ZB | 🔍 observability

📦 Local Production (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow | wrun_41KYWYXZXH0GYY3C8YMDHK8PFW

📋 Other (1 failed)

e2e-vercel-prod-tanstack-start (1 failed):

  • error handling retry behavior regular Error retries until success

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 1451 4 239 1694
✅ 💻 Local Development 1467 0 227 1694
❌ 📦 Local Production 1620 1 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
❌ 📋 Other 1019 1 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7359 6 1132 8497
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
❌ example 125 1 28
❌ express 125 1 28
✅ fastify 126 0 28
❌ hono 125 1 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
❌ nuxt 125 1 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

❌ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
❌ nextjs-turbopack-stable 153 1 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

❌ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
❌ e2e-vercel-prod-tanstack-start 125 1 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@vercel
vercel Bot requested a deployment to Preview – workflow-docs July 31, 2026 20:07 Abandoned
# Conflicts:
#	packages/core/src/runtime.ts
#	packages/core/src/runtime/step-executor.ts
#	packages/core/src/runtime/suspension-handler.ts
#	packages/world-vercel/src/events-v4.ts
#	packages/world-vercel/src/events.ts
…estart-evidence

# Conflicts:
#	packages/core/src/runtime.ts
Base automatically changed from peter/event-count-guard to main July 31, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant