* Add world-sim: a deterministic simulation World for concurrency scenarios `@workflow/world-sim` is an in-process World implementation whose point is that nothing in it races. Every world call is a stoppable point with `before`/`positioned`/`after` phases, every call is attributed to a writer (orchestrator, a named step body, or an out-of-band external client), and the clock is virtual — a thirty-day sleep costs no wall time. A scenario script says "stop this writer here, do that, let it go", so an interleaving that a real deployment leaves to chance becomes something you can name. The model follows workflow-server where it matters: - Event ids are minted at the handler boundary, not at storage append. DynamoDB does not generate ids, so the server does, in the request handler — and that id is the log's sort key. This is what makes "earlier log position, later commit" expressible, and it is the hazard the fence scenarios are about. - The out-of-band write marker is keyed on the event's own ULID time and is forward-only, over `hook_received`, `step_completed` and `step_failed`. - Both halves of the staleness fence are modelled behind flags: the watermark (`preconditionGuard`) that clients send today, and the count (`countGuard`) that they do not. The count is synthesized on the caller's behalf and keyed by run rather than by writer, since an orchestrator and its inline step bodies are one process sharing one loaded log. `workbench/sim-world` is the scenario book — 39 of them, each a workflow plus a script. Several are pinned corruptions rather than passing assertions: they record what the runtime does today, so that a change in behaviour shows up as a diff. The doc-29/30/31 trio is the argument for the count guard, one flag apart: (B, A, C) corrupts under the watermark alone, is fenced once the count is on, and (B, C, A) corrupts with both on. That last one suspends mid-run on purpose. The fence is a conditional append evaluated inside the storage write, so there is no checked-but-uncommitted moment to slip past; the window nothing can close is the quiescent gap between deliveries, where the run makes no writes and so meets no checks. Both packages are private and unpublished, so no changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Add the new sim-world workspaces to the lockfile Kept separate from the source commit because it is not a clean diff. The two new importers are the part that belongs to this branch; the rest is re-resolution churn from running `pnpm install` at a later date than whoever last touched the file — `latest` specifiers like docs' `radix-ui` move on their own. Drop or regenerate this commit if the churn is unwelcome; the source commit stands on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Make a consistency violation fail the scenario that tripped it `expect.violations` let a scenario declare the corruption it reproduced and pass for reproducing it. That is a green suite describing a broken system, and it goes red on the day someone *fixes* the bug — backwards, and the opposite of what a test is for. The field is gone. A scenario now states the outcome the run should have reached, which for a corruption means the branch its own durable log implies, and stays red until the runtime gets there. Any violation fails the scenario. The six reproductions read as ordinary failures now: expected output "afterSlow:doc-26", got "afterFast:doc-26" Six scenarios are therefore red, and `run.ts` exits non-zero. The count is the signal: seven is a regression, five means something was fixed and a scenario is ready to retire. Five of the six have known fixes — four predate the count guard, and doc-29 goes green the moment a client sends `stateEventCount`. doc-31 has none, which is the point of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Document the world-sim implementation in DESIGN.md Folds the working notes into a checked-in design doc: module map, the runtime model the simulator has to match, the interception model and its three call phases, the determinism machinery, the store's guards and fault injection, the writer vocabulary, the termination budgets, both consistency checkers, and current test status. Links it from both READMEs, and updates the workbench's doc-31 note now that the append-tail fence it needs exists as a proposal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Say which fix closes each red scenario, and which are demonstrated The fix column read "predates the count guard" on three rows, which scans as "the count guard is involved" when it meant the opposite. It was also wrong on one: `two racing STEPS, no hook anywhere` cannot be fixed by anything predating the count guard, since the row below it is that same fault with `preconditionGuard` on and failing identically. The column now names the specific change, and a second column separates a fix that is argued for from one that is shown — a passing scenario that is the red one with the fix armed, same tempo, one flag apart. Two of the six have that; three name a fix with no paired scenario yet; doc-31 has none. Also states the thing the column could imply but does not mean: `countGuard` requires `stateEventCount`, which no client sends, so three of the five identified fixes are dark in production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Give scenarios stable ids, refer to events by log position, add colour Four changes to make a rendered scenario something you can cite and read. Scenario ids. Every scenario gets a hyphenated `id` next to its prose name — `in-flight-after-decision`, `stale-read-step-count-fork`. The name is a sentence and will be reworded; the id is what a commit message, a bug report or `pnpm sim <id>` refers to. The runner matches the id first and falls back to the name, so existing invocations keep working, and the table of the six red scenarios in DESIGN.md now cites ids. One reference scheme. Events were referred to three ways at once: the trace printed raw correlation ULIDs, violation messages carried `evnt_<ulid>` event ids, and the docs spoke of "position 7". Now there is only log position. `#12` is the twelfth event in the durable log sorted the way `events.list` sorts it, `@7` is the resource created at position 7, and ids inside violation messages are rewritten on the way out. That numbering is deliberately in *log* order while the trace prints in *commit* order, which makes the subject of the red scenarios visible on the page: a run whose log disagrees with the order its writers committed in shows positions counting backwards. Colour, by event family, and only when the destination is a terminal. Off under `NO_COLOR` or `--no-color`, forced on with `--color`. With colour off the output is the same plain ASCII, so it stays usable as a golden file. The workflows under test are now one file. Nothing imported across them and scenarios read them alongside the tempo that steers them, so five modules were a two-file hop for no benefit. Unit tests 61/61. Scenario book unchanged at 33 passed, 6 failed. Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Add an append-only log world, and split the book into one file per scenario Three things the book could not do before. `appendOnlyLog` gives an event its log position at *commit* instead of when its handler minted one. That is the single change that makes a stale read impossible — the log can be behind, never wrong — so playing the same 39 scenarios with and without it is how you tell which of the six reds the change would actually close. All six: 33 pass / 6 violations mint-ordered, 39 pass / 0 violations append-only. It bundles two effects that have to move together: an overtaken write re-mints at the tail, and a withheld read returns a prefix rather than a hole, which is why `onStaleRead` now carries `{eventId, hidden, truncated}` and the trace distinguishes a lagging read from a stale one. `preconditionGuard` on `RunScenarioOptions` forces the fence off across the book, asking whether anything relies on it. Violations 6 -> 8 mint-ordered, so it is load-bearing there; 0 -> 0 append-only, so it is dead weight once positions are assigned at commit. Both flags are tri-state: `undefined` leaves it to the spec, which is not the same as `false`. The scenario book was a 1020-line file; it is now 39 files and an index that only decides reading order. Same 39 ids. This is the whole answer to "how do I add a scenario" — copy the file next door — and it is why the README can be short. Three `in-flight-*` scenarios are reworded so that no expectation is restated per world: a scenario is one sequence of movements, the only thing a world changes is what a read returns, and what catches the fault in both is the invariant that a run's log must replay back into that run. Also: `loadFlowHandler` moves out of `build.ts` into `load.ts`, so playing scenarios no longer drags SWC and esbuild into the module graph; the CLI gains `--report-only`, `--summary-file` and `--detail-file` for CI, with the default still exiting non-zero; and the workbench's `test` script points at `--report-only` so a recursive `pnpm -r test` does not go red for the six. Docs are split by task: the workbench README is how to add a scenario, and the package README plus DESIGN.md are how to change the simulator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Publish the scenario book from CI, without blocking a merge Plays the book on every pull request, once per log world, and posts both summaries as one sticky comment. It never gates. Six scenarios are red on purpose — each is a reproduction of a corruption the runtime can still produce, stating the outcome its own durable log implies and staying red until the runtime gets there — so a lane that failed on them would be red on every PR and read as broken rather than as informative. What it publishes is the pair of counts, and the thing to look at is whether they still say 33/6 and 39/0. A seventh red is a regression; five means something got fixed and a scenario is ready to retire. The append-only column is the measurement the pair exists for: it says which of the six would close if positions were assigned at commit. Non-blocking at the job level rather than only on the two sim steps, so that a failed install or a PR comment the token cannot write does not turn this into a red X either. The steps themselves keep their real exit codes, so the run still says which world was clean. `--title` is new on the CLI: two summaries land in one comment, and two headings reading "world-sim scenario book" would leave the chips line as the only way to tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * Make the Sim World comment four lines until you open it Two collapsed folds, one per world, each with its count and a green or orange dot on the visible line; the table is behind them. Nothing else above the fold. The failures list is gone. Six scenarios are red on purpose, so a comment that led with them led with the part that was not news, and grew a wall of text on exactly the PRs that changed nothing. The count is the signal — and anyone who wants the names can open the table, which has always had them. `renderMarkdownSummary` now renders no heading of its own, since it is built to be stacked under one; the workflow supplies the heading and the one-line description. `mdCell`, `clip` and `dedupe` existed only for the failures table and go with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(sim-world): drop the six-red list, add an API reference The "six red scenarios" section was a second copy of something `pnpm sim` already prints exactly, and the copy that goes stale. What replaces it says how to read a red — an open bug stating the outcome its own durable log implies, green when fixed rather than when seen again — and points at DESIGN.md for the part that is not re-derivable from a run. The reference has three tables. Writers: what a concurrent thread of execution is here, and the four ids one can have. Movements: the instruction that moves a writer to a place and holds it, described against the world boundary, the position in the event log, and the commit to storage — three moments, which is why there are three stops rather than one. Withholdings: the two ways to change what a reader sees without holding anybody. Terminology unified on hold/held (the word `Held` and the trace already use) and on "assigned a position in the event log" / "committed to storage", which also fixes the `runToEventProduced` docstring: the event has crossed the world boundary there, so "submitted" was the misleading half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(sim-world): move the API reference into the package, uniform vocabulary The reference documents `@workflow/world-sim`, so it belongs beside the API and not in the workbench that happens to be its first caller. It replaces the old `## Writers` section, which said most of the same things in a different order and a different vocabulary. Three nouns, one meaning each. A *writer* is a thread of execution — the table lists all three kinds with the handle that names each and the events each one writes. An *advance* moves one writer to a named place and holds it; the three `runTo*` stops are the three moments a write has (crossed the world boundary, assigned a position in the event log, committed to storage), which is why there are three rather than one, and the table says which way a concurrent commit sorts at each. A *withholding* hides something from readers without holding anybody, which is what `withholdNextEvent` and `beginHookDelivery` have in common and why the latter is not an advance. "Movements" is gone in favour of "advances", which the code and the older prose already used. "Held" replaces "stopped"/"paused" throughout, matching `Held` and `isHeld()`. The workbench README loses the duplicate tables and keeps the four rules that bite on a first scenario, so it is a guide to adding one and links out for the rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(world-sim): rename "Two logs" to "World behaviors", one-line motivations The section described two logs, but there are four world behaviors a scenario can pick between: the two log orderings and the two guards. Renamed and regrouped so each one is described by what it does rather than by which of the current reds it closes. Measured counts and "production" / "what happens today" framing are out of this README throughout — they belong to a run of the book, and a doc that carries them is stale the first time a scenario changes colour. The workbench README and the CI lane still print them, where they read as measurements. Every section's motivation is one line; the file-top motivation is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(world-sim): Usage shows both halves — the workflow and the script The example was a script with no workflow beside it, which hid the fact that you write both. Replaced with the smallest pair that has something to control: two steps in flight at once, and a script that decides which of them reaches the log first. The scenario in it was run before it was written down — `prepare` held before it takes a position, `finalize` committed into the earlier slot, #6 then #7 — so the positions the prose cites are the ones the trace prints. Link to the workbench moved to the end of the section, where it reads as "where to go next" rather than as an aside mid-example. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(world-sim): say what "arm" meant instead of using the word The READMEs told a reader to arm a wait without ever saying that calling an advance and awaiting it are two separate things. That is the whole mechanism — `runTo` registers its watch synchronously when called, and the returned promise only reports arrival — so it is stated plainly once in Advances and the jargon is dropped everywhere it stood in for the explanation. "Armed" survives only where it means a guard is switched on, which is a different word doing a different job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(world-sim): two symmetric watches in the Usage example The example started one watch and awaited the other inline, which is exactly the shape a reader cannot generalise from — it looks like the two calls do different things. Both are now started, then both awaited, so the sentence above the block and the code below it say the same thing. Re-run before committing: same log, finalize at #6 and prepare at #7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * docs(world-sim): neutral names in the Usage example `prepare` / `finalize` / `held` / `committed` made a reader decode four domain-ish names to see a mechanism that has nothing to do with any of them. stepA and stepB, watchA and watchB: the only thing left to notice is that the two watches stop at different points, which is the whole lesson. This detaches the example from the workbench's `parallelStepsWorkflow`, so it was verified against a throwaway copy of the workflow rather than assumed — same shape, stepB at #6 and stepA at #7, replay ok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * refactor(world-sim): one event fold, two call phases, a smaller entry Three reductions to the same end — less surface to keep consistent — with the scenario book as the check that none of them changed behaviour. **One fold.** `create` validated *and* applied an event in a 17-case switch; `foldSeededEvent` applied it again in 15 cases without validating. Two copies of the event -> entity state machine that only a replay compares, and any divergence between them makes a replay disagree with the run it is checking for a reason that is not the runtime's fault. Both paths now end in one total `applyEvent`: the write path validates first and calls it, `seedFromLog` calls it with no validation at all, because those events were accepted once already and re-litigating them would reject legitimate history. store.ts loses 175 lines. Characterization tests for the seeded path went in first and were confirmed to bite before the refactor started. **Two phases.** `CallPhase` had a `positioned` phase between `before` and `after`, and `runToPositionMinted` / `runToCall` to park on it. Nothing in the book used any of the three: the mint/commit gap they existed for is reached through `beginHookDelivery`, which owns both halves explicitly and does not block the writer that made the write. Removing the phase also removes an `await` from the interception path, so this was measured rather than reasoned about — all four book runs are unchanged. **A smaller entry.** `index.ts` re-exported the construction kit — `createSimWorld`, `createSimStore`, `driveQueue`, `verifyReplay`, `checkInvariants`, the clock — which nothing outside the package imports and which made every one of their signatures a compatibility promise. The entry is now the scenario surface; extenders import from the module. `InFlightWrite` joins it, having been missing though `beginHookDelivery` returns it. Unchanged, and the point of saying so: 33/6/6 mint-ordered, 39/0/0 append-only, 8 violations with `--no-fence`, 0 with both. 70 unit tests green (up from 67), tsc and biome clean in both workspaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * fix(world-sim): follow the new event-result contract Rebase fixups for four core replay commits, no behaviour change of our own. `EventResult` is now a union — a populated page (`events` + `cursor` + `hasMore`) or all three absent — so the three fields cannot be assembled from three independent variables the type has no way to see agree. They travel as one `deltaPage` object, spread as a whole or not at all. The spread has to be conditional rather than optional: `...maybe` widens each field to `T | undefined`, which is neither arm. `processExitTriggersQueueRedelivery` is gone from the `World` interface — #3385 stopped exiting on an exhausted replay budget, so there is nothing left to tell it not to. The book is unmoved across the rebase, which is the thing worth reporting: 33/6/6 mint-ordered, 39/0/0 append-only, 8 violations with `--no-fence`, 0 with both — and the same six ids red, not a swap that nets to the same count. 70 unit tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * feat(world-sim): a scenario for the unclaimed-payload delivery race Reproduces vercel/workflow#3406 as a pair of scenarios. A hook payload nobody reads registers an unarmed delivery barrier, and a step result is allowed to skip it — otherwise the workflow stalls until the barrier registry idles. The skip is transitive, so the step also skips an armed `wait_completed` merely parked behind that payload. Both branches then draw their next `step_created` id in the order the log does not record. The replay invariant cannot see this: live and replay run the same code, make the same mistake, and agree. What is checkable is the log disagreeing with itself — `wait_completed` is committed first, yet the step branch draws the earlier id. Verified red on the current runtime and green with #3406's diff applied. `claimed-payload-under-fork` is the control: same steps, same tempo, same log order through the step result, one branch awaiting the hook so the payload lands claimed. It passes in both cases. Getting there needed one new primitive. The delivery loop is serial, so a held inline step body stops the loop inside its own delivery and no timer can fire — every interleaving where a `wait_completed` lands while a step result is outstanding was unreachable. `sim.deliverQueued(select?)` takes a message out of the pending set and delivers it from the script, concurrently with the hold; `takeById` removes it first, so the loop can never pick up the same message. The book is now 41 scenarios: 34/7 with 6 violations mint-ordered, 40/1 with 0 violations append-only. The six violation reds and the --no-fence 6 -> 8 / 0 -> 0 measurements are unchanged. The new red is the first that stays red in both worlds, because no log position is wrong in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * fix(world-sim): address review on the simulation world Fifteen findings from review, and one measurement that moved because of them. Correctness: - tempo: `abort()` stored the raw `reject` rather than the wrapper that also disposes the watch, so a point reached after teardown blocked a world call on a promise nobody could release. The deadline is one-shot and already spent by then, so that is a hang. Route abort through the disposing path and make the action a no-op once aborted. - world: `externalDepth` was a plain counter, so a step body writing concurrently with a scripted `deliverHook` was attributed to the script — invisible to any armed `runTo`, and `ext` in the trace. Measured: one misattribution book-wide. Scope it with `AsyncLocalStorage` instead. - writers: `runToEventCommitted` matched a *rejected* create, so in fence scenarios a script could wake believing a write was durable when it had 412'd. Record `failed` on the observed point and require `failed: false`. - clock/ids: reject fractional `advanceBy`, and floor in `ulid()` — a fractional millisecond silently minted `undefinedundefined…` ids that failed much later at `z.ulid()`. - streams: literal NUL bytes as key separators, `limit: 0` paging forever, and a garbled cursor slicing the array away as `NaN`. - scenario: register the handler under the exported `WORKFLOW_QUEUE_PREFIX`. The count guard, which is the substantive one. Since #3145 `@workflow/core` sends `stateEventCount` on every replay-context create and the server's count guard defaults on, so the sim's "no client sends the count" claim was stale in six places. `countGuard` now follows the fence, the sim prefers the runtime's own count over its reconstruction, and the two scenarios whose subject is isolating the watermark half say `countGuard: false` explicitly. The scoreboard does not move under the production-shaped default, which is itself the answer to the review's question. `log.monotonic-order` could never fire: its only caller fed it the sorted array. It now takes commit order, supplied only by a world that promises the two agree — under a mint-ordered log an out-of-order commit is the premise the scenario injected, not a defect. Docs: DESIGN §5 gains the two ways the sim's guards are stronger than production's (FIFO-vs-mint-order pruning, and a fence that is exact in-process where production's is region-local and fails open); §9's scoreboard is regenerated and its "no fix armed anywhere real" claim corrected; §10 gains the parallel hook-resume path, which no sim delivery takes. `step-vs-step-fork` and its twin now say who the withheld reader is in production. `unclaimed-payload-under-fork` is green after the rebase — #3406 fixed the delivery-barrier ordering — so the book is back to six reds and stays there. Counts refreshed everywhere they appear: 35/6/6 mint-ordered, 41/0/0 append-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> * fix(world-sim): make the README's code samples type-check `packages/docs-typecheck` globs `packages/*/README.md`, so the new README went into the Docs Code Samples job and five of its `ts` blocks failed. Four were genuine excerpts with an unbound `sim` or `spec`; one was a match- object *sketch* containing a `…`, so not TypeScript at all — that one loses its `ts` tag, joining the eleven other untagged blocks in the file. The rest name what they use. That needed two `paths` entries in the checker: `@workflow/world-sim` was unmapped, and an unresolved import is deliberately tolerated there (`isExpectedMissingModule`), so every identifier in those samples was `any` — they would have gone green while checking nothing. Mapped, they are checked against the real declarations: seeding `id: 12345`, `readyAtMsTYPO`, `dirsTYPO` and `runToEventCommittedTYPO` is caught against `ScenarioSpec`, `PendingMessageView`, `SimBuildOptions` and `Writer`. The lead teaser keeps its four unadorned lines and takes a `@skip-typecheck` marker instead; the same calls appear in full, checked form further down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com> --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
@workflow/world-sim
A deterministic, fully in-memory World for playing out workflow scenarios and checking that the world contract holds.
It exists to answer questions that a real World cannot be asked, because in a real World they are races:
What happens if the approval webhook arrives after
step_startedis durable but before the workflow gets control back?
In @workflow/world-local you would answer that by polling in a loop and
hoping. Here you state it, and it is what happens — every time, byte for byte.
const wf = sim.writer.orchestrator();
await wf.runToEventCommitted('step_started', 'reserveInventory');
await sim.deliverHook('approval:doc-1', { approved: true });
await wf.release();
The resulting event stream:
0 +0ms wf run_created approvalWorkflow input=<17B>
1 +0ms wf run_started
2 +0ms wf hook_created hook_…KX token="approval:doc-1"
3 +0ms wf step_created step_…KY reserveInventory input=<58B>
4 +0ms wf step_started step_…KY reserveInventory
+0ms wf >> held "orchestrator -> step_started step=reserveInventory (committed)" at events.create:after
5 +0ms ext hook_received hook_…KX token="approval:doc-1" payload=<44B>
6 +0ms reserveInventory step_completed step_…KY reserveInventory result=<22B>
…
The second column names the writer. The indented hook_received is written by
the scenario (ext) from inside the events.create call that committed
step_started, while the orchestrator is held in it. Advance a different
writer instead — sim.writer.step('reserveInventory') — and the same workflow,
same input and same output produce a different log, which is the point.
For how it is built — the interception model, the store's guards, the determinism machinery, and the test status — see DESIGN.md.
The model
Three rules, and everything else follows from them.
1. The World API is the schedule. Every method is wrapped so a scenario can
run code before a call starts, or after its effect is committed but before
the awaiting caller is resumed. Since the World API is the only channel between
the runtime and the outside, that is a complete set of injection points.
2. Nothing happens on its own. queue() records a message and returns; it
never dispatches. The scheduler picks the next message — always the minimum by
(readyAt, enqueueSeq) — hands it to the flow handler, and waits for it to
finish before looking again. One delivery is in flight at a time.
3. Time is a number the scheduler assigns. sleep('30d') becomes a queue
message dated 30 days out; delivering it means moving the clock, not waiting.
Date.now() and new Date() read the virtual clock while a scenario runs
(timers are left alone — the runtime uses zero-delay macrotasks as ordering
barriers, and faking those would change the interleavings we came to observe).
Consequence: scenarios terminate. A month-long sleep costs microseconds. A hook nobody delivers drains the queue and is reported as a stall, naming the token that was never sent, instead of hanging. Delivery count, virtual span and wall time are all capped as a backstop.
Consistency checking
Every scenario ends with the event log re-read and the entity state
re-derived from it. checkInvariants verifies, among others:
| Rule | What it means |
|---|---|
log.monotonic-order |
Append order equals (createdAt, eventId) sort order — replay sees what happened |
run.created-first, run.created-once, run.terminal-is-last |
Run lifecycle shape (a step already running may still close out after termination) |
step.no-restart-after-terminal, step.terminal-once |
A finished step stays finished |
step.entity-matches-log, step.attempt-matches-log, run.entity-matches-log |
Materialized rows are a pure fold of the log |
hook.token-unique, hook.no-receive-after-dispose |
One live hook per token; disposal is final |
wait.resume-at-stable, wait.completed-once |
A wait's deadline is not rewritten (the sleep consumer treats a change as replay divergence) |
Replay verification
Shape checks say the log is well formed, not that it is enough to rebuild the
run, so every scenario reaching completed or failed ends with a cold start:
- Take the committed log and withhold its terminal
run_*event. - Seed the rest into an empty world as durable history.
- Deliver one queue message to the same
workflowEntrypointa deployment serves, with the clock pinned to the instant the run ended. - The runtime must replay from the log alone and re-derive the event that was withheld, with the same output.
No step body re-executes: every step_completed is in the log, so anything the
replay produces came from the log and nothing else. Failures are named:
| Rule | What happened |
|---|---|
replay.diverged |
The runtime could not follow its own history: REPLAY_DIVERGENCE / CORRUPTED_EVENT_LOG |
replay.suspended |
The replay ran out of log before the workflow finished |
replay.output-differs, replay.status-differs |
It finished, with a different answer |
replay.log-differs |
It re-derived a different tail than the one withheld |
Skipped for cancelled and stalled runs: their terminal event came from an
operator, or never existed, so there is no workflow-derived answer to reproduce.
The store behind all of this is a compact reference implementation of the same
event → entity state machine @workflow/world-local runs on the filesystem. Its
cross-process race machinery (claim files, per-entity locks, staged hook events,
canonical event-id pinning) is dropped, since a scenario is single-threaded;
every validation is kept, because rejections are the observable contract.
World behaviors
A scenario picks the world it plays in, and each behavior below changes a rule the runtime is written against.
Mint-ordered log — the default. A position is assigned when the event's handler mints its id, and the event is committed to storage separately. The id is the log's sort key, so a write held between the two lands behind events minted later and committed sooner: an event can arrive in the past, and a read taken in between saw a log the log itself went on to contradict.
Append-only log (appendOnlyLog: true) — a position is assigned at commit.
A write overtaken while it was held gives up its position and re-takes the tail.
Two things follow:
- Log order is commit order. Nothing is inserted behind a row a reader has already seen, so no two reads can disagree about the past.
- Every read is a prefix of the log. A read can be short — missing a write that has not committed yet — but never self-inconsistent. Staleness collapses into lag, and lag is what an optimistic-concurrency fence can see; a hole is what it cannot.
Uncontended writes are untouched either way: a position that is still the newest
when it commits keeps its id, so a scenario that never holds a write mid-flight
produces a byte-identical log in both. withholdNextEvent follows the same
rule — a hole in the mint-ordered log, a truncated tail under append-only —
which is why StaleRead reports { eventId, hidden, truncated } and the trace
distinguishes a lagging read from a stale one.
Precondition fence (preconditionGuard: true) — rejects a write whose
stateUpdatedAt snapshot is strictly older than the newest externally
originated event. It is a high-water mark, so it sees a log truncated at the end
and is blind to a hole in the middle.
Count guard (countGuard: true) — adds the other half: how many events the
log holds at or below stateUpdatedAt, against how many the caller loaded. It
closes the hole a watermark cannot see, and requires the caller to send
stateEventCount. It is evaluated inside the fence's predicate, so it is only
live when the fence is.
Each is a spec field, and RunScenarioOptions carries a run-wide override —
pnpm sim --append-only, --fence / --no-fence — where undefined leaves
each scenario's own choice alone. Playing one book under two behaviors and
diffing the results is what the pair is for; DESIGN.md
§5 has the guards in full.
Usage
You write two things: a workflow, and a script that controls how that workflow executes.
The workflow is ordinary workflow code, compiled the way a deployment compiles it:
// workflows/index.ts
async function stepA(input: string) {
'use step';
return `a:${input}`;
}
async function stepB(input: string) {
'use step';
return `b:${input}`;
}
export async function twoStepsWorkflow(input: string) {
'use workflow';
const [a, b] = await Promise.all([stepA(input), stepB(input)]);
return `${a}|${b}`;
}
Both steps are in flight at once, so which of them reaches the log first is a
race. The script decides it: hold stepA before its completion is assigned a
position, let stepB commit, then let both go.
import type { ScenarioSpec } from '@workflow/world-sim';
const spec: ScenarioSpec = {
// The stable handle: what a bug report cites and `pnpm sim <id>` selects.
// The prose `name` beside it is free to be reworded.
id: 'b-lands-first',
name: 'stepB lands in the log before stepA',
// Named from the build manifest — no client transform needed.
workflow: 'twoStepsWorkflow',
input: ['x'],
script: async (sim) => {
const a = sim.writer.step('stepA');
const b = sim.writer.step('stepB');
// Calling an advance starts watching for its point; awaiting it waits for
// the writer to get there. Start both watches, then await both — asking
// for a point that has already gone by is an error, not a wait.
const watchA = a.runToEventProduced('step_completed');
const watchB = b.runToEventCommitted('step_completed');
await watchA;
await watchB;
await b.release();
await a.release();
},
expect: { status: 'completed', output: 'a:x|b:x' },
};
stepA is held before it takes a position, so stepB gets the earlier one —
#6 stepB, #7 stepA — on every run, in either order the runtime would
otherwise have picked.
Playing it needs the compiled bundle, because the orchestrator runs from a code string inside a VM:
import {
loadFlowHandler,
renderScenario,
runScenario,
type ScenarioSpec,
} from '@workflow/world-sim';
// Separate entry on purpose: this one reaches SWC and esbuild through
// `@workflow/builders`, and playing a scenario should not drag a compiler into
// the module graph.
import { buildSimBundle } from '@workflow/world-sim/build';
declare const spec: ScenarioSpec; // the one above
const bundle = await buildSimBundle({ cwd: process.cwd(), dirs: ['workflows'] });
const handler = await loadFlowHandler(bundle.flowBundlePath);
const result = await runScenario(spec, {
handler,
workflowIds: bundle.workflowIds,
});
console.log(renderScenario(result));
expect states what correct looks like, which is not always what the runtime
does. There is deliberately no way to expect a consistency violation: a scenario
reproducing a corruption declares the outcome the run should have reached and
stays red until the runtime delivers it, because a suite that goes green by
recording the bug gives no signal on the day someone fixes it.
workbench/sim-world is the worked
example — a book of scenarios, a CLI that plays them, and a guide to adding
one.
Reading the output
Events are referred to one way and one way only: by log position, so a claim about the output is one a reader can check against it.
#12 is the twelfth event in the log sorted the way events.list sorts it,
(createdAt, eventId); @7 is the resource created at position 7. Ids in
violation messages are rewritten to positions on the way out.
The trace prints in commit order and is numbered in log order, so a run whose log disagrees with the order its writers committed in shows up as positions counting backwards:
# 8 +1.0m wf wait_completed @6
# 7 +1.0m ext hook_received @2 token="count:doc-29"
# 9 +1.0m wf step_created @9 settle
The hook owns position 7, the timeout at 8 was committed first, and the branch at 9 went with the timeout. Out-of-order positions are highlighted when colour is on.
Colour is applied only when stdout is a terminal, and is off under NO_COLOR
or --no-color; pass { color: true } to force it. With colour off the output
is plain ASCII, stable enough to check in as a golden file.
API reference
Three things a script works with. A writer is a thread of execution. An advance moves one writer to a named place and holds it there. A withholding hides something from readers without holding anyone.
Writers
A run is not one program: several writers append to one event log, and each write crosses the world boundary, is assigned a position in the event log, and is committed to storage.
| writer | handle | what it is | what it writes |
|---|---|---|---|
orchestrator |
sim.writer.orchestrator() |
The workflow function and the runtime around it, committing at a suspension point. One per queue delivery. | the run lifecycle, step_created / step_started, hook_created, wait_* |
step:<name> |
sim.writer.step('<name>') |
One step body, running inline with full Node access. Two steps sharing a function name share the writer. | its own step_completed / step_failed / step_retrying, and any attr_set from step context |
external |
none — see Withholdings | The scenario, acting as a webhook receiver or an operator | hook_received, run_cancelled |
Two step bodies in a single delivery are already two writers racing to the same log: no second invocation and no real threads are required. That is why the vocabulary is per-writer rather than per-invocation.
sim.writer.anyStep() and sim.writer.any() are handles that match more than
one writer — whichever reaches the advance first. A handle is a name, not a
live object, so sim.writer.step('slow') can be taken before that step exists.
sim.writer.seen() lists the ids observed so far, in first-appearance order.
Advances
An advance tells one writer to move to a named place and hold there until
release(). Every other writer keeps running, so whatever the script does in
between is guaranteed to land first.
Calling an advance starts watching; awaiting it waits for the hold. The two
are separate on purpose: const p = wf.runToEventCommitted(…) is already
watching for that point, and await p only blocks the script until the writer
gets there. So a script that needs two writers held at once starts both
watches, then awaits both.
import type { ScenarioScript } from '@workflow/world-sim';
const script: ScenarioScript = async (sim) => {
const wf = sim.writer.orchestrator();
const reserve = sim.writer.step('reserveInventory');
// Hold just after step_started is committed and before the orchestrator is
// resumed — the window the whole instrument exists for.
await wf.runToEventCommitted('step_started', 'reserveInventory');
sim.check('no payload yet', !sim.world.events().some((e) => e.eventType === 'hook_received'));
await sim.deliverHook('approval:doc-1', { approved: true });
// Start the next watch BEFORE releasing: a released writer can reach the
// next point within the same turn, and a watch started afterwards has
// missed it.
const done = reserve.runToEventCommitted('step_completed');
await wf.release();
await done;
await reserve.release();
};
| method | writer | description |
|---|---|---|
wf.runToEventProduced(type, opts?) |
any | Hold once the event has crossed the world boundary — formed, attributed, in the trace — and before it is assigned a position in the event log. Anything committed to storage during the hold sorts ahead of it. |
wf.runToEventCommitted(type, opts?) |
any | Hold once the event is committed to storage, before the writer resumes. |
wf.release() |
the held one | Let the writer go. Idempotent; awaiting it yields the event loop, so the writer has really moved by the time it resolves. |
wf.isHeld() / wf.history() |
— | Is it held / where it has been. |
sim.park(match, label?) |
whichever matches | Hold the next matching call, whoever makes it. |
sim.until(match, label?) |
whichever matches | Wait for a matching call, without holding it. |
sim.during(match, body) |
whichever matches | park, run body while it is held, then release. |
type is one event type or several. opts is a step name as a bare string, or
{stepName, token, correlationId, where, label, timeoutMs}.
Both advances hold a writer whose event has no position yet, so a write that
commits during the hold sorts ahead of it. For the other order — an event that
already owns an earlier slot and has not appeared — hold the write itself with
sim.beginHookDelivery, which reserves the position and hands
back a commit(). Under appendOnlyLog that reservation is provisional: an
overtaken write gives it up and re-takes the tail, which is exactly how the world
closes the gap. See World behaviors.
runTo is level-triggered: it consults recorded history, so a point this
writer already passed is an AlreadyPassedError naming the point rather than a
wait that never ends. Asking twice means "the next one". Each advance carries a
watchdog (limits.maxRunToWallMs) whose timeout reports where every writer
was standing, which is a diagnosis rather than the scenario's global budget
running out.
Two mistakes are worth knowing, and the errors name both:
- Watching too late. Releasing writer A before B's watch has started. B's step body may already be in flight and commit during the release.
- Naming the wrong writer.
step_startedis the orchestrator's write;step_completedis the step body's. The wrong one is a timeout.
park / until / during take a raw match object and are what the writer
handles are built from. Fields are ANDed; eventType implies events.create,
stepName accepts the machine name or the plain function name, where covers
what the declarative fields cannot say, and phase defaults to 'after':
{ call: 'events.create' | 'queue' | 'runs.get' | … , phase: 'before' | 'after',
eventType, stepName, correlationId, token, runId, writer, failed, where }
Reach for them when the point is a state rather than a name. where is the
one thing a level-triggered runTo cannot re-check against history, so a
where wait is edge-triggered and leans on its timeout.
The park/permit model — and the word tempo for the resulting order — is lifted
from blanket,
which does this for Python's threading primitives. The mapping is direct: a
world call is a transaction, the after phase is its parking state, and
release() is the permit.
A script is the only way to hang this simulator, because a held call blocks its writer and in the limit the scheduler. Three guards close that: the per-advance watchdog above, the runner reporting what a script was still waiting for instead of awaiting it forever, and a wall-clock deadline that releases every held call and rejects every pending wait. A script that throws is reported as a scenario problem rather than a World error, so a broken script is never misread as a runtime bug.
Withholdings
A withholding hides something from readers without holding the writer that produced it. An advance stops one thread; a withholding lets every thread run and changes what storage answers.
| method | writer | description |
|---|---|---|
sim.withholdNextEvent(reads?) |
whichever commits next | Hide the next event committed to storage from the next reads event-log reads (default 1). Call it immediately before the write to hide. |
sim.beginHookDelivery(token, payload) |
external |
Deliver a hook, withheld between its two halves: assigned a position in the event log, not committed to storage. Returns {eventId, commit()}. |
beginHookDelivery is the one place inside an external writer a script can
reach, and it is a withholding rather than an advance because holding that
writer would be the wrong model: an out-of-band receiver is a separate process,
so nothing of the run's is blocked while its write is in flight. Holding an
inline write would stall the delivery that made it, and the reader with it.
Both change shape with the log. Under appendOnlyLog a withheld read is cut
short at the withheld event instead of missing it from the middle — the log can
be behind, never wrong — and an overtaken hook re-takes the tail on commit().
Everything else a script can do
deliverHook(token, payload) |
Runs the real resumeHook() — the same code an out-of-band webhook receiver would |
cancelRun(reason?) |
Cancel the run under test |
advanceTime(ms) |
Jump the virtual clock |
deliverQueued(select?) |
Deliver one queued message now, concurrently with a held writer |
note(msg) / check(name, cond) |
Record a marker / an assertion in the trace; a false check fails the scenario |
world |
Read-only snapshot: runs, events, steps, hooks, waits, pending messages, rejected calls |
appendOnlyLog |
Which log this run is playing against — for phrasing a check, never for branching the tempo |
A scenario with no script at all is a control: the run plays out on the default schedule, and the only question is whether the log it leaves reproduces it.
deliverQueued, and why it is not an advance
The scheduler is strictly serial: one message at a time, and the clock only moves when it picks the next one up. So a held writer freezes virtual time along with everything else, and a whole family of interleavings is simply unreachable from the advances above — anything of the form a timer fires while a step result is outstanding. Both halves need to be in flight at once, and the loop will only ever have one.
deliverQueued takes a message out of the pending set and delivers it right
there in the script, so it runs alongside the held writer rather than after it.
takeById removes it first, so the loop can never pick up the same message: the
two are different deliveries running concurrently, not a race for one.
That concurrency is real, and so is its fallout. Two flow deliveries for one run
will collide the way they do in production — expect EntityConflictError and
HookNotFoundError in the rejection list once both branches finish. Those are
the deliveries losing races they are supposed to lose, not violations.
The default picks pending[0], matching the loop's own order. Usually you want
to choose: a hook delivery enqueues a flow message of its own and it sorts
earlier than the timer you are almost certainly after.
import type { Tempo } from '@workflow/world-sim';
declare const sim: Tempo; // the `script` parameter
const fired = sim.deliverQueued(
(pending) => pending.find((m) => m.readyAtMs > sim.world.nowMs())?.messageId
);
Note the missing await — awaiting it here would wait for the delivery to
finish, which defeats the purpose. Arm a hold on the writer that delivery will
wake, fire it, await the hold, and the two are now interleaved. Await the
returned promise at the end to assert it found something.
Extending the simulator
Changing the instrument itself, routed by task — adding a scenario needs none
of it, and is
workbench/sim-world/README.md.
The module map is DESIGN.md §1.
| I want to… | Change | Read first |
|---|---|---|
| let scripts hold at a point the API can't name | world.ts — the call-point wrapper, and CallMatch in types.ts |
§3 Interception |
| add a phase to an existing call | CallPhase in types.ts, where world.ts parks on it, plus the writer op that names it |
§3 Two phases |
| add a rule the log must satisfy | invariants.ts, plus the rule table above |
§8 Consistency checking |
| add or change a writer kind | writers.ts for the handles, world.ts for attribution |
§3 Writer attribution |
| add a fault injector | store.ts — next to withholdNextEvent and the guards |
§5 Fault injection |
| change what a read returns | store.ts applyWithhold |
§5 The store |
| change where an event lands | store.ts positionAtCommit / mintEvent |
World behaviors above |
| add a spec field | ScenarioSpec in scenario.ts, RunScenarioOptions beside it, then run.ts for the CLI flag |
§6 Spec |
| change the replay check | replay.ts |
§8 Replay verification |
| change the output | report.ts — renderScenario, renderSummary, renderMarkdownSummary |
Reading the output above |
Four things worth knowing before you start:
The package entry is the scenario surface, not the whole package.
index.ts exports what it takes to write a scenario, play it and render the
result. The construction kit — createSimWorld, createSimStore, driveQueue,
verifyReplay, checkInvariants, the clock — is imported from its own module,
so adding an option to one of them is not a change to the package's public
signature. Promote a name to the entry when something outside the package needs
it, not before.
A new world flag is tri-state at the runner. ScenarioSpec carries the
scenario's own choice, RunScenarioOptions the run-wide override, and
undefined means "leave it to the spec" — not the same as false, because a
scenario that asked for the flag must keep it. run.ts maps --x / --no-x
onto that, and the resolved value reaches createSimWorld and the chips line.
Anything a scenario can observe has to survive replay. verifyReplay
re-plays the log in a fresh world built from the same options, so a store rule
that is not applied there turns every scenario using it red for the wrong
reason.
Tests come in two shapes. src/*.test.ts are vitest units against the
pieces in isolation — copy store.test.ts for anything that changes what the
log looks like, where the append-only block is written as pairs asserting
opposite outcomes in the two worlds. The scenario book is the integration
test; run it before and after and diff the counts.
What this does not give you
Worth being explicit, because the guarantees are narrower than "deterministic":
- Determinism is world-level. Step bodies are ordinary Node code. A step
that calls
Math.random(), reads a file, or hits the network is as nondeterministic here as anywhere. Keep step bodies pure, or stub them. - Only one interleaving per scenario. Deliveries are serialized, so a
scenario pins one schedule rather than searching the space of them
(
selectNextpicks which queued message goes next). Same tradeblanketmakes: it reproduces orderings you can describe, it does not discover ones you can't. - The store is a reimplementation, not the real thing. It models
world-local's semantics rather than delegating to them, so it could in principle agree with the runtime while a real world disagrees. The fix is conformance testing: make the storage layer pluggable, play the same book againstworld-local, and diff the event streams. - "Before the workflow resumes" is about the log, not the CPU. The hook is committed before the intercepted call returns, so it is in the log before the runtime's next read of it. Whether the runtime observes it on the next replay depends on optimizations that can skip a re-read — visible in the trace.
- One scenario at a time per process. The virtual clock and the World are process-global singletons.
- Not a deployable World. It has no persistence and no concurrency; it is a
test instrument, and is intentionally not listed in
worlds-manifest.json.