@sanity/workflow-engine-test 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,494 @@
1
+ # @sanity/workflow-engine-test
2
+
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 65c7b0f: **BREAKING:** `bench.editDocument` now takes a `{set?, unset?, insert?}` patch spec instead of a set-only object — rewrite `patch: {title: 'x'}` as `patch: {set: {title: 'x'}}`. All three op kinds ride the same guarded as-actor write seam in a single commit, so `!defined(...)` predicates and array appends are now testable without bypassing `identity()` routing. A non-delete edit whose patch carries no ops throws, and `action: 'delete'` rejects a patch outright — contradictory input fails loud instead of being dropped.
8
+
9
+ New bench surface:
10
+ - `bench.setStage(args)` and `bench.abortInstance(args)` — the two instance-admin verbs join the wrapped set, injecting the bench access default and the bench clock like every other wrapper (previously callers rebuilt an engine by hand and time-based predicates evaluated at wall-clock).
11
+ - `createBenchEngine(bench, overrides?)` is now exported: builds an out-of-band engine that inherits the bench's client, tag, workflow resource, and clock; `overrides` win.
12
+ - The bench exposes its `tag` and `workflowResource`, and the default tag ships as the `BENCH_TAG` constant.
13
+
14
+ - d192d68: Definitions can now declare which documents they're for, and consumers can discover the right workflow to surface or start — no more hand-maintained per-consumer mapping tables.
15
+ - `types?: string[]` on `doc.ref` / `doc.refs` field entries — the engine's `reference.to`. Absent accepts any type; declaring it on any other kind is a define-time error, as is an empty list or a literal seed whose GDR `type` isn't listed.
16
+ - Optional definition-level `applicableWhen` — GROQ evaluated against a candidate document (the doc is the root; no `$` vars are bound, no lake) for value-level routing like `"_type == 'article' && articleType == 'finance'"`. Deploy validation rejects a parse error, any `$var` read, and any `*` dataset scan — each would make the predicate silently wrong at runtime.
17
+ - Discovery is a pure derivation over deployed definitions: startable ∧ a required subject entry accepts the doc's `_type` ∧ `applicableWhen` passes (a GROQ-null result fails closed; a malformed predicate fails LOUD, naming its definition). Exposed as `applicableDefinitions` / `isDefinitionApplicable` / `acceptsDocumentType`, and as the `definitionsForDocument` verb (namespace + `Engine`) — the startable half of `instancesForDocument`. It takes the LOADED candidate document, considers only the latest deployed version per name, and surfaces ALL matches with no engine ranking. Advisory like every engine-side check.
18
+ - Every hard write door honours `types`: `startInstance` initialFields, the spawn `with` handoff, and `editField` / `field.set` / `field.append` ops (resolved ref entries persist their declared `types`, like `object`/`array` persist `fields`/`of`, so instances stay self-describing). A violation fails hard — API-contract validation, the same tier as a missing required field. The spawn error names the projection footgun when (and only when) it applies: a projected bare id / `_ref` canonicalises with the generic type `"document"`, so a typed child entry needs `{"id": _id, "type": _type}` projected. A query-sourced entry's mismatching lake result is discarded with an audit row, like every other query-shape mismatch.
19
+ - Bench: `definitionsForDocument(document)` read helper.
20
+
21
+ - d6e92ed: Now published with public npm access (previously restricted) — installable without `@sanity` org membership.
22
+
23
+ ### Patch Changes
24
+
25
+ - 9f5a40f: Multi-resource bench coverage now proves the engine's auto-derived sibling routing: a subject in a foreign dataset is readable with no `resourceClients`, and a resolver miss reads the derived sibling for the target resource instead of falling back to the workflow client.
26
+ - 2511bb5: The `releaseField` fixture builds its `release.ref` envelope through the engine's `releaseRef` constructor, and test expectations track the engine's reworded `instancesQuery` document-filter error.
27
+ - 6bbf3ca: **BREAKING:** effect delivery reliability — claim leases with a stale-claim sweeper, and caller-supplied idempotency keys that are actually honored.
28
+
29
+ Claim leases (`drainEffects` / `sweepStaleClaims`):
30
+ - Every claim `drainEffects` stamps now carries `leaseExpiresAt` (engine-configurable via `createEngine({effectLeaseMs})`, default 5 minutes). A claim whose lease lapsed no longer protects its entry — the claimer is presumed dead, so a drainer dying between claim and complete no longer wedges the entry forever (previously the only recovery was aborting the whole instance). A claim persisted before leases existed counts as already expired, keeping exactly those stuck entries recoverable. There is deliberately no attempt counter on the claim: the retry count is the `effectClaimReleased` history rows for the entry's `_key` (plus the live claim), and a stored field would drift from that trail.
31
+ - `drainEffects` takes expired claims over in the same CAS write that stamps its own claim, recording an `effectClaimReleased` history row (new `HistoryEntry` variant) with the abandoned claim embedded for audit. The `ifRevisionId` guard is the sweeper election — concurrent recoverers race safely.
32
+ - New standalone `sweepStaleClaims({client, tag, instanceId})` export: force-release expired claims WITHOUT dispatching, for ops/admin housekeeping. Deliberately not an `Engine` verb (same category as the guard-lifecycle helpers) — a drain already self-recovers via takeover. Returns the released entries; the next drain redispatches them.
33
+ - `DrainEffectsResult` gains a required `lost` bucket: entries this drainer dispatched whose completion lost to another party (e.g. a takeover finishing first). Previously that race crashed the drain with `EffectNotFoundError` mid-loop.
34
+ - `EffectHandler`'s TSDoc now states the delivery contract explicitly: **at-least-once — a handler MAY double-run** (crash between dispatch and completion, or a slow dispatch outliving its lease). Check `effectHistory[]` for the run's `ctx.effectKey` before irreversible work, and derive external-system identifiers from `ctx.effectKey` so the receiving system can dedupe.
35
+ - `drainEffects`/`sweepStaleClaims` read the engine clock for claim/lease stamps, so lease timing is deterministic under `createEngine({clock})` (previously drain stamped wall-clock unconditionally).
36
+
37
+ Caller idempotency keys (previously a declared-but-dead arg):
38
+ - The commit-owning verbs — `fireAction`, `editField`, `completeEffect`, `setStage`, `abortInstance` — now honor `idempotencyKey`. The first commit records the key on a new `processedRequests[]` instance ledger **in the same CAS write as the operation**, and a blind retry replays instead of double-applying (or, for a completed effect, throwing `EffectNotFoundError`): the op is not re-applied, but the cascade still runs to convergence — repairing the exact crash window a keyed retry exists for (died between the original's commit and its cascade) — so `changed` is `false` unless that cascade moved the instance. The check runs before the verbs' soft gates, so a replay can't die on a gate its own original commit closed (a resolved activity, a moved stage).
39
+ - Ledger rows expire after `idempotencyTtlMs` (engine-scope config, default 24h) and are pruned lazily by later keyed commits; instances whose callers pass no keys never grow the ledger. Reusing a live key on a different verb throws `ContractViolationError`.
40
+ - The moved arg is the breaking part: `idempotencyKey` now lives on `DedupableOperationArgs` (which the five verbs' args extend) instead of `OperationArgs`, so `tick` — which owns no commit and is naturally idempotent — no longer accepts the previously-ignored field.
41
+
42
+ - adf32f7: **BREAKING:** the `createBench({accessControl})` option is removed — it is subsumed by the identity registry (the identity's grants both render `$can` and gate the fake lake's writes, so identity and enforcement can't disagree), and a supplied `client` is rebound to the bench's default token identity. The bench expresses identities as registered tokens on its fake client: `createBench({currentUser, grants})` registers the default token, per-call `actor:`/`grants:` shortcuts mint cached token-scoped sibling clients, and the engine token-resolves every identity from `/users/me` + the ACL path exactly like production — token and actor can no longer disagree. The bench API is unchanged for tests using the shortcuts. New surface: `bench.clientFor({actor, grants?})` exposes the identity directory directly — the one registration seam for acting as another user (bind its sibling to `createBenchEngine`); omitted grants default to wildcard. New `createBench({executionContext})` passthrough (default `{kind: 'test'}`) for provenance assertions.
43
+ - 23cf131: Internal test fixture only (nothing published changes): `multiResourceHarness` accepts two new options — `contentAccessControl` gives the content resource an identity-serving, grant-gated client whose ACL path serves the supplied grants; `wfGrants` swaps the grants on the harness's existing token-bound wf identity (the identity itself stays `{id: 'test'}`).
44
+ - 6c21009: Regression tests pinning documented contracts: a guard `match.idPatterns` glob supports multiple `*`s and matches the whole id (anchored — no substring leak), `$can` binds in activity `requirements.*` and in `editable` predicates (unmet under read-only grants, met under update grants), and deploying a guard whose `idRefs` resolve to documents in different resources fails loudly with the single-resource error instead of picking one.
45
+ - dc64968: Published tarballs no longer include sourcemaps (whose `sourcesContent` embedded the complete TypeScript source) or inline code comments in the compiled JS — license markers and bundler annotations (`/*@__PURE__*/`-style tree-shaking hints) are preserved. Types and TSDoc in `.d.ts` files ship unchanged, and the tarballs shrink accordingly (workflow-engine: ~3× smaller). Each tarball now ships its `CHANGELOG.md`, so release notes reach consumers who can install the package but cannot see the repository.
46
+ - d192d68: **BREAKING:** subworkflow lifecycle is now owned by a workflow-scope registry (`instance.subworkflows`) instead of stage-entry bookkeeping. A child instance is a workflow-level fact; its record now lives (and survives) at workflow scope.
47
+ - Re-entering a spawning stage **adopts** live children whose `forEach` rows are rediscovered instead of spawning a duplicate cohort. Rows that vanished from discovery follow the block's new `onExit` policy — `'detach'` (default) lets the child run to completion outside the gate, `'abort'` kills it. Stage exit applies the same policy to the live cohort.
48
+ - `forEach` rows must carry an identity for adopt-matching: `_key` ?? `_id` for object rows (mint a `_key` in the projection for synthetic rows), the value itself for scalar rows. An identity-less or duplicate-identity row fails the spawn with a teaching error.
49
+ - `abortInstance` now cascades: every live descendant is aborted recursively. An owed abort is recorded ON the child's registry row (`abortPending: {at, reason}` — crash-safe, and kept afterwards as the audit of why); the cascade drains condemned rows, and a condemned row is never adopted into a fresh cohort.
50
+ - `$subworkflows` is now an **always-bound** condition var over ALL registry rows, faceted by `activity` / `definition` / `rowKey` / `status` (`'active' | 'done' | 'aborted'`), with `current` marking the open stage entry's cohort and `stage` the child's current stage — usable in transition filters, requirements, and any stage's gates. The implicit spawn gate scopes itself to the current cohort and settles on not-active (an aborted child releases it); authored gates that assumed spawn-scoped rows must now facet (`$subworkflows[activity == "…" && current && …]`).
51
+ - Live children join the parent's watch-set and snapshot with raw reads, so conditions over child state re-evaluate as children move; a terminal child's state is frozen onto its registry row and its document leaves the watch-set.
52
+ - Child completion propagates through the registry: a detached child (spawning stage exited, parent moved on) still stamps its row and re-cascades the parent; a terminal parent takes the stamp and history record without being reopened; a propagation with no matching registry row records a loud `subworkflowOrphaned` history entry instead of silently dropping.
53
+ - A parent may complete with live detached children — legal, and `diagnose` now reports `liveChildren` on terminal states so consumers can surface it.
54
+ - Removed: `ActivityEntry.spawnedInstances` — the registry replaces it. Instances persisted before the registry have an empty registry, so a spawning activity that was waiting on in-flight children resolves immediately on its next cascade (the cohort gate is vacuously settled) and those children's completions no longer propagate — each is recorded as a `subworkflowOrphaned` history entry on the parent when it terminates. Let in-flight fan-outs finish before upgrading, or expect to re-drive affected parents manually.
55
+
56
+ - Updated dependencies [4ca17bb]
57
+ - Updated dependencies [5143707]
58
+ - Updated dependencies [0709ab1]
59
+ - Updated dependencies [6bbf3ca]
60
+ - Updated dependencies [9f5a40f]
61
+ - Updated dependencies [d192d68]
62
+ - Updated dependencies [6c21009]
63
+ - Updated dependencies [109f900]
64
+ - Updated dependencies [109f900]
65
+ - Updated dependencies [444b0e6]
66
+ - Updated dependencies [2511bb5]
67
+ - Updated dependencies [d4fd8e6]
68
+ - Updated dependencies [30ed0e8]
69
+ - Updated dependencies [2834704]
70
+ - Updated dependencies [2511bb5]
71
+ - Updated dependencies [2511bb5]
72
+ - Updated dependencies [2511bb5]
73
+ - Updated dependencies [4607d59]
74
+ - Updated dependencies [52f6024]
75
+ - Updated dependencies [a8f1cb9]
76
+ - Updated dependencies [adf32f7]
77
+ - Updated dependencies [5cb0850]
78
+ - Updated dependencies [70a136c]
79
+ - Updated dependencies [91540de]
80
+ - Updated dependencies [8455fbb]
81
+ - Updated dependencies [325c2d1]
82
+ - Updated dependencies [d6e92ed]
83
+ - Updated dependencies [6c21009]
84
+ - Updated dependencies [7ffdc77]
85
+ - Updated dependencies [325c2d1]
86
+ - Updated dependencies [7ffdc77]
87
+ - Updated dependencies [ced40e4]
88
+ - Updated dependencies [9b1b40d]
89
+ - Updated dependencies [8c319b2]
90
+ - Updated dependencies [dc64968]
91
+ - Updated dependencies [23cf131]
92
+ - Updated dependencies [d192d68]
93
+ - Updated dependencies [15337f1]
94
+ - Updated dependencies [2d1a8e1]
95
+ - Updated dependencies [12cdbd6]
96
+ - Updated dependencies [437b544]
97
+ - Updated dependencies [ced40e4]
98
+ - @sanity/workflow-engine@0.15.0
99
+
100
+ ## 0.9.0
101
+
102
+ ### Minor Changes
103
+
104
+ - 7a655bd: The bench grows the fixture helpers every consumer was hand-rolling: `subjectField(documentId, {name?, type?})` and `releaseField(releaseName, {name?})` build initial-field entries for the bench's default resource (derived via the engine's GDR constructors, not restated URI strings), and `bench.completePendingEffect({instanceId, effect, ...})` completes a pending effect looked up by name — the idiomatic way to play an external handler reporting its result — throwing loud when no such effect is pending.
105
+ - 5c69928: **BREAKING:** runtime API coherence pass — one result shape, one session noun, nameable engine args.
106
+ - `DispatchResult` → `OperationResult`, and its `fired` flag → `changed` with one meaning honored by every verb: this call changed the instance's workflow state (`false` = successful no-op — an idempotent re-fire, a tick that wrote nothing, a `setStage` already at the target, an abort of an already-terminal instance; failures still throw). `tick` derives it from the instance revision, so a persisted activity-gate flip that didn't unlock a transition still reports `changed: true`. `abortInstance` documents its always-`0` cascade (a terminal instance doesn't cascade; ancestor propagation reports on the ancestors).
107
+ - `startInstance` now returns `OperationResult` instead of a bare `WorkflowInstance` — its cascade is finally observable. Reach the instance via `result.instance`.
108
+ - Engine argument types are exported and nameable: the exported `*Args` shapes are now exactly what `Engine` methods take, and the raw `workflow.*` namespace takes `<Verb>Args & EngineScopeArgs` (the new exported scope type: `client` / `tag` / `workflowResource` / `resourceClients`). New named args for the read/housekeeping verbs: `InstanceRefArgs`, `ChildrenArgs`, `GuardsForDefinitionArgs`, `InstancesForDocumentArgs`, `QueryArgs`, `QueryInScopeArgs`, `FindPendingEffectsArgs`, `DrainEffectsArgs`, `SessionArgs`. `EvaluateArgs` is likewise scope-free now.
109
+ - Verb-surface parity between `Engine` and `workflow.*` is exact and documented: `Engine.evaluateInstance` → `Engine.evaluate` (matching `workflow.evaluate`); engine-only members (`session`, `subscriptionDocumentsForInstance`, `drainEffects`, `verifyDeployedDefinitions`) and the namespace-only `workflow.permissions` are called out as deliberate.
110
+ - One noun for the reactive session handle: `engine.instance(doc, opts?)` → `engine.session({instance, access?, grantsFromPath?})` (args object — no positional outlier), still returning `InstanceSession`; workflow-react's `WorkflowInstanceController` → `WorkflowSession`; the studio/sdk hooks `useWorkflowInstance` → `useWorkflowSession`. "Instance" now always means the document.
111
+ - Test bench mirrors the engine: `bench.startInstance` returns `OperationResult`, and the `Bench*Args` types are the engine-bound shapes minus `access`.
112
+ - CLI reports follow the rename: the report objects for `fire-action` / `move-stage` / `abort` (and `fire-action`'s `--json` payload) now say `changed` instead of `fired`.
113
+
114
+ - 6ee9068: **BREAKING:** the bench's default `ALL_ACCESS_USER` actor is `kind: 'person'` (was `'user'`), following the engine's actor-kind rename — tests asserting on recorded actor kinds must follow.
115
+
116
+ ### Patch Changes
117
+
118
+ - d9c8179: Drop the remaining "POC" branding from the guard docs and single-source the persisted guard doc type. Doc comments reference the type via `GUARD_DOC_TYPE` instead of hardcoding the literal, tests build guard ids and `_type` assertions with the exported `GUARD_DOC_TYPE` / `lakeGuardId`, and a contract-pin test pins the persisted wire literals so the eventual stored-type rename is a deliberate act. The `GUARD_DOC_TYPE` doc also notes that the cutover rename is a breaking type change for already-persisted guard docs.
119
+ - 0a7979a: Reconcile the guard enforcement story across the public surface. The packages previously asserted both that "the lake enforces guards" and that "enforcement is optimistic — the lake does not enforce `temp.system.guard`". Only the second is true: the lake does not evaluate the guard doc type yet (it is the placeholder for the lake's forthcoming guard primitive), so deployed guards deny optimistically engine-side on every plan and the lake ACL is the only hard gate. Every TSDoc block and README now tells that one story, with `GUARD_DOC_TYPE` as the canonical anchor; the bench's write rejection is described as a simulation of the intended lake contract, not a reproduction of current production behavior.
120
+ - 113a796: README: the shared-store sample passes the shipped `tag` argument to `createBench` instead of the renamed-away `tags`.
121
+ - Updated dependencies [a0443c4]
122
+ - Updated dependencies [a0443c4]
123
+ - Updated dependencies [ad9b6f6]
124
+ - Updated dependencies [83d5924]
125
+ - Updated dependencies [7a655bd]
126
+ - Updated dependencies [d9c8179]
127
+ - Updated dependencies [0a7979a]
128
+ - Updated dependencies [df00bbb]
129
+ - Updated dependencies [5c69928]
130
+ - Updated dependencies [2f081a3]
131
+ - Updated dependencies [df00bbb]
132
+ - Updated dependencies [113a796]
133
+ - Updated dependencies [df00bbb]
134
+ - Updated dependencies [2829643]
135
+ - Updated dependencies [6ee9068]
136
+ - Updated dependencies [2829643]
137
+ - Updated dependencies [a0443c4]
138
+ - @sanity/workflow-engine@0.14.0
139
+
140
+ ## 0.8.0
141
+
142
+ ### Minor Changes
143
+
144
+ - 5bbc3f0: **BREAKING:** `Bench` methods (and exported test helpers) that took 3+ positional parameters now take a single named options object — e.g. `bench.editDocument({documentId, patch})`, `bench.instancesByStage({workflowName, stage})`, `bench.queryInScope({instanceId, groq})`. Behavior is unchanged — only the calling convention; update call sites to pass a single object.
145
+ - 6e51913: Add resource aliases so a definition can reference content portably across deploys. A definition addresses content through a named alias — `@content:<id>` — instead of a hardcoded physical GDR. `deployDefinitions` accepts a `resourceAliases` map binding each alias to a physical `WorkflowResource` (`ResourceAliases`, the sibling of role aliases), and expands every `@<alias>:` reference to its bound physical GDR at deploy: the deployed definition holds only physical references, so no alias and no runtime resolver survive past deploy. Switching environments is just deploying the same source with a different map.
146
+
147
+ Expansion runs in the shared deploy planner, before the content fingerprint, so `deployDefinitions` and `definition diff` always fingerprint the same physical content (they can't drift), and rebinding an alias and redeploying the same source surfaces as changed content — a new version — never a silent shift in what the workflow reads. Deploy fails closed when a definition references an alias the deploy doesn't bind, or when a standalone reference expands to a malformed GDR. Expansion is a structural walk that rewrites references in GROQ and literal ref values only, never in `title`/`description` prose. Bare ids and physical GDRs are unchanged, so single-resource setups need no `resourceAliases`. The test bench accepts `resourceAliases` on `deployDefinitions`.
148
+
149
+ ### Patch Changes
150
+
151
+ - Updated dependencies [416d4c9]
152
+ - Updated dependencies [4dfe72a]
153
+ - Updated dependencies [5bbc3f0]
154
+ - Updated dependencies [0bf7503]
155
+ - Updated dependencies [6e51913]
156
+ - Updated dependencies [3229f41]
157
+ - @sanity/workflow-engine@0.13.0
158
+
159
+ ## 0.7.0
160
+
161
+ ### Minor Changes
162
+
163
+ - 9519c76: **BREAKING:** Rename the structural `Task` node in the workflow DSL to `Activity`. The node holds multiple Actions, its own scoped Fields, and a status lifecycle — it is not atomic, so in process-modelling terms it is an _Activity_ (BPMN: Activity is the umbrella for work, Task the atomic kind). This frees the word "task" so it never appears in the model — "Tasks" is now purely a product/UI worklist view, not a model entity.
164
+
165
+ `Action` (the actor-fired command) is unchanged.
166
+
167
+ The rename cascades across the whole surface:
168
+ - Authoring: `defineTask` → `defineActivity`; the `tasks:` array on a stage → `activities:`; `Task`/`AuthoringTask` types → `Activity`/`AuthoringActivity`.
169
+ - Classification: `TASK_KINDS` → `ACTIVITY_KINDS`, `TaskKind` → `ActivityKind`, `deriveTaskKind`/`taskKind` → `deriveActivityKind`/`activityKind` (kind values `user`/`service`/`script`/`manual`/`receive` are unchanged). The shipped "task kinds" surface is retitled "Activity kinds".
170
+ - Status: `TASK_STATUSES`/`TaskStatus` → `ACTIVITY_STATUSES`/`ActivityStatus` (and the terminal-status counterparts); status _values_ are unchanged.
171
+ - Predicate sentinels: `$tasks` → `$activities`, `$allTasksDone` → `$allActivitiesDone`, `$anyTaskFailed` → `$anyActivityFailed`, `$requiredTasksDone` → `$requiredActivitiesDone`, `$task` → `$activity`. Authored GROQ filters referencing the old names must be updated.
172
+ - Persisted/wire format: the `'task'` field scope value → `'activity'`, history entry types (`taskActivated`/`taskStatusChanged` → `activityActivated`/`activityStatusChanged`), and engine result/diagnostic codes (`failed-task` → `failed-activity`, `task-not-active` → `activity-not-active`, `reset-task` → `reset-activity`, etc.).
173
+ - `workflow-react`: the `fireAction` argument `{task}` → `{activity}`.
174
+
175
+ Nothing referencing "task" remains in the model. This is a deliberate pre-customer breaking change to land the correct, permanent vocabulary.
176
+
177
+ - 9233cd3: Add `engine.instancesForDocument({document})` — the reverse of the watch-set, finding every in-flight instance whose evaluation depends on a given document. The forward watch-set (`subscriptionDocumentsForInstance` / `collectWatchRefs`) answers "which docs does this instance watch"; this answers "which instances watch this doc", which a non-reactive, content-change-driven runtime (a Sanity Function, an Inngest/durable worker) needs to know which instances a changed document should `tick`. It mirrors the forward method's name (`subscriptionDocumentsForInstance` ↔ `instancesForDocument`) and sits alongside the existing `instancesForSubject` / `instancesByStage` read helpers.
178
+
179
+ It matches the same ref set the forward watch-set uses — the instance itself, its ancestors, and the docs named by `doc.ref` / `doc.refs` / `release.ref` field entries on the workflow scope **and the current stage** — via the new pure `instanceWatchesDocument(instance, document)` helper, which derives from `collectWatchRefs`, so forward and reverse stay in lockstep (the same way `hydrateSnapshot`'s load-set does). A coarse GROQ prefilter narrows candidates server-side; the shared matcher does the exact, open-stage-only narrowing. The subject is matched by its full, resource-qualified GDR URI, so a cross-dataset doc never matches a same-id doc in another resource. The test bench gains a matching `bench.instancesForDocument(document)` read helper.
180
+
181
+ This replaces the hand-rolled, incomplete GROQ a content forwarder would otherwise write (which misses stage-scope refs, `release.ref`, and ancestors).
182
+
183
+ ### Patch Changes
184
+
185
+ - Updated dependencies [5b10863]
186
+ - Updated dependencies [ae1434e]
187
+ - Updated dependencies [d850aef]
188
+ - Updated dependencies [9519c76]
189
+ - Updated dependencies [9233cd3]
190
+ - Updated dependencies [3ea4953]
191
+ - Updated dependencies [e5a862a]
192
+ - @sanity/workflow-engine@0.12.0
193
+
194
+ ## 0.6.1
195
+
196
+ ### Patch Changes
197
+
198
+ - Updated dependencies [3b8f3de]
199
+ - @sanity/workflow-engine@0.11.0
200
+
201
+ ## 0.6.0
202
+
203
+ ### Minor Changes
204
+
205
+ - fae3553: **BREAKING:** Rename the domain concept **state → fields** across the whole API and persisted data model.
206
+
207
+ "State" was too easily conflated with "stage". The typed, valued data attached to workflows, stages, and tasks is now called **fields** — clearer, and aligned with Sanity schemas: the fields on a workflow, stage, and task can have typed values. This is a clean break (no migration, no back-compat); already-deployed definitions and instances must be redeployed/recreated.
208
+
209
+ **Authoring DSL**
210
+ - `defineState(...)` → `defineField(...)`
211
+ - The `state: [...]` array on a workflow / stage / task → `fields: [...]`
212
+ - The `$state.<name>` GROQ scope token → `$fields.<name>`
213
+ - A `claim` action's `state:` reference → `field:`
214
+ - A `stateRead` source → `fieldRead`; its `state:` (entry name) → `field:`
215
+ - Op targets address the entry by `field:` instead of `state:`
216
+
217
+ **Persisted / wire format**
218
+ - Op type strings `state.set` / `state.unset` / `state.append` / `state.updateWhere` / `state.removeWhere` → `field.*`
219
+ - The stored `state[]` array on instances, stage entries, and task entries → `fields[]`
220
+ - `startInstance({ initialState })` → `startInstance({ initialFields })`
221
+
222
+ **Types & functions** (engine)
223
+
224
+ `StateEntry`→`FieldEntry`, `AuthoringStateEntry`→`AuthoringFieldEntry`, `ResolvedStateEntry`→`ResolvedFieldEntry`, `InitialStateValue`→`InitialFieldValue`, `StateKind`→`FieldKind`, `StateValueMap`→`FieldValueMap`, `StateScope`→`FieldScope`, `StoredStateRef`→`StoredFieldRef`, `STATE_SLOT_DISPLAY`→`FIELD_SLOT_DISPLAY`, `EditStateArgs`→`EditFieldArgs`, `EditStateTarget`→`EditFieldTarget`, `EditStateDeniedError`→`EditFieldDeniedError`, `ConcurrentEditStateError`→`ConcurrentEditFieldError`, `RequiredStateNotProvidedError`→`RequiredFieldNotProvidedError`, and the `editState` verb → `editField`.
225
+
226
+ Unchanged: runtime/lifecycle instance "state" (stage moves, `WorkflowStateDivergedError`, the projected instance snapshot), the `field`/`fields` JSON-matching primitives in `updateWhere`/`removeWhere` predicates and object Sources, and the Content Lake release `state`.
227
+
228
+ ### Patch Changes
229
+
230
+ - Updated dependencies [fae3553]
231
+ - @sanity/workflow-engine@0.10.0
232
+
233
+ ## 0.5.0
234
+
235
+ ### Minor Changes
236
+
237
+ - 2e4c980: Add a generic edit seam for declared-editable workflow state.
238
+
239
+ State slots can now declare `editable` (default off) and be written directly
240
+ through one `editState` verb — reassign, reschedule, claim-by-hand, append to a
241
+ log — instead of a bespoke action per field.
242
+ - `editable` on a state slot — default off. _Who_ may edit is expressed with
243
+ the gating actions already have, not a new permission concept: `true` (anyone
244
+ in the slot's scope window), a **role list** (the same `action.roles` sugar —
245
+ it desugars to the identical `count($actor.roles[@ in [...]]) > 0` membership
246
+ predicate), or a raw GROQ predicate (checked like `action.filter`). A stage
247
+ may TIGHTEN a slot via an `editable` override map — the override is ANDed with
248
+ the slot's baseline, so it can only narrow, never open a closed slot.
249
+ - `editState` engine verb (and `engine.editState` / `instanceSession.editState`):
250
+ gates on the slot's editability and scope window, applies the edit as a
251
+ `state.*` op (so provenance + history are stamped by the op path), refreshes
252
+ the stage's guards, then cascades — an edit to a value a transition reads can
253
+ move the instance. Advisory like every engine gate; the lake ACL + guards are
254
+ the only hard locks.
255
+ - Scope is the editable window: a workflow slot is editable while the instance
256
+ is live, a stage slot while its stage is current, a task slot while its task
257
+ is active.
258
+ - The projection gains `editableSlots`: each declared-editable slot in scope
259
+ with this actor's edit verdict and the current value's provenance (`setBy` /
260
+ `setAt`, read off `opApplied` history — no denormalised copy). `EditDisabledReason`
261
+ and `EditStateDeniedError` mirror the action verdict surface.
262
+ - `@sanity/workflow-react`'s controller exposes `editState`, threaded through
263
+ the Studio and App SDK adapters.
264
+
265
+ ### Patch Changes
266
+
267
+ - fd6b866: Add role aliasing to the authoring DSL: a definition-level `roleAliases` map
268
+ ("can be fulfilled by"). It's an authoring convenience — the same capability is
269
+ often carried by different role names depending on how a project deploys its
270
+ Content Lake roles, so rather than enumerate every equivalent role inline in
271
+ each `roles` gate (or fork the definition per deployment), declare once which
272
+ other roles fulfill it. Each key is a role a gate or `assignees` entry names;
273
+ its value lists the roles that also satisfy it. The reserved `"*"` key lists
274
+ roles that fulfill any gate (e.g. `{ "*": ["administrator"] }` for a
275
+ deployment's broad role).
276
+
277
+ The map expands the **required** side — never the actor's roles — and is applied
278
+ in both places roles are matched: the `roles` gate bakes the expanded membership
279
+ into its desugared GROQ at define time, and `$assigned` resolves the assignee's
280
+ role through the map at runtime. Expansion is one level (non-transitive).
281
+
282
+ This also corrects the misleading `Actor."*"` documentation: an actor's `roles`
283
+ are matched by literal membership only — there is no actor-side wildcard — with
284
+ the definition's `roleAliases` as the sole mechanism for widening, so the
285
+ advisory gate can never grant access the actor was not actually given. Like
286
+ every engine gate it is advisory; the registrant owns keeping the map true to
287
+ the deployment's real Content Lake ACLs.
288
+
289
+ - Updated dependencies [2e4c980]
290
+ - Updated dependencies [fd6b866]
291
+ - @sanity/workflow-engine@0.9.0
292
+
293
+ ## 0.4.2
294
+
295
+ ### Patch Changes
296
+
297
+ - Updated dependencies [c298ee2]
298
+ - Updated dependencies [8131dd5]
299
+ - Updated dependencies [8bf467e]
300
+ - @sanity/workflow-engine@0.8.0
301
+
302
+ ## 0.4.1
303
+
304
+ ### Patch Changes
305
+
306
+ - Updated dependencies [ef927bc]
307
+ - Updated dependencies [32f5e64]
308
+ - Updated dependencies [df7582f]
309
+ - Updated dependencies [ed4174c]
310
+ - Updated dependencies [129258c]
311
+ - @sanity/workflow-engine@0.7.0
312
+
313
+ ## 0.4.0
314
+
315
+ ### Minor Changes
316
+
317
+ - c6d1762: Model the engine tag as a single environment partition: `tags: string[]` becomes `tag: string`.
318
+
319
+ The array + set-intersection matching was over-modelled — an engine operates against exactly one environment (test vs prod) at a time. `createEngine`, the raw `workflow.*` args (`DeployDefinitionsArgs`, `StartInstanceArgs`, `OperationArgs`, `EvaluateArgs`, `DeleteDefinitionArgs`), and the stored `workflow.definition` / `workflow.instance` documents now carry a single `tag`. Reads filter by equality (`tag == $tag`) instead of intersection, and `workflow.query` requires a `$tag` filter rather than `$engineTags`. Consumer-authored `type: "query"` state entries that referenced the `$engineTags` scope param now receive `$tag` (a single string) instead. `validateTags` is replaced by `validateTag`, and `canonicalTag` is removed (the tag is the ID prefix directly).
320
+
321
+ The `tag` is **required** — there is no default. The engine enforces nothing, so the partition is the only thing keeping reads and writes off the wrong environment; an engine must not guess. `createEngine` requires it, and the CLI (`WORKFLOW_TAG` / `--tag`) and MCP server (`WORKFLOW_TAG`) both fail at startup when it's absent.
322
+
323
+ The test bench's `createBench({ tags })` option becomes `createBench({ tag })`.
324
+
325
+ ### Patch Changes
326
+
327
+ - Updated dependencies [c6d1762]
328
+ - @sanity/workflow-engine@0.6.0
329
+
330
+ ## 0.3.1
331
+
332
+ ### Patch Changes
333
+
334
+ - Updated dependencies [dbc4afa]
335
+ - Updated dependencies [e2a6d5d]
336
+ - Updated dependencies [aa87a1c]
337
+ - @sanity/workflow-engine@0.5.0
338
+
339
+ ## 0.3.0
340
+
341
+ ### Minor Changes
342
+
343
+ - fcf938a: Rebuild the authoring DSL and stored model around generic primitives plus
344
+ define-time sugar. Breaking across the board:
345
+ - **Name identity everywhere.** `name` replaces `id`/`workflowId` on
346
+ definitions, stages, tasks, actions, transitions, state entries, and
347
+ effects; instances carry `definition` + `currentStage` by name. The API
348
+ boundary follows: `startInstance({definition})`,
349
+ `guardsForDefinition({definition})`, `children({task})`, deploy results
350
+ carry `definition`, and guard provenance is `sourceDefinition` /
351
+ `sourceStage`. The pre-rebuild "slot" vocabulary is gone — state values
352
+ validate as state entries (`StateValueShapeError` with `entryType` /
353
+ `entryName`). Bare
354
+ in-array `type` discriminators replace namespaced `_type` kinds
355
+ (`doc.ref`, `assignees`, `state.set`, …). Engine-owned lake doc types are
356
+ `sanity.workflow.definition` / `sanity.workflow.instance`.
357
+ - **Two-layer schemas.** Authoring shapes (primitives + sugar) desugar at
358
+ define-time into the stored definition: the claim pair, the `audit` op
359
+ (stamped append), `roles`/`status` field sugar, lexical scope resolution
360
+ for op targets, and the default `$allTasksDone` transition filter.
361
+ State-entry names and predicate keys must be GROQ-safe identifiers (they
362
+ are referenced as `$state.<name>` / `$<name>` in conditions). Deploy
363
+ invariants reject duplicate names (stages, tasks, actions, transitions,
364
+ state entries per scope, guards across the definition, effects), unknown
365
+ targets, reserved-var shadowing, predicate cross-references and
366
+ caller-scoped vars in predicates, `$can` outside action filters, and
367
+ malformed guard reads.
368
+ - **Ops are the only mutation vocabulary.** `state.set` / `state.unset` /
369
+ `state.append` / `state.updateWhere` / `state.removeWhere` /
370
+ `status.set`, with `{scope, state}` targets resolved at deploy and
371
+ `status.set` as the single task-resolution mechanism (stamps completion +
372
+ history).
373
+ - **Lifecycle payloads.** Tasks own activation (`activation: 'auto' |
374
+ 'manual'`, machine-step auto-resolution, `completeWhen`/`failWhen`,
375
+ `subworkflows` fan-out with `$subworkflows` completion gates and parent
376
+ `context` handoff); transitions own exit/arrival (always-filtered,
377
+ first-truthy wins, carry ops + effects); terminal stages are structural
378
+ (no transitions).
379
+ - **One gate mechanism.** Conditions are GROQ over the rendered scope
380
+ (`$self`, `$state`, `$actor`, `$assigned`, `$can`, `$effects`, `$tasks`,
381
+ `$subworkflows`, `$now`, …) with author predicates pre-bound as boolean
382
+ params. Everything engine-side is advisory; hard enforcement is the lake
383
+ ACL plus guards (`temp.system.guard` docs with `metadata` projections and
384
+ name-derived ids).
385
+ - **Effects registry.** Effects are `{name, bindings, input}` with
386
+ GROQ-string bindings over the rendered scope; completed-effect `outputs`
387
+ land in `effectsContext` namespaced under the effect's name and read as
388
+ `$effects['<effect name>'].<output>`.
389
+
390
+ The test bench mirrors the new surface (`task` instead of `taskId`,
391
+ name-keyed reads, `effectsContextMap` returning the rendered `$effects`
392
+ map).
393
+
394
+ - 6c4e0a0: Add `guardsForDefinition` — find every guard a workflow deployed without needing a live instance. Guard docs are stamped with the version-less workflowId, so it spans the datasources statically named across all deployed versions: the workflow resource, plus any resource a guard idRef names statically (a hardcoded GDR literal, directly or via a `stateRead` of a slot whose source is a literal GDR). Guards whose resource is only known per-instance (a `subject`/`init`-sourced slot) are not reachable this way — `guardsForInstance` covers those. Exposed on the engine (`engine.guardsForDefinition({workflowId})`) and the bench (`bench.guardsForDefinition(workflowId)`), reusing the existing `GUARD_DOC_TYPE` + provenance fields.
395
+
396
+ ### Patch Changes
397
+
398
+ - 12e23b0: New shared primitives so consumers and tests build the engine's shapes exactly the way the engine does:
399
+ - `datasetResourceParts(id)` — the one parser for `<projectId>.<dataset>` resource ids (`gdrFromResource` and the React adapters' routing both delegate to it; malformed ids now fail on empty parts too).
400
+ - `subscriptionDocument(ref)` — build one `SubscriptionDocument` from a `GlobalDocumentReference`, the same construction `subscriptionDocumentsForInstance` uses.
401
+ - `MutationGuardDeniedError.fromGuards({documentId, action, guards})` — the one mapping from denying guard docs to the structured `denied` payload, used by the engine's pre-flight and the test bench's write seam.
402
+
403
+ - Updated dependencies [4b5f59a]
404
+ - Updated dependencies [fcf938a]
405
+ - Updated dependencies [6c4e0a0]
406
+ - Updated dependencies [12e23b0]
407
+ - Updated dependencies [12e23b0]
408
+ - @sanity/workflow-engine@0.4.0
409
+
410
+ ## 0.2.1
411
+
412
+ ### Patch Changes
413
+
414
+ - 0c6ccde: Compose the engine's shared `WORKFLOW_INSTANCE_TYPE` constant and `tagScopeFilter()` predicate in the test bench's read helpers (`instancesForSubject`, `instancesByStage`) instead of hand-writing the `"workflow.instance"` type literal and the `count(tags[@ in $engineTags]) > 0` tag-scope predicate.
415
+ - Updated dependencies [41ddd80]
416
+ - Updated dependencies [0c6ccde]
417
+ - @sanity/workflow-engine@0.3.0
418
+
419
+ ## 0.2.0
420
+
421
+ ### Minor Changes
422
+
423
+ - 2b800a8: Enforce lake mutation guards at the client write seam. The bench-minted `TestClient` is now wired with `@sanity-labs/client-fake-for-test@^0.4.0`'s `mutationGuard` hook, backed by the engine's `denyingGuards` — so **every** write through the bench client is gated against deployed guards (incl. the engine's own content writes), exactly where the real lake enforces, not just in `bench.editDocument`. `editDocument` now writes through the client; its `{as}` actor routes through a token-scoped `withConfig({ accessControl })` sibling so `identity()` resolves correctly. Guard evaluation stays in `@sanity/workflow-engine` (one code path); the client only provides the seam.
424
+ - ce6960d: Guard provenance + housekeeping queries.
425
+ - Mutation guard docs now carry explicit `sourceInstanceId` / `sourceDefinitionId` / `sourceStageId` provenance fields, so the engine can find its guards for coherency refresh and housekeeping without parsing them out of `_id`.
426
+ - New `engine.guardsForInstance({instanceId})` returns every guard an instance registered, unioned across the resource the instance lives in and the resource of each `doc.ref`/`doc.refs` GDR it holds in state (same "if it's in the workflow, we pull it in" scope as snapshot hydration).
427
+ - New exported `guardsForInstance` / `guardsForResource` helpers and a centralized `GUARD_DOC_TYPE` constant (single rename point for the eventual `temp.` prefix drop).
428
+ - The bench exposes `guardsForInstance`; its `listGuards` now delegates to `guardsForResource`.
429
+
430
+ - 0432670: Injectable clock for deterministic time. The engine now threads a single
431
+ `Clock` (`() => string`, default wall-clock) through each verb-driven
432
+ operation, taking one reading per operation so `$now` in filters, the `now`
433
+ op-source, and the timestamps the engine stamps (history `at`, `completedAt`,
434
+ `enteredAt`, `lastChangedAt`, queued/ran effect stamps, query-slot `resolvedAt`)
435
+ all share one instant. Production passes nothing and is unchanged. The clock is
436
+ injected ONCE — via `createEngine({ clock })`, or the test bench's `setNow` /
437
+ `advance` — not as a per-verb option; the raw `workflow.*` verbs accept it only
438
+ through an internal seam, keeping the public `*Args` interfaces free of it. The
439
+ external effect drainer (`drainEffects`) is out of scope and still uses
440
+ wall-clock. The pure
441
+ core (`buildParams`, `resolveSource`) now takes `now` as a value rather than
442
+ reading wall-clock itself, restoring `core/`'s "no `new Date()`" contract.
443
+ `Clock` and `wallClock` are exported from the package root.
444
+
445
+ The bench (`@sanity/workflow-engine-test`) exposes the knob: `bench.setNow(iso)`
446
+ freezes time, `bench.advance(ms)` moves it forward, `bench.now()` reads it, and
447
+ `createBench({ now })` starts frozen. The clock is threaded into every wrapped
448
+ verb, so a time-based `completeWhen` / `failWhen` / SLA predicate fires by
449
+ advancing the clock and ticking — without mutating the subject document.
450
+
451
+ Also removes the never-implemented `dueAt` hint field from `TaskEntry`: nothing
452
+ ever wrote or read it, so it carried no behaviour. Re-add it with a real
453
+ implementation if a cron-indexing fast-path is built later.
454
+
455
+ ### Patch Changes
456
+
457
+ - a4f6d81: Bump `@sanity-labs/client-fake-for-test` to 0.5.0 for its new
458
+ `simulateConcurrentEdit` helper, which the bench surfaces (via `bench.client`)
459
+ to drive optimistic-locking races deterministically — used by the concurrent
460
+ `fireAction` coverage.
461
+ - 95d7a31: Add a GROQ parity gate that pins the bench's `groq-js` evaluation against a real
462
+ Content Lake. A shared corpus of representative query shapes — filters, nested
463
+ array traversal, aggregations, computed projections, `in`-sets, reference
464
+ matches, and dereferences — runs in CI in-memory (`groq-parity.test.ts`), and an
465
+ on-demand `test:parity:live` script replays the identical cases against a live
466
+ dataset to catch evaluator drift. The live gate is excluded from the default
467
+ `vitest run` and skips itself without `SANITY_*` creds.
468
+ - Updated dependencies [d04cbea]
469
+ - Updated dependencies [870188e]
470
+ - Updated dependencies [a4f6d81]
471
+ - Updated dependencies [eff2a77]
472
+ - Updated dependencies [ce6960d]
473
+ - Updated dependencies [dff88ff]
474
+ - Updated dependencies [782272a]
475
+ - Updated dependencies [2b800a8]
476
+ - Updated dependencies [0432670]
477
+ - Updated dependencies [53a0598]
478
+ - Updated dependencies [c2ad7a9]
479
+ - @sanity/workflow-engine@0.2.0
480
+
481
+ ## 0.1.0
482
+
483
+ ### Minor Changes
484
+
485
+ - 43d6b81: Initial release.
486
+ - **`@sanity/workflow-engine`** — a workflow / BPM engine for Sanity content. Define workflows as data (stages, tasks, actions, guards, effects), run them as instances against a Sanity client, gate transitions on GROQ predicates, and queue side effects for runtimes to drain. Multi-tenant via engine-scope tags; ships a typed authoring surface under `@sanity/workflow-engine/define`.
487
+ - **`@sanity/workflow-engine-test`** — an in-memory `createBench` harness for exercising workflows against the engine without a live Sanity client (deploy definitions, start instances, fire actions, assert state).
488
+
489
+ ### Patch Changes
490
+
491
+ - Updated dependencies [43d6b81]
492
+ - Updated dependencies [127be08]
493
+ - Updated dependencies [bf62213]
494
+ - @sanity/workflow-engine@0.1.0
package/README.md CHANGED
@@ -13,6 +13,57 @@ boilerplate.
13
13
  npm install -D @sanity/workflow-engine-test
14
14
  ```
15
15
 
16
+ ## The bench
17
+
18
+ `createBench()` builds an isolated bench: the real engine, a fake in-memory
19
+ Sanity client, an all-access default actor, and a bench-owned clock. Every
20
+ wrapped verb injects the bench's client, tag, workflow resource, access
21
+ default, and clock, so a test reads as the workflow it exercises:
22
+
23
+ - **Runtime verbs** — `deployDefinitions`, `startInstance`, `fireAction`,
24
+ `editField`, `completeEffect` (and `completePendingEffect`, addressed by
25
+ effect name), `tick`, `evaluate`.
26
+ - **Instance-admin overrides** — `setStage`, `abortInstance`. Same injection,
27
+ so a forced move into a deadline-gated stage evaluates `$now` at bench time,
28
+ not wall-clock.
29
+ - **Reads and probes** — `getInstance`, `currentStage`, `activityStatus`,
30
+ `pendingEffects`, `children`, `instancesForSubject`, `instancesByStage`,
31
+ `instancesForDocument`, `query`, plus the guard helpers below.
32
+
33
+ ```ts
34
+ const bench = createBench({now: '2026-01-01T00:00:00Z'})
35
+ await bench.deployDefinitions({definitions: [reviewWorkflow]})
36
+
37
+ const {instance} = await bench.startInstance({
38
+ definition: 'review-workflow',
39
+ initialFields: [subjectField('article-1')],
40
+ })
41
+ await bench.fireAction({instanceId: instance._id, activity: 'review', action: 'approve'})
42
+ expect(await bench.currentStage(instance._id)).toBe('approved')
43
+
44
+ // Admin overrides ride the same defaults:
45
+ await bench.abortInstance({instanceId: instance._id, reason: 'hard stop'})
46
+ ```
47
+
48
+ `subjectField(docId)` and `releaseField(name)` build the runtime
49
+ initial-field entries for the bench's default resource, so tests never
50
+ hand-roll GDR URI strings. For denial paths, pass a restrictive
51
+ `currentUser` / `grants` (or a full `access`) at construction, or override
52
+ per call with the `actor` shortcut / an explicit `access`.
53
+
54
+ **Time is bench-owned.** `createBench({now})` starts frozen; `setNow` /
55
+ `advance` move it. Every wrapped verb evaluates `$now` and stamps history at
56
+ bench time, so deadline predicates are tested by advancing the clock and
57
+ ticking — never by waiting.
58
+
59
+ ## Out-of-band engines
60
+
61
+ Some tests need an engine of their own — custom effect handlers, racing two
62
+ drainers, a `deleteDefinition` call. `createBenchEngine(bench, overrides?)`
63
+ builds one on the bench's client, inheriting its tag, workflow resource, and
64
+ clock (`overrides` win). The bench exposes `tag` and `workflowResource` for
65
+ raw `workflow.*` calls, and the default tag ships as the `BENCH_TAG` constant.
66
+
16
67
  ## Mutation guards
17
68
 
18
69
  A workflow stage can deploy **mutation guards** — `temp.system.guard` documents
@@ -26,10 +77,10 @@ once the lake enforces them. There are two distinct things you test, and the
26
77
  bench gives you a helper for each:
27
78
 
28
79
  - **Write-time rejection (simulated)** — `bench.editDocument({documentId, patch})`
29
- writes through the bench client, and a write a deployed guard denies throws
30
- `MutationGuardDeniedError` the rejection the lake is designed to apply to
31
- _any_ client once guard enforcement ships; the bench reproduces it on the
32
- client it mints.
80
+ writes through the bench client (`patch` is a `{set?, unset?, insert?}` spec),
81
+ and a write a deployed guard denies throws `MutationGuardDeniedError` the
82
+ rejection the lake is designed to apply to _any_ client once guard
83
+ enforcement ships; the bench reproduces it on the client it mints.
33
84
  - **Read-time preflight** — `bench.activeGuardsForDocument(id)` answers "_would_
34
85
  a write be denied right now?" _without_ writing. This is the check a UI uses
35
86
  to disable a button or show a lock ahead of time. It's a pure read over the
@@ -46,9 +97,9 @@ const bench = createBench({documents: [{_id: 'doc-1', _type: 'article', body: 'b
46
97
  const active = await bench.activeGuardsForDocument('doc-1')
47
98
 
48
99
  // Rejection: a guard-violating write throws (bench-simulated lake contract).
49
- await expect(bench.editDocument({documentId: 'doc-1', patch: {body: 'edited'}})).rejects.toThrow(
50
- MutationGuardDeniedError,
51
- )
100
+ await expect(
101
+ bench.editDocument({documentId: 'doc-1', patch: {set: {body: 'edited'}}}),
102
+ ).rejects.toThrow(MutationGuardDeniedError)
52
103
  ```
53
104
 
54
105
  > To share one store across benches with the write seam intact, pass another