@sanity/workflow-engine 0.14.0 → 0.16.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 +779 -0
- package/DATAMODEL.md +252 -0
- package/README.md +2 -2
- package/dist/_chunks-cjs/invariants.cjs +3208 -0
- package/dist/_chunks-es/invariants.js +2976 -0
- package/dist/define.cjs +106 -944
- package/dist/define.d.cts +263 -411
- package/dist/define.d.ts +263 -411
- package/dist/define.js +81 -941
- package/dist/index.cjs +9882 -5220
- package/dist/index.d.cts +3099 -724
- package/dist/index.d.ts +3099 -724
- package/dist/index.js +9468 -5284
- package/package.json +9 -5
- package/dist/_chunks-cjs/schema.cjs +0 -1289
- package/dist/_chunks-cjs/schema.cjs.map +0 -1
- package/dist/_chunks-es/schema.js +0 -1274
- package/dist/_chunks-es/schema.js.map +0 -1
- package/dist/define.cjs.map +0 -1
- package/dist/define.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
# @sanity/workflow-engine
|
|
2
|
+
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 11e2d6f: Export `DEFAULT_TRANSITION_WHEN` — the trigger a transition desugars to when the author declares none — so consumers describing transition triggers can recognize the default's spelling instead of copying the literal.
|
|
8
|
+
- 7510fd5: Delivery-surface helpers and an honest remediation contract. The start-surface boundary is now engine-owned and shared: `buildInitialFields` (typing caller-supplied start values against a workflow's declared field entries) and `startRefusal` (the spawn-only refusal message), both previously private to the CLI. Also exported: `documentPrefilter` and `inFlightFilter` (the instance-list document and in-flight arms, for list builders that compose their own conditions) and `parseDefinitionInput` (the deploy boundary's envelope-strip + stored-form parse). The `drain-effects` remediation verb is now flagged `available: true` — the engine's `drainEffects` verb ships, so a hung-effect diagnosis names a fix the consumer can actually run instead of describing it as not yet callable.
|
|
9
|
+
- 79c54db: Reactive session reads now match the engine's own reads.
|
|
10
|
+
- The session feeder projects every observed doc onto its watch ref's identity the way the lake's perspective reads do: the published-form id becomes `_id` (Studio/SDK stores resolve drafts and release versions under `drafts.*`/`versions.*` ids, which keyed the session's overlay and snapshot where no transition `when` condition or `$fields.<docref>` deref ever looks — an editor's uncommitted draft could not satisfy a condition the engine's `tick()` would read as satisfied), and a draft/version's stored id rides along as `_originalId`, so conditions like `_originalId in path("versions.**")` read what the engine's own hydration returns. The projection is an engine export (`projectToWatchRef`), one encoding beside `contentDraftFallback`/`contentDocQuery`, and it is strict: only the exact representations a store may legitimately resolve under the instance's perspective are accepted — anything else (another doc's dotted id under a version prefix, a version from an unbound release, a draft the perspective makes invisible) fails loudly instead of being fed under the watched identity.
|
|
11
|
+
- Watches whose perspective makes drafts invisible to the engine no longer fall back to the draft. The engine hydrates a `[release]` stack as version-over-published (and `'published'`/`'raw'` scalars as published/raw) with drafts invisible; the Studio adapter resolved `version ?? draft ?? published` and the SDK's per-doc store has the same fallback built in. The Studio adapter now skips the draft arm unless the effective perspective names `'drafts'`, and the SDK adapter observes such refs through the query store under the instance's exact perspective stack. The SDK's query-store route reads committed state (live), so local uncommitted edits to a release version doc no longer feed the session there — Studio keeps its optimistic `editState` reads. New engine helpers `contentDraftFallback` and `contentDocQuery` are the single encoding of the rule, shared with the engine's own hydration.
|
|
12
|
+
- `useWorkflowSession` resets its surfaced `evaluation` to `undefined` on an `instanceId` swap, as the contract promises, instead of leaving the previous instance's projection visible until the new instance first evaluates.
|
|
13
|
+
|
|
14
|
+
- 6a46db1: **BREAKING:** the definition root's `applicableWhen` moved to `start.filter`, inside a new `start` block — `{kind?: 'interactive' | 'autonomous', filter?: GROQ}` — describing how standalone runs begin. No alias, no dual-read: definitions authored with `applicableWhen` fail the strict parse until rewritten.
|
|
15
|
+
- `start.kind` classifies who initiates a run — a person from a start surface (`'interactive'`, the default; `startKindOf(def)` is the one reading rule) or a system reacting to a document (`'autonomous'`, hidden from human start pickers). Classification only: `startInstance` stays one verb and one code path for every caller and kind — an interactive start of an autonomous workflow is legal. Deploy invariants: a spawn-only (`lifecycle: 'child'`) definition may not declare `start`; an `'autonomous'` kind requires every required input entry to be subject-style (`doc.ref`/`doc.refs` — the shared `isSubjectEntry` rule); a `start.filter` may read only declared **input-sourced** entries via `$fields` (a typo or a query/literal entry is a silent never-pass); and a root-reading filter (`_type == …`, `@`, or a `^` escaping a scan) requires a subject entry, since a read surface with no subject never binds a root.
|
|
16
|
+
- `start.filter` is a **read-side visibility rule, not a gate** — `startInstance` never evaluates it and never throws on it; it starts whenever the caller supplies the required inputs. The filter decides what appears in / is chosen from Start lists: `definitionsForDocument`/applicability and the Studio start control evaluate it in a widened, named start-filter context (`START_FILTER_VARS`) — the candidate document as root, `$tag`/`$definition`/`$now`/`$fields` bound (`$fields` = the caller's input entries as GDR envelopes, so `$fields.<entry>.id` is the GDR URI), and `*[...]` = the workflow resource's dataset. Filters that never scan keep the cheap pure evaluation (`analyzeCondition(...).readsDataset` decides); scanning filters evaluate over a fetched slice of EVERY instance in the tag (completed included — `*` carries no hidden predicate; filters qualify in-flight themselves with `!defined(completedAt)`), so a filter can bound what surfaces (WIP limits, one-run-per-subject). Absent bindings evaluate GROQ-null and fail closed.
|
|
17
|
+
|
|
18
|
+
- 35b9b85: Commit/recovery hardening and a locale-independent content fingerprint:
|
|
19
|
+
- `completeEffect` now commits under the engine's shared optimistic-locking retry, like every other claim-protocol write: a lost revision race reloads and re-commits the same reported outcome instead of escaping as a raw 409 — which discarded the handler's outputs and re-dispatched the effect after lease expiry, running the external side effect twice. Exhausting every attempt throws the new `ConcurrentCompleteEffectError` (kind `concurrent-complete-effect`).
|
|
20
|
+
- A retried `abortInstance` of an already-terminal instance now propagates to ancestors unconditionally, matching the condemned-children drain: a crash between the terminal commit and the ancestor walk no longer wedges the parent's subworkflow gate on a child that already died.
|
|
21
|
+
- `hashDefinitionContent` and the model-shape canonicaliser sort object keys by UTF-16 code unit instead of the locale-dependent `localeCompare`, so identical definitions fingerprint identically on every machine. A definition whose key ordering differed under the deploying machine's locale hashes differently once — the next redeploy of such content mints one new version instead of reading as unchanged.
|
|
22
|
+
|
|
23
|
+
- e5f5b77: **BREAKING:** actions become the only payload mechanism — every op, effect, and spawn in the system lives on an action; structure never does.
|
|
24
|
+
- Actions gain `when` (cascade-fired trigger: the engine fires it the moment it's true, at most once per stage visit, level-triggered; never fireAction-able) and `spawn` (the subworkflow block, unlocking caller-triggered and conditional spawn). Fires-on-entry is spelled `when: 'true'`.
|
|
25
|
+
- Deleted from the model: `activation`, activity-level `ops`/`effects`/`subworkflows`, `completeWhen`/`failWhen`, `kind`, and transition `ops`/`effects`. Transitions are pure edges `{name, when, to}` (the condition key renamed from `filter` — trigger semantics). `target` survives as the off-system marker; `kind` is now always derived, plus a new derived executor classification (autonomous / interactive / off-system / hybrid) on activity evaluations.
|
|
26
|
+
- Activities are `active` from stage entry (no `pending` status, no activation moment, no machine-step implicit); a new deploy invariant requires every activity to have a reachable path to a terminal status.
|
|
27
|
+
- A cascade hop is ONE transaction: the current stage's triggered actions run to in-memory fixpoint, then transition selection, then a single CAS commit; chain hops stay separate commits. The per-visit `firedActions` ledger on the activity entry re-arms on re-entry (a stage resets on re-entry). The cascading token executes triggered actions; `roles` on a triggered action pins which identities may execute it (an incapable token leaves it armed).
|
|
28
|
+
- The context bag split: `$effects` now carries completed effects' outputs ONLY (derived from `effectHistory`), and the new `$context` carries the `startInstance` seed plus a parent's `spawn.context` handoff (the persisted `effectsContext` field renamed `context`; `startInstance`'s seed arg renamed accordingly). `$effectStatus` is unchanged.
|
|
29
|
+
- Spawn registry rows key per (activity entry, spawning action), so two spawn actions on one activity can never cross-adopt each other's children.
|
|
30
|
+
- `fireAction` rejects a `when` action (`cascade-fired` verdict); evaluation projects triggered actions as narratable (`triggered: true` + `whenInsight`), never as buttons.
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- 01e8042: Fix `fieldRead` values landing in `doc.ref`-kinded slots: a bare (no-path) read of a `doc.ref` source now preserves the stored reference instead of handing over the dereferenced document wherever the result feeds a `doc.ref`-kinded slot — a `doc.ref` entry's `initialValue` seed (the mirror pattern no longer fails `startInstance` with `FieldValueShapeError`), a `field.set` op targeting a `doc.ref` entry, a `field.append` op item landing in a `doc.refs` entry, and `doc.ref` sub-fields reached through an object-expression op value (`field.set` object entries, `field.append` rows, `field.updateWhere` merges — sub-values resolve against the entry's declared sub-field shapes). Identity preservation applies only to bare reads into `doc.ref`-kinded slots; every other surface (conditions, effect bindings, guard metadata, path-carrying reads, undeclared object-expression keys) keeps the doc-centric dereference.
|
|
35
|
+
- 01e8042: Close the unvalidated `field.updateWhere` write path. The merged rows now pass the same shape gate as `field.set` / `field.append` — a wrong-kinded merge value for a declared row sub-field throws `FieldValueShapeError` and aborts the commit instead of persisting silently. An `updateWhere` may only target an `array` entry (the one kind whose rows carry declared sub-fields): a definition-carried op against any other kind is rejected at define/deploy time, and the op applier enforces the same gate for the boundaries deploy never sees (effect-completion ops, definition/instance divergence) — previously a merge into a `doc.refs` entry spread the merge value's fields (for a bare `fieldRead` of a `doc.ref`, the whole dereferenced document) into the stored GDR rows with no error. A merge may also never write the reserved row keys `_key` / `_type` — row identity and bookkeeping are engine-stamped, and a merge would restamp every matched row with the same literal; statically-named keys are rejected at define/deploy, and the applier re-checks the resolved merge. `field.removeWhere` is unchanged and keeps accepting every array-valued kind, `doc.refs` included — dropping rows needs no row shape.
|
|
36
|
+
- Updated dependencies [7510fd5]
|
|
37
|
+
- @sanity/groq-condition-describe@0.2.0
|
|
38
|
+
|
|
39
|
+
## 0.15.0
|
|
40
|
+
|
|
41
|
+
### Minor Changes
|
|
42
|
+
|
|
43
|
+
- 4ca17bb: **BREAKING:** Deploy validation now rejects caller-bound vars (`$actor`, `$assigned`, `$params`) in the cascade's caller-free gates — transition filters and activity `filter`/`completeWhen`/`failWhen` — exactly as `$can` already was. The cascade never binds a caller at those sites, so such a gate either routed wrong (a comparison against the unbound var fails closed and a later catch-all fires) or wedged the instance (a raw caller read is unevaluable, which halts transition selection), while the caller-bound evaluation projection reported the same gate satisfied.
|
|
44
|
+
|
|
45
|
+
The evaluation projection now also evaluates transition filters caller-free, so `filterSatisfied`/`unevaluable` report the route the cascade will actually take — including for definitions deployed before this validation existed.
|
|
46
|
+
|
|
47
|
+
Variable-read detection in these checks (and the predicate-body checks) is now AST-exact via groq-js instead of a regex scan, so a `$actor` inside a GROQ string literal no longer counts as a read.
|
|
48
|
+
|
|
49
|
+
`validateDefinition` now re-runs the cross-field invariants (`checkWorkflowInvariants`), so `deployDefinitions`, the CLI, and the MCP validate tool reject a stored-shape definition that never went through `defineWorkflow` — deploy enforces exactly what authoring enforces.
|
|
50
|
+
|
|
51
|
+
- 5143707: **BREAKING:** An abort now records the pending effects it cancels with a real `cancelled` status instead of `failed` — a cancellation is a discarded run, not a failure, so failure counters (dashboards, alerting, retry tooling) no longer see aborted-away effects. The effect-run vocabulary is exported as `EffectRunStatus` (`'done' | 'failed' | 'cancelled'`) and now types `EffectHistoryEntry.status`, the `effectCompleted` history entry, and the `$effectStatus` projection; exhaustive switches over the old two-value union must add the `cancelled` arm. Completion reporting is unchanged in type — `completeEffect` accepts only the new `EffectCompletionStatus` (`'done' | 'failed'`) — and now also refuses anything else at runtime with `ContractViolationError`, so a plain-JS completer cannot forge a cancellation.
|
|
52
|
+
|
|
53
|
+
An abort also writes one `effectCompleted` settle event per cancelled effect (paired with the `effectHistory` row by `effectKey`, built by the same constructor as a reported completion's), so the event trail accounts for every queued effect the same way reported completions do.
|
|
54
|
+
|
|
55
|
+
`EffectNotFoundError` now carries an optional `settled: {status, ranAt, detail?}` (exported as `EffectSettledInfo`) when the missing key matches an `effectHistory` row, with the message saying how the entry settled — so a completer can tell a double delivery (`done`/`failed`: treat as already-done) from an abort's cancellation (`cancelled`: the reported work was discarded) instead of guessing from a bare not-found. `drainEffects` uses it too: its `lost`-bucket warning now says "cancelled by an abort during dispatch" when that's what happened, instead of guessing "completed by another party".
|
|
56
|
+
|
|
57
|
+
- 0709ab1: **BREAKING:** `DisabledReason` and `EditDisabledReason` gain an `{kind: 'instance-aborted', abortedAt}` arm — aborted instances now report it instead of `instance-completed` on action verdicts, edit-denial reasons, and the thrown `ActionDisabledError`/`EditFieldDeniedError` (whose detail reads "instance aborted at …"). An exhaustive `switch` over either union needs a new case.
|
|
58
|
+
|
|
59
|
+
Promote three canonical helpers to the public surface so consumers stop re-deriving them:
|
|
60
|
+
- `errorMessage(err)` — the one "human-readable message for a caught `unknown`" helper. It now also strips control characters (keeping newlines and tabs) so server-derived error text can't smuggle terminal escape sequences into consumer output; every engine message built through it (including `rethrowWithContext`) gets the same treatment.
|
|
61
|
+
- `terminalState(instance)` (+ the `TerminalState` type) — classifies an instance as `'aborted' | 'completed' | 'in-flight'`, owning the "aborted instances carry `completedAt` too, so `abortedAt` wins" precedence. `diagnoseInstance`, action-verdict lifecycle gating, and the field-editability window/denial checks all derive their terminal branches from it.
|
|
62
|
+
- `parseDefinitionSnapshot(instance)` — the one way to read an instance's frozen definition snapshot, failing with the instance id attached instead of a bare `SyntaxError`.
|
|
63
|
+
|
|
64
|
+
- 6bbf3ca: **BREAKING:** effect delivery reliability — claim leases with a stale-claim sweeper, and caller-supplied idempotency keys that are actually honored.
|
|
65
|
+
|
|
66
|
+
Claim leases (`drainEffects` / `sweepStaleClaims`):
|
|
67
|
+
- 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.
|
|
68
|
+
- `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.
|
|
69
|
+
- 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.
|
|
70
|
+
- `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.
|
|
71
|
+
- `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.
|
|
72
|
+
- `drainEffects`/`sweepStaleClaims` read the engine clock for claim/lease stamps, so lease timing is deterministic under `createEngine({clock})` (previously drain stamped wall-clock unconditionally).
|
|
73
|
+
|
|
74
|
+
Caller idempotency keys (previously a declared-but-dead arg):
|
|
75
|
+
- 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).
|
|
76
|
+
- 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`.
|
|
77
|
+
- 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.
|
|
78
|
+
|
|
79
|
+
- 9f5a40f: **BREAKING:** foreign-GDR reads no longer silently query the workflow resource. With no `resourceClients` (or on a resolver miss), the engine now derives a sibling client for the target resource from the workflow client's own credentials; a client that cannot derive siblings (no `withConfig`) makes a foreign GDR throw `ContractViolationError` instead of reading the wrong place. Consumers that relied on the old silent fallback — foreign GDRs quietly reading the workflow resource — see different reads (the correct resource) or a loud error.
|
|
80
|
+
|
|
81
|
+
`WorkflowClient` gains an optional `withConfig` seam member (satisfied by both `@sanity/client` and the in-memory test fake — the fake derives dataset siblings only until it learns `resource` upstream); the shared router resolves in order: the `resourceClients` override, the engine's own client for the workflow resource itself, then a derived sibling (cached per operation). Engine-internal verbs now require the router (`EngineCallOptions.clientForGdr`) rather than rebuilding or defaulting one. With an org-capable token, cross-resource workflows route correctly with zero wiring.
|
|
82
|
+
|
|
83
|
+
- 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.
|
|
84
|
+
- `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.
|
|
85
|
+
- 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.
|
|
86
|
+
- 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.
|
|
87
|
+
- 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.
|
|
88
|
+
- Bench: `definitionsForDocument(document)` read helper.
|
|
89
|
+
|
|
90
|
+
- 109f900: Effects can now declare the outputs they produce, as typed `FieldShape[]` on the effect declaration (each `name` is an output key, read downstream as `$effects['<name>'].<key>`).
|
|
91
|
+
|
|
92
|
+
`outputs` is a **strict allowlist enforced at completion**: `completeEffect` validates the handler's returned outputs against the declared shapes and rejects an undeclared key — or a value that doesn't fit its shape — with `EffectOutputsInvalidError`; the completion does not commit, so off-contract data never reaches the instance. Outputs may not accompany a `failed` completion either (they would otherwise bypass the allowlist yet still land on the history rows) — mirroring the existing ops-on-failure rejection. The bound is universal, not opt-in: omitting `outputs` is an **empty allowlist** — the effect produces nothing, and any returned output is rejected. (An effect that returns outputs must declare them; the effects-context _seed_ on `startInstance` stays unrestricted, since it isn't a handler return.)
|
|
93
|
+
|
|
94
|
+
Strict because `effectsContext` lives on the instance document: the allowlist keeps it bounded — a handler can't accidentally spread a whole API response into the instance — and the declared shapes let tooling (e.g. the simulator's drain UI) suggest an effect's exact output keys.
|
|
95
|
+
|
|
96
|
+
Deploy additionally runs an **advisory** producer/consumer lint (`lintEffectOutputs`, exported for tooling) over `$effects['<name>'].<key>` reads in conditions, effect bindings, and guard reads, returning a warning for a read of a key a declared effect does not produce — surfaced on `DeployDefinitionResult.warnings`, the engine logger, and the deploy CLI. Advisory only (deploy still succeeds): a read of a name that is not a declared effect is left alone, because a `startInstance` seed or a `subworkflows.context` handoff can produce it at runtime.
|
|
97
|
+
|
|
98
|
+
- 109f900: Effect outputs and the start-time effects context now carry arbitrary JSON, not just scalars and GDRs. `completeEffect`'s `outputs`, the `EffectHandler` return `outputs`, and `startInstance`'s `effectsContext` seed are widened to `Record<string, unknown>`, so an effect can report an object/array result and a run can be seeded with structured context — read back the same under `$effects.<name>`. Scalars and GDRs still store as their typed `effectsContext` entries; any other JSON stores as one `effectsContext.json` entry, so there is no stored-shape change and no migration.
|
|
99
|
+
|
|
100
|
+
The `subworkflows.context` parent→child handoff now fails loud when a value resolves to neither a scalar nor a GDR, instead of silently dropping it into an absent handoff.
|
|
101
|
+
|
|
102
|
+
- 444b0e6: **BREAKING:** Deploy validation now rejects `$params` in the caller-bound condition sites — action filters, `requirements.*`, and `editable` predicates. Those sites bind `$actor`/`$assigned`/`$can` but never `$params`: an action's args exist only once the caller fires it, after its filter has already decided whether the action is enabled. A filter like `$params.urgent` deployed fine yet was a permanently unevaluable gate — the action could never enable, reporting only a bare `filter-failed` with no hint why. `$params` stays bound where it always was: an action's effect bindings and where-op `where`s.
|
|
103
|
+
- 2511bb5: **BREAKING:** `CompiledQuery.params` widens from `Record<string, string>` to `Record<string, string | string[]>` — custom observer/client implementations typed to the old shape must widen their param handling.
|
|
104
|
+
|
|
105
|
+
Export the canonical helpers consumers were re-deriving, and widen the instance-list prefilter to many documents:
|
|
106
|
+
- `instancesQuery` accepts `filter.documents` — a multi-document prefilter sharing one spelling with the single-document arm — and `filter.ids` — explicitly requested instance ids OR-ed into the match. A DEFINED-but-empty `documents` / `ids` array matches nothing (the GROQ-natural reading); omit a field for no constraint. Entries in `filter.ids` must be bare instance ids — a GDR URI is rejected, mirroring the documents arm
|
|
107
|
+
- new root exports: `isTerminalActivityStatus`, `definitionLookupGroq`, `definitionsListGroq`, `latestDeployedDefinitions` (the head-per-name reduction `definitionsForDocument` applies), `parentRef`, `releaseRef`, `releaseDocId`, `isTodoListEntry`, `isTodoListItem`, `documentActionDenials`, `deniedGuardRefs` (the structured denial payload `deniedGuardLabels` phrases), `missingRequiredInputs` (the required-input predicate behind `RequiredFieldNotProvidedError`), `rejectedRefTypes` (the `doc.ref`/`doc.refs` `types`-facet check), plus the `EffectsContext`, `TodoListItem`, and `DocumentActionDenialsArgs` types
|
|
108
|
+
- `TodoListItem.status` is optional per row — the `todoList` sugar guarantees the columns in the schema, not values per row, and field validation makes every declared column optional — so `isTodoListItem` accepts status-less rows and rejects a non-string status
|
|
109
|
+
- `releaseRef` is the one constructor for the `release.ref` value envelope (`releaseDocId` owns the release system-doc id spelling), and field validation now cross-checks that a stored release ref's `id` names the release in `releaseName`
|
|
110
|
+
- `documentActionDenials` is the prospective guard verdict for document actions (disable-this-button pre-flights); `instanceWriteDenials` rides it
|
|
111
|
+
|
|
112
|
+
- d4fd8e6: `definitionDeployedData` — the structural derivation behind `Editorial Workflows Definition Deployed` — is exported, so shells can project other events from the same counting code path instead of re-deriving (and drifting from) the counts.
|
|
113
|
+
- 30ed0e8: The notes vocabulary joins the todoList helpers on the root surface: `isNotesEntry` recognises a notes-shaped `array` entry (the sugar's `{body, actor, at}` row shape) on both a resolved instance entry and a deployed definition entry, and `NoteItem` types one persisted notes row. `isTodoListEntry` accepts the same two vocabularies (it previously took only a resolved entry), so start-time and runtime surfaces route array entries with one verdict. `resolveFieldEntry` is exported as the one owner of field-site scope resolution — the resolved entry behind a workflow/stage/activity field site, read against the open stage (the same resolution `readFieldValue` and the op path already use) — so consumers stop re-implementing it.
|
|
114
|
+
- 2834704: **BREAKING:** the engine owns `ENGINE_API_VERSION` for all engine traffic. Every entry point — `createEngine`, each raw `workflow.*` verb, and the housekeeping exports — derives its working client with `withConfig({apiVersion: ENGINE_API_VERSION})` (memoized per client, so identity-keyed actor/grants caches stay stable), and `resourceClients`-resolved clients are rebound the same way. A caller's configured `apiVersion` never reaches engine reads or writes; before, the pin was a documentation convention, and an older dated version evaluated the engine's GROQ silently incomplete, not loud. The caller's own client instance is untouched — `withConfig` returns a sibling — so any `apiVersion` satisfies `@sanity/client` at construction. The breaking part is the `WorkflowClient.withConfig` contract: its config is now the all-optional exported `WorkflowClientConfig` (`resource` optional, new `apiVersion`), so a custom implementation must accept a version-only rebind — and the one client the engine cannot rebind, a custom `WorkflowClient` without `withConfig`, must be built to serve `ENGINE_API_VERSION` already.
|
|
115
|
+
- 2511bb5: Export `findOpenStageEntry` and `findCurrentActivityEntry` from the package root — the open-stage-entry and current-activity-entry lookups over a stored instance's `stages`/`currentStage`, for consumers that read instance state without re-implementing the loop-back semantics.
|
|
116
|
+
- 2511bb5: Export `toBareId` from the package root — the canonical lenient GDR-URI → bare document id transform (a non-GDR string passes through unchanged), for consumers that join stored refs against lake `_id`s.
|
|
117
|
+
- 2511bb5: Export `tryParseGdr` from the package root — the "is this a GDR, and if so its parts" primitive (returns `undefined` instead of throwing on non-GDR input), for boundaries that classify stored refs without trusting their shape.
|
|
118
|
+
- 4607d59: **BREAKING:** A singular `doc.ref` field read is now doc-centric on every surface. `$fields.<doc.ref>` dereferences into its referenced document — read content by name (`$fields.subject.amount`) and identity via the reserved `_id`/`_type` (`$fields.subject._id`) — in conditions, effect bindings, guard `metadata`, op payloads (`ValueExpr`), and a field's `initialValue` (`fieldRead` `FieldSource`) alike, all through one shared resolver. This closes the gap where guard metadata, ops, and `initialValue` reads silently walked the stored `{id, type}` reference envelope instead of the document, and unifies the identity spelling: the reference's GDR URI is the document's `_id`, its `type` the document's `_type`, so a content field named `id`/`type` can never be shadowed.
|
|
119
|
+
|
|
120
|
+
Migration: a guard/op/`initialValue` read that reached the reference's `.id`/`.type` must use `._id`/`._type` — identical values. Guard `idRefs` are unchanged (they route a guard write on the stored reference, never its content). `query` `FieldSource`s (arbitrary lake GROQ) are unchanged.
|
|
121
|
+
|
|
122
|
+
Scope notes:
|
|
123
|
+
- `doc.refs` (plural) is unchanged: it stays a collection of references (the array of GDR envelopes) for identity/iteration — e.g. a spawn `forEach`. To read a referenced document's content, model it as a singular `doc.ref`. An element's identity is therefore read off the envelope (`$fields.refs[i].id`/`.type`), NOT the doc-centric `._id`/`._type` — the singular-vs-plural spelling difference is deliberate.
|
|
124
|
+
- An op that reads content through a `doc.ref` resolves at op-apply against the commit's snapshot and stamps the result into `opApplied.resolved` — a transactional content→state pin in the same commit, no effect round-trip. An unfilled or unresolvable reference resolves to `null`.
|
|
125
|
+
- On the path-carrying surfaces (guard `metadata`, ops, `initialValue`), reading _through_ a nested, undeclared reference inside a loaded document (e.g. `subject.image.asset.url`) throws — the engine only loads references a workflow declares as `doc.ref`/`doc.refs` entries (best-effort: an unresolvable reference whose document isn't loaded resolves `null`). Conditions and effect bindings run on groq-js, which passes no path here and null-propagates through such a read instead of throwing.
|
|
126
|
+
- A field's `initialValue` resolves at materialisation, before any snapshot exists, so only identity reads (`._id`/`._type`) resolve there; a content read falls back to the field default (consistent with an `initialValue`'s existing unresolvable-read behavior). Content reads through a ref belong in ops/guards/conditions, which resolve against the runtime snapshot.
|
|
127
|
+
|
|
128
|
+
- 52f6024: The GDR constructors (`refDataset`, `refCanvas`, `refMediaLibrary`, `refDashboard`, `gdrRef`) now preserve the `type` literal they were called with instead of widening it to `string`, so a constructed ref satisfies literal-typed targets — e.g. a `release.ref` field value (`type: 'system.release'`) can be built as a plain spread without re-asserting the literal. `GlobalDocumentReference` gains an optional `TType extends string = string` parameter; the default keeps every existing call site and type annotation compiling unchanged. Zero runtime change.
|
|
129
|
+
- adf32f7: **BREAKING:** identity is the token — actor injection is gone from every production path. `WorkflowAccessOverride` and the `access?` argument on every verb, `evaluate`, `drainEffects`, and `engine.session` are removed; `resolveAccess(client, {grantsFromPath?})` always resolves the actor from `/users/me` and grants from `grantsFromPath`. Acting as someone else means a client with their token. The engine's own system actors (`engine.drainEffects`, `engine.machineStep`, `engine.completeWhen`/`failWhen`) are gone too: auto-work (drain claims, gate flips, machine steps, cascade aborts) is attributed to the committing token's user, and `driverKind` no longer classifies any actor as `engine` (the stored legacy value remains readable). New: `createEngine({executionContext: {kind?, id?}})` declares the host once at construction; the engine infers the JavaScript runtime and stamps `executionContext: {runtime, kind?, id?}` on every history entry alongside the actor — advisory "via what" provenance (exported `EXECUTION_KINDS` carries the well-known vocabulary). A server proxy acting with a user's token now expresses itself as `{actor: <user>, executionContext: {kind: 'server', id: '<proxy>'}}` instead of forging an identity.
|
|
130
|
+
- 5cb0850: **BREAKING:** every evaluation node now carries derived evaluation state, so consumers constructing evaluation values by hand must supply the new members: `TransitionEvaluation.insight` is required, and `WorkflowEvaluation.fieldInsights` is required (hand-built diagnostics fixtures should use the new narrowed `DiagnosedTransition` instead of the full transition shape), and `ConditionVar` gains a required human `label`. Consumers that only read evaluations are unaffected — everything below is additive data.
|
|
131
|
+
|
|
132
|
+
Derived evaluation state from the GROQ AST, riding every `evaluate`:
|
|
133
|
+
- `TransitionEvaluation.insight`, `ActionEvaluation.insight`, `ActivityEvaluation.requirementInsights`/`filterInsight`/`completeWhenInsight`/`failWhenInsight`, `EditableFieldEvaluation.insight` — each gate's `ConditionInsight`: per-atom three-valued verdicts, the unmet frontier (`blockedBy`), single-fix pivotality, and solvable requirements (`equals`/`differs`/`defined`/`undefined`/`truthy`/`falsy`/`compares`). Verdict flags are projections of the insight outcome — one groq-js evaluation feeds both.
|
|
134
|
+
- `WorkflowEvaluation.fieldInsights` — per-field involvement ("this field is read by transition X, requirement Y") plus counterfactually verified proposals ("setting `legalApproved` to `true` flips `to-publish` to satisfied"), computed from the same walk that produced the verdicts.
|
|
135
|
+
- Atoms are opaque about their parse tree (`ConditionAtom.node: unknown`) — the groq-js AST shape is not part of the public contract.
|
|
136
|
+
- The generic machinery (analysis, explanation, what-ifs, phrasing) now lives in the new `@sanity/groq-condition-describe` package; the engine re-exports it and registers its workflow dialect (field titles, condition-var labels, roles/claim desugar shapes, `$now`) through the describe seams. `explainCondition`/`whatIfCondition` take `dataset: unknown[]` (pass `snapshot.docs`).
|
|
137
|
+
- Standalone primitives for tooling: `analyzeCondition` (scope reads + boolean clause tree in negation normal form), `explainCondition`, `whatIfCondition`/`withAssignment` (counterfactual re-evaluation with fabricated containers, sparse-index guarded), `formatRead`, and the `ConditionOutcome` type.
|
|
138
|
+
- The static tier for whole-definition consumers (diagram explain props, docs, CLI): `conditionSitesOf(definition)` enumerates every condition site with addressable keys (the same `InsightSite` vocabulary evaluation insight emits, plus `predicate`), and `describeDefinition(definition)` phrases them all against an empty scope in one call — the site enumeration lives in ONE exported walker, consumed by the rendering corpus test too.
|
|
139
|
+
- `describeSiteHeading(address, ctx)` — the connective mood for tooltips/panels ("«Approval» completes automatically when:" flowing into the checklist lines); `describeSite` stays noun-mood for mid-sentence composition. Accepts the definition-level address superset.
|
|
140
|
+
- One front door for rendering: `describeNode(node, ctx)` accepts any insight-bearing evaluation node (transition, action, activity, editable field, field insight, or a bare condition insight) and returns the shape that node demands via a conditional type — a checklist description, an activity's four gates, or a field's involvement + proposals.
|
|
141
|
+
- Deterministic human rendering (no LLM): `describeCondition`/`checklistLines` (checklist + frontier summary), `describeAtom`/`describeSite`/`describeFieldInsight`. Templates over requirements, the condition-var registry's new `label`s, the definition's own titles, and structural recognizers for the desugarer's canonical shapes (roles gate, claim no-steal) plus common patterns (deadline-vs-`$now`, literal membership, count thresholds). Every rendered phrase is an `InsightPhrase` — `{id, params, text}`: stable namespaced message id + interpolation params + the English default, so an i18n consumer (e.g. a Studio locale bundle) translates by id and ignores `text`. Anything unphrasable falls back to the atom's GROQ with a depends-on hint and marks the description `fullyDescribed: false`. The rendering corpus test over every example definition currently shows zero fallbacks.
|
|
142
|
+
|
|
143
|
+
The verdict of record always rides the engine's own evaluation path; the clause-tree math used for frontier/pivotality mirrors GROQ's Kleene semantics and is pinned by tests. Everything is advisory, like every engine-side check. Measured cost of the added derivation: well under 1ms per evaluation on a realistic stage (in-memory groq-js, no I/O). Research notes: `docs/research/derived-evaluation-state.md`.
|
|
144
|
+
|
|
145
|
+
- 70a136c: Author-declared groups: a `groups: [{name, title?, description?, kind?}]` array on the workflow root, a stage, or an activity declares named groups, and field entries, activities, and actions join them via `group: string | string[]` (stored canonically as a list) — advisory "what belongs together" metadata the engine stores and deploy-checks but never acts on. Memberships resolve lexically up the enclosing chain; names are unique per level (the same name at different levels is intentional nesting, never merged by the engine). `kind` is a closed role vocabulary: `core` = include in condensed views first, `details` = disclose on demand; consumers reading a definition as data treat an unknown kind as unset. New surface: `defineGroup`, `groupSitesOf` (per-level declarations with lexically attributed members), `groupMembershipNames`, `GROUP_KINDS`, `GROUP_KIND_DISPLAY`, and the `Group` / `GroupMembership` / `DefinitionGroupSite` / `GroupMember` / `GroupKind` types.
|
|
146
|
+
- 91540de: **BREAKING:** telemetry vocabulary metrics pass — new events, payload enrichments, and commit-time emission threading. Every event name now carries the product name — `Editorial Workflows <X>` instead of `Workflow <X>` — because bare "Workflow(s)" is ambiguous with other initiatives.
|
|
147
|
+
- New unsampled `Editorial Workflows Stage Transitioned` event: one event per committed stage move (cascade hops, propagation passes, and admin overrides included), carrying positional stage indexes, a terminal flag, a loop-back (`isRevisit`) flag, dwell time in the exited stage, and `via: 'transition' | 'setStage'`. Emitted from the commit path itself, so every hop counts exactly once regardless of which verb drove it.
|
|
148
|
+
- `deployDefinitions` mints a random `deployId` per call, stamps it on every `Editorial Workflows Definition Deployed` event, and returns it on `DeployDefinitionsResult` so callers reporting on the same deploy carry the same grouping id.
|
|
149
|
+
- `Editorial Workflows Definition Deployed` payload reshaped: adds `deployId`, distinct `fieldKinds`, `lifecycle`, and counts (`guardCount`, `effectCount`, `subworkflowCount`) replacing the `usesGuards`/`usesSpawn`/`usesEffects` booleans.
|
|
150
|
+
- All instance-scoped events now carry `definitionContentHash` (per-definition slicing) and `instanceId` — the instance document `_id` — so per-instance journeys and distinct-instance funnels are answerable. Effect names and instance ids are the payload policy's two deliberate customer-string exceptions.
|
|
151
|
+
- `Editorial Workflows Instance Started` adds `viaSpawn` and `lifecycle`; spawned children now emit it too (previously only direct starts did).
|
|
152
|
+
- `Editorial Workflows Effect Completed` carries the effect's name and the queueing boundary (`origin: 'activity' | 'transition' | 'action'`), and widens `status` with `'cancelled'`; and the abort commit emits one cancellation per discarded pending effect — exact under races, cascade-driven child aborts (a parent abort, a delete-cascade) included. `CompleteEffectResult` gains `origin`.
|
|
153
|
+
- `Editorial Workflows Effects Drained` carries the dispatched effects' names (`drainedEffects`). Effect names are one of the payload policy's two deliberate customer-string exceptions (with instance ids) — identifying which integration ran outweighs the string hygiene, and the names join donated definitions exactly. `DrainEffectsResult` carries the drained instance's `definitionContentHash`; drain-driven cascades now emit transition events (drained per-effect completions stay silent — successful dispatches ride the drain summary, counted once; drain-time failures emit nothing until the health-telemetry pass). The drain summary is now unsampled when non-empty — outcome counts must be complete — with empty polls throttled engine-side to one per minute.
|
|
154
|
+
- New `processShellUserProperties` export: the execution-environment user-property set process shells attach to their stores.
|
|
155
|
+
- `EditMode`, `EditFieldTarget`, and `DEFAULT_EDIT_MODE` moved to the pure editability core (public import paths unchanged).
|
|
156
|
+
|
|
157
|
+
- 8455fbb: Persisted data-model governance. The doc types whose format the engine owns outright (definition, instance) are now stamped with a pair — `modelVersion` (provenance: the `DATA_MODEL_VERSION` shape contract that wrote it, re-asserted on every full persist) and `minReaderModel` (compatibility: the oldest engine model that can safely interpret it, `DATA_MODEL_MIN_READER`) — orthogonal to definition-content versioning (`version` / `pinnedVersion` / `contentHash`). Engine reads gate on the reader floor, not the stamp: a document written by a newer model whose changes were additive keeps working on older engines (mixed-version fleets of Functions, Studios, and CLIs interoperate), and only a floor ahead of the engine fails hard with the new `ModelVersionAheadError`. A document without a stamp reads as model 0, and backward compatibility is unconditional. Old writers restamp down, preserve unknown fields (set-only patches, spread copies), and never lower a stored floor. The guard doc deliberately carries no stamp — its format is the Content Lake's forthcoming contract, versioned by the `temp.` prefix cutover itself. The model rules and history live in the package's `DATAMODEL.md` (shipped in the tarball), enforced by a shape-snapshot test; `DATA_MODEL_VERSION`, `DATA_MODEL_MIN_READER`, `modelVersionOf`, `minReaderModelOf`, and `assertReadableModel` are exported for consumers.
|
|
158
|
+
- 325c2d1: `InstanceSession` gains `previewField` / `discardFieldPreview` — staged optimistic previews of field edits. `evaluate()` now projects against the held instance with previews applied, so field values and the advisory verdicts derived from them (editability, action verdicts, transition satisfaction) move instantly, while everything the engine writes (stage, activity statuses, history) stays committed by construction: previews apply `field.*` ops only, through the same target resolution, op shape, and value validation as the committed edit path. `editField` stages its own preview on entry and drops the target's previews when the commit settles — success lands the reloaded value, failure reverts the projection. Previews are derived at evaluate time, so a newer held instance (commit reload or store echo) rebases them automatically.
|
|
159
|
+
|
|
160
|
+
Previews are edit-window-aware: a target whose window is closed — a pending activity's field before activation, whose runtime entry doesn't exist yet — stages an INERT preview (no echo, no throw) and never reaches the op applier, so one such preview can't poison the projection. Stage- and activity-scope previews are additionally keyed to the stage VISIT they were staged in (the open stage-entry `_key`): a stage bounce delivered as a single `update` drops them deterministically instead of resurrecting a prior visit's values on re-entry, while workflow-scope previews — whose window is the whole in-flight instance — survive stage changes.
|
|
161
|
+
|
|
162
|
+
Documents and pins the session commit verbs' existing revision-conflict contract: a lost `ifRevisionId` race on the action/edit commit retries through the engine's shared reload → re-gate → re-apply loop (capped), a re-gate that fails on the reloaded instance surfaces its structured gate error immediately (never a blind replay), and exhausting the cap throws `ConcurrentFireActionError` / `ConcurrentEditFieldError`. Only the instance reloads per attempt — the held content snapshot and staged previews are untouched across attempts.
|
|
163
|
+
|
|
164
|
+
- d6e92ed: Now published with public npm access (previously restricted) — installable without `@sanity` org membership.
|
|
165
|
+
- 7ffdc77: Resource-shaped GDRs (`<type>:<id>` with no document part, e.g. `dataset:abc123.production`, `media-library:mlXyz`) are now a first-class parse/validate shape: `parseResourceGdr(uri)` parses one into a `WorkflowResource` (throwing a clear grammar error on unknown types, document-shaped URIs, or malformed dataset ids), and `resourceGdr(res)` formats the inverse. Use them anywhere a resource — not a document in it — is named across an API boundary, instead of hand-rolling the split.
|
|
166
|
+
- 7ffdc77: New `clientConfigFromResource(resource)` export — the `@sanity/client`-config
|
|
167
|
+
fragment addressing a `WorkflowResource` (dataset via the classic
|
|
168
|
+
`{projectId, dataset}` config, other resource types passed through as
|
|
169
|
+
`resource`). The one home for that branch: hosts spread it into their own
|
|
170
|
+
`createClient` call, and the CLI's `clientFor` now derives its config through
|
|
171
|
+
it instead of a private copy.
|
|
172
|
+
- ced40e4: `createTelemetryIntake` / `isTelemetryEnvDenied` — the standard Sanity-intake consent/transport recipe (account status from `GET /intake/telemetry-status` with resolved-only caching, `POST /intake/batch` `{projectId, batch}` transport, the `telemetry.*` request tags, and the CI/`DO_NOT_TRACK` environment denial) now lives once in the engine package as structural mirrors of `@sanity/telemetry`'s store options — still no runtime dependency on that package. The SDK and MCP shells consume it instead of carrying twin copies. `WorkflowConfig` additionally accepts an optional `telemetry` logger the CLI routes every event to in place of its built-in shell, and the mirrored event type's `description` is optional to match the real package, so events built with the real `defineEvent` flow through the mirrored logger seam.
|
|
173
|
+
- 9b1b40d: **BREAKING:** `workflow.deployDefinitions` and `diffEntry` now normalize and strict-parse incoming definitions at the boundary. A fetched `sanity.workflow.definition` document round-trips cleanly: the document envelope (`_*` system fields, `tag`, `version`, `contentHash`) is stripped before fingerprinting, so redeploying a fetched document reports `unchanged` — previously the envelope was silently fingerprinted as content, minting a spurious new immutable version on every redeploy. Any other unknown key, anywhere in the tree, now fails the deploy with a path-prefixed validation error instead of riding silently into stored content — which also removes the need for consumer-side envelope strip lists. The input types become the new conditional `WorkflowDefinitionInput<T>`: a loosely-typed fetched document (`Record<string, unknown>`) passes without a cast, while precisely-typed input keeps the full `WorkflowDefinition` contract — unknown keys beyond the document envelope are `never`, so authoring literals keep exact compile-time checking and autocomplete.
|
|
174
|
+
- 23cf131: Evaluation forecasts prospective subject writes per involved resource: every `doc.ref` / `doc.refs` / `release.ref` subject outside the workflow's own resource is checked against that resource's own ACL, fetched through its resource client (cached per client + path). When the actor's grants there don't allow `update`, effects-bearing actions are forecast disabled with the new `subject-permission-denied` disabled reason — surfaced on `evaluate` and thrown by `fireAction`'s soft-gate as `ActionDisabledError`. `$can` is unchanged (still the instance-doc read against the workflow's own resource), and the forecast is advisory: it degrades open when a resource's grants can't be fetched or evaluated, and the subject's lake remains the enforcement point.
|
|
175
|
+
|
|
176
|
+
New exports: `subjectDenialLabels` (the display convention for the denial payload, the subject-forecast twin of `deniedGuardLabels`), `actionDisabledDetail` (the engine's per-reason detail fragment, for dev-facing hosts), and `aclPathForResource` (the canonical resource → ACL-path encoding behind `grantsFromPath`).
|
|
177
|
+
|
|
178
|
+
- 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.
|
|
179
|
+
- 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.
|
|
180
|
+
- `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.
|
|
181
|
+
- `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.
|
|
182
|
+
- `$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 && …]`).
|
|
183
|
+
- 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.
|
|
184
|
+
- 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.
|
|
185
|
+
- A parent may complete with live detached children — legal, and `diagnose` now reports `liveChildren` on terminal states so consumers can surface it.
|
|
186
|
+
- 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.
|
|
187
|
+
|
|
188
|
+
- 15337f1: **BREAKING:** `engine.client` now exposes the request-tag-stamping wrapper — a delegating object implementing the `WorkflowClient` interface over the supplied client, no longer the same instance. Identity comparisons against the supplied client and runtime re-widening to a concrete `SanityClient` (`.listen()`, `.config()`, `.withConfig()`) stop working; the typed `WorkflowClient` contract is unchanged.
|
|
189
|
+
|
|
190
|
+
Request-tag every Content Lake request. Each engine verb stamps an operation-scoped `workflow.*` request tag (`workflow.fire-action`, `workflow.deploy`, `workflow.guard.deploy`/`refresh`/`retract`, `workflow.access.resolve-actor`, …) through a stamp-if-missing wrapper that never overrides an explicit tag and honors the runtime `null` suppression form; effect-handler traffic through the handler ctx — both `ctx.client` and `clientFor` — falls back to `workflow.effect`, distinct from the drainer's own `workflow.drain` housekeeping. The `WorkflowClient` surface carries the tags: `tag` on `WorkflowFetchOptions` and `WorkflowCommitOptions`, an options bag on `getDocument` and `transaction().commit()`. The request tag is observability metadata in request logs — unrelated to the workflow-domain deployment tag.
|
|
191
|
+
|
|
192
|
+
- 2d1a8e1: Pluggable product telemetry. `createEngine` accepts an optional `telemetry` logger, defaulting to a no-op — the engine defines and emits adoption events; the app shell owns store creation, consent, and transport. The engine carries no runtime dependency on `@sanity/telemetry`: the event/logger shapes (`WorkflowTelemetryEvent`, `WorkflowTelemetryLogger`) are structural mirrors, so a real `TelemetryLogger` slots straight in and the engine's events feed a real store unchanged (drift is pinned by a type-level test). The v1 vocabulary (exported from the package root): `Editorial Workflows Definition Deployed` (structural counts, activity kinds, capability flags, content hash, created/unchanged status), `Editorial Workflows Instance Started`, `Editorial Workflows Action Fired`, `Editorial Workflows Field Edited` (sampled), `Editorial Workflows Instance Ticked` (sampled), `Editorial Workflows Effect Completed` (direct reports; engine-drained effects surface via the drain event instead), `Editorial Workflows Effects Drained` (sampled), `Editorial Workflows Instance Aborted`, `Editorial Workflows Stage Set`, and `Editorial Workflows Definition Deleted`. Every attempt emits — no-op outcomes ride the payload (`status: 'unchanged'`, `changed: false`, `drainedCount: 0`) rather than suppressing the event; the one silence is an idempotent action replay. Payloads carry no customer-authored strings — structural counts, kind enums, capability flags, and content fingerprints only. A throwing host logger never fails a committed write. The reactive session emits the same events through the engine's logger, and the resolved logger is exposed as `engine.telemetry` for adapters.
|
|
193
|
+
- 12cdbd6: **BREAKING:** Deploy validation now rejects dangling references that previously deployed fine and failed silently at runtime:
|
|
194
|
+
- **Unreachable stages** — a stage with no transition path from `initialStage` can never host an instance; its activities, guards, and conditions are dead weight.
|
|
195
|
+
- **Undeclared `$fields.<name>` reads in conditions** — GROQ resolves an unknown attribute to null, so a typo'd read was a silent never-true. Sites nested in a stage/activity are checked against their exact lexical window (workflow + enclosing stage + enclosing activity; transition-level sites see no activity fields); workflow-level sites whose overlay follows the evaluation context (predicate bodies, a workflow field's `editable`) are checked against all declared names. Detection is AST-exact — `"$fields.x"` inside a string literal and dynamic `$fields[$var]` access don't count.
|
|
196
|
+
- **`fieldRead` value sources** — a seed (`initialValue`), op payload, or guard read naming an entry its resolver can't reach returned the default/undefined instead of failing. Seeds must read an earlier same-scope sibling (or workflow scope explicitly — forward and self references are rejected, and scope spellings the resolver can't honour, like `scope: "stage"` on an activity-scope seed, are rejected with the working alternative). Op payloads resolve stage-then-workflow and never see activity fields. Guard reads (`$fields.<name>[.path]` on `match.idRefs` / `metadata`) resolve against workflow-scope fields only, and a guard's `$effects['<name>']` read must name a declared effect.
|
|
197
|
+
- **`fieldRead` / guard-read dot-paths** — a declared `path` must fit the target's value shape: `object.fields` / `array.of` sub-shapes recursively, the fixed GDR / release / actor / assignee envelopes, numeric indices for array kinds, and no paths into scalars.
|
|
198
|
+
|
|
199
|
+
Every check reports through `checkWorkflowInvariants` with an exact issue path, so `defineWorkflow`, `validateDefinition`, `deployDefinitions`, the CLI, and the MCP validate tool all reject the same definitions with the same messages.
|
|
200
|
+
|
|
201
|
+
- 437b544: `validateDefinition` now parse-checks `editable` predicate strings on workflow-, stage-, and activity-scope field entries and stage `editable` tighten-overrides — previously these deployed unchecked (a malformed one threw at evaluate time; a `_type`-scanning one silently left the field non-editable). Every authored condition string gets the full deploy check (GROQ syntax plus the `_type` discovery-scan rejection), reported once at its authored site; plain condition sites are enumerated by the shared `conditionSitesOf` walker so the validator cannot drift from the insight surface. Validation issue labels are uniformly stage-qualified and derived from site addresses — a transition filter reads `stage "draft" transition "auto" filter` (the `(from → to)` arrow is gone), and op `where` / effect-binding labels on transitions and actions now carry their stage too.
|
|
202
|
+
- ced40e4: Request tags move to workflows-owned families with the runtime as the segment: `sanity.workflows.cli`, `sanity.workflows.studio`, `sanity.workflows.sdk` — never another team's family (`sanity.cli.*`, `sanity.studio.*`) — and platform analysis zero-rates `sanity.workflows.*`. The MCP server deliberately sits outside that family as `sanity.workflows-mcp` (no dot boundary), so agent-driven traffic bills. Under any workflows-family prefix the engine's tag wrapper strips the now-redundant `workflow.` root from every relative op tag — stamped fallback and explicit alike — so traffic lands as `sanity.workflows.cli.deploy` with exactly one workflows segment; on raw consumer clients and under foreign host prefixes the root stays, since there it is what identifies the traffic. The Studio adapter's engine and resource-router clients and the SDK telemetry shell's intake client now set their family prefix themselves instead of riding the host's.
|
|
203
|
+
|
|
204
|
+
### Patch Changes
|
|
205
|
+
|
|
206
|
+
- 6c21009: Correct public TSDoc and README claims that contradicted the implementation: CLI auth precedence (an explicit `SANITY_AUTH_TOKEN` wins over the `sanity login` session), the real CLI verbs (`workflow.diagnose`, `workflow.availableActions`) and document types (`sanity.workflow.instance` / `sanity.workflow.definition`), guard predicates reading the delta-mode `before()`/`after()` natives, glob patterns supporting multiple `*`s, where `$can` actually binds (requirements and the editField gate, not action filters alone), the editField target contract (any-scope match with activity-first precedence), guard observation fan-out across the engine dataset plus each watched subject's dataset, the object value expression being op-payload-only, and the MCP descriptor / SDK observer export pointers. The deploy-time `$can` rejection error now states where `$can` actually binds (the caller-bound projection: action filters, requirements, editable predicates) instead of claiming action filters are its only home.
|
|
207
|
+
- a8f1cb9: `subworkflows.forEach` now recognizes the engine's own reference shape as a row identity: a GDR-value row (`{id, type}` with a GDR-URI `id` — what a `doc.refs` field stores) keys on its `id`, extending the row-identity rule to `_key` ?? `_id` ?? GDR `id` ?? scalar. Fanning out over a `doc.refs` field — `forEach: '$fields.documents[]'` — starts instead of failing with "row without an identity", and adopt-matching is unchanged: same document ⇒ same row. The teaching error now names the GDR case alongside `_id`/`_key`/scalar.
|
|
208
|
+
|
|
209
|
+
`subworkflows.context` values are now validated where they evaluate: a value that is neither a scalar nor a canonical GDR (`{id, type}` with a GDR-URI `id`) fails the spawn with a teaching error instead of being silently dropped (arrays, plain objects) or persisted malformed (GDR-shaped values with a non-string `id`), and a GDR handoff is normalized to exactly `{id, type}` so extra projected attributes don't ride into every child instance.
|
|
210
|
+
|
|
211
|
+
- 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.
|
|
212
|
+
- 325c2d1: `InstanceSession.editField` keeps its promise shape for op-shape contract bugs: a `set`/`append` call with no `value` now returns a rejected promise carrying `ContractViolationError` instead of throwing synchronously from its preview-staging step.
|
|
213
|
+
- 8c319b2: Stage-entry evaluation now reads the post-entry view: an entered stage's activity `filter`s, auto-activation gates, and `fieldRead` entry fields see `$stage` naming the entered stage, its `$fields` overlay, and the firing transition's op writes. Previously they read the exiting stage's context, silently skipping activities gated on the entered stage. Initial-stage filters likewise now see the stage's own entry fields at prime.
|
|
214
|
+
- 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.
|
|
215
|
+
- Updated dependencies [bae7eb8]
|
|
216
|
+
- Updated dependencies [7217e14]
|
|
217
|
+
- @sanity/groq-condition-describe@0.1.0
|
|
218
|
+
|
|
219
|
+
## 0.14.0
|
|
220
|
+
|
|
221
|
+
### Minor Changes
|
|
222
|
+
|
|
223
|
+
- ad9b6f6: **BREAKING:** New reserved condition var `$effectStatus` — a definition with an author predicate named `effectStatus` now fails deploy validation (rename the predicate).
|
|
224
|
+
|
|
225
|
+
`$effectStatus` is the declarative "has my effect drained?" read: a map from effect name to `'done'` / `'failed'` for the latest completed run **queued under the current stage entry**, absent until that entry's run drains. It is re-entry-safe where hand-rolled `effectHistory` counting is not — runs queued under a prior entry (even ones completing late, after re-entry) never count — so an effect-drained gate is a one-liner: `completeWhen: "defined($effectStatus['<effect>'])"` (settled either way) or `$effectStatus['<effect>'] == 'done'`. To support the read, queued effects and their completion rows now carry `stageEntryKey` — the `_key` of the stage entry the effect was queued under (a transition's effects belong to the entry being entered). The match is entry **identity**, never a timestamp, so it holds under frozen test clocks and same-commit loop-backs; rows persisted before the stamp never match (fail closed).
|
|
226
|
+
|
|
227
|
+
Also new:
|
|
228
|
+
- One exported source of truth for condition-scope variables: `CONDITION_VARS` (each var annotated with the context that binds it: always / caller / spawn), with `RESERVED_CONDITION_VARS`, `FILTER_SCOPE_VARS`, and `GUARD_PREDICATE_VARS` derived alongside — the deploy-time shadow check and the docs now share one inventory, and the `Condition` type carries real TSDoc pointing at it. Exported from both the package root and `./define`.
|
|
229
|
+
- `isGuardLifted(guard)` — the public discriminator for a lifted guard (a lift patches the predicate and keeps the doc, so lifted guards stay in guard queries and streams).
|
|
230
|
+
|
|
231
|
+
- 83d5924: **BREAKING:** `DeployDefinitionResult.definition` is renamed to `name` — the same noun `DeleteDefinitionResult` already uses for the definition's `name`, so the two result shapes agree on how a definition is identified.
|
|
232
|
+
- df00bbb: New `instancesQuery({tag, filter})` — the shared instance-list query builder: one `CompiledQuery` for both read styles, the engine's one-shot fetch and a reactive adapter's live subscription (the same stateless/reactive split as `instanceGuardQuery`, which now also returns the named `CompiledQuery` type). Filters: `document` (GDR prefilter, narrow exactly with `instanceWatchesDocument`), `definition`, `stage`, `includeCompleted`. `instancesForDocument` now builds its prefilter through it. Also exported: `ResourceClientResolver` (for consumers wiring per-resource clients) and `ENGINE_API_VERSION` (the API surface the engine's reads/writes assume — one pin for every client bound to the engine).
|
|
233
|
+
- 5c69928: **BREAKING:** runtime API coherence pass — one result shape, one session noun, nameable engine args.
|
|
234
|
+
- `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).
|
|
235
|
+
- `startInstance` now returns `OperationResult` instead of a bare `WorkflowInstance` — its cascade is finally observable. Reach the instance via `result.instance`.
|
|
236
|
+
- 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.
|
|
237
|
+
- 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.
|
|
238
|
+
- 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.
|
|
239
|
+
- Test bench mirrors the engine: `bench.startInstance` returns `OperationResult`, and the `Bench*Args` types are the engine-bound shapes minus `access`.
|
|
240
|
+
- 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`.
|
|
241
|
+
|
|
242
|
+
- 2f081a3: **BREAKING:** Unify the error model under one `WorkflowError` base class with a `kind` discriminant.
|
|
243
|
+
- Every structured error the engine throws at a caller now extends `WorkflowError` and carries a stable `kind`, so a consumer can write one catch-and-render path: `instanceof WorkflowError`, then switch on `kind`. The model covers the runtime verbs' refusals, denials, races, and lookups. Three things deliberately stay plain `Error`: deploy/authoring validation (a structured issue-report design of its own), transient transport failures (retryable, carrying `cause` — not engine verdicts), and internal invariant violations (the remediation is a bug report, not a catch branch).
|
|
244
|
+
- One class for guard denial across all verbs: `fireAction` and `editField` now throw `MutationGuardDeniedError` when a deployed mutation guard denies the instance write — the same error `tick` and the reactive session already threw — instead of wrapping the identical condition in `ActionDisabledError` / `EditFieldDeniedError`. Those two classes now statically exclude the `mutation-guard-denied` arm from their `reason`, and the commit-time re-checks of `filter-failed` / `activity-not-active` also throw `ActionDisabledError`, so a caller racing a state change sees the same typed error either way.
|
|
245
|
+
- One payload shape for the same denial: the `mutation-guard-denied` verdict arm carries `denied: {guardId, name?}[]` (the `DeniedGuardRef` shape the thrown error also carries) instead of `guardIds: string[]` + `detail`; `deniedGuardLabels(denied)` is the shared display convention (authored name over id).
|
|
246
|
+
- Contract violations are typed: an invalid `tag`, a `workflow.query` GROQ that never binds `$tag`, a non-GDR `document` to `instancesForDocument`, firing an undeclared action or activity, editing an undeclared field, a set/append edit without a value, malformed `initialFields` values, and a client that cannot resolve an actor all throw `ContractViolationError`; a missing/invisible instance throws `InstanceNotFoundError` and a missing deployed definition throws `DefinitionNotFoundError` (all previously untyped `Error`s).
|
|
247
|
+
- Operation refusals and runtime lookups are typed too: `deleteDefinition` refusals throw `DefinitionInUseError` (with a `blockedBy` payload naming the non-terminal instances or spawn-referrers), `completeEffect` on an unknown/already-drained key throws `EffectNotFoundError` (so an at-least-once runtime can treat a double delivery as already-done), and the `missingHandler: 'fail'` policy throws `MissingHandlerError` carrying its `MissingHandlerInfo`.
|
|
248
|
+
- New exports: `WorkflowError`, `WorkflowErrorKind`, `ContractViolationError`, `InstanceNotFoundError`, `DefinitionNotFoundError`, `DefinitionInUseError` (+ `DefinitionInUseBlocker`), `EffectNotFoundError`, `MissingHandlerError`, `FieldValueShapeError`, `DeniedGuardRef`, `deniedGuardLabels`, `ActionDisabledReason`, `EditFieldDeniedReason`, `MutationGuardDenial`. The verdict↔exception mapping is documented on `DisabledReason`.
|
|
249
|
+
|
|
250
|
+
- df00bbb: The reactive watch-set now includes the open stage's **activity-scope** `doc.ref`/`doc.refs`/`release.ref` fields — the third field scope. Everything deriving from `collectWatchRefs` follows together: session subscriptions re-evaluate when an activity-scope subject changes, snapshot hydration loads it, `instanceWatchesDocument`/`instancesForDocument` (and the reactive discovery hooks) match instances whose only reference to a document is an activity-scope field, and guard housekeeping unions across the datasets activity-scope refs name. Exited stages drop out at all three scopes, as before.
|
|
251
|
+
- 2829643: **BREAKING:** guard `match.idRefs` and `metadata` values are now authored as typed `GuardRead` expressions — `{type: 'self'}`, `{type: 'now'}`, `{type: 'fieldRead', field, path?}`, `{type: 'effectsRead', effect, path?}` — instead of the `"$self"` / `"$fields.<name>"` string mini-language. Desugar prints the exact stored string form the deploy resolver and guard refresh already speak, so deployed definitions and the lake guard contract are byte-identical; only the authoring surface changes. Guard `predicate` (lake GROQ) is untouched. A read `path` must be single-line (the printed stored form is parsed by single-line expressions). Migrate `'$fields.subject'` to `{type: 'fieldRead', field: 'subject'}` and `"$effects['x'].y"` to `{type: 'effectsRead', effect: 'x', path: 'y'}`.
|
|
252
|
+
|
|
253
|
+
**BREAKING:** literal `initialValue` seeds are now **pinned to the entry's declared kind at define time**: a mismatched shape, a `null` seed (omit `initialValue` instead), or a ref id outside the bare `_id` | GDR URI | `@<alias>:<id>` grammar fails `defineWorkflow` with a labeled error instead of surfacing at materialization — a malformed `@alias` ref can no longer degrade to a bare id and read the wrong resource, and the `@<alias>:` id part is held to the Sanity `_id` charset so junk can't ride through deploy into a never-matching GDR. The grammar applies at every depth: a `doc.ref` sub-field inside an `object`/`array` literal speaks the same three id forms as a top-level entry. Bare ids (including system ids like `_.releases.<name>`) are rooted at the workflow's home resource when the instance materializes the field.
|
|
254
|
+
|
|
255
|
+
- 6ee9068: **BREAKING:** vocabulary sweep — one word per concept across the public surface.
|
|
256
|
+
- "slot" is purged in favor of the field vocabulary: `EditableSlotEvaluation` → `EditableFieldEvaluation`, `Evaluation.editableSlots` → `editableFields`, `FIELD_SLOT_DISPLAY` → `FIELD_KIND_DISPLAY`; runtime strings follow (`EditFieldDeniedError` now renders `field is not declared editable`, unresolved edit targets throw `No field "…" resolves`).
|
|
257
|
+
- One who-acts taxonomy: `Actor.kind` is now `'person' | 'agent' | 'system'` (was `'user' | 'ai' | 'system'`), sharing its vocabulary with `DriverKind` — `driverKind()` passes `person`/`agent` through and only splits `system` into `engine` vs `service` (anything out-of-vocabulary still lands inside `DRIVER_KINDS`). The shared `ACTOR_KINDS` const (and `ActorKind` type) is exported and feeds both the `Actor` type and the runtime `ActorShape` validator. `ActivityKind` (`user`/`service`/`script`/`manual`/`receive`) is a different axis and is unchanged; its TSDoc gloss now uses the actual values. Actors persisted on new history entries carry the new kinds. **Pre-rename persisted data is not migrated:** an actor-typed field value stored with an old kind fails `ActorShape` validation if an op re-writes it (a `fieldRead` copy, an effect completion `field.set`), and a stored definition seeding an actor literal with an old kind fails new instance starts — finish or migrate in-flight instances and redeploy such definitions.
|
|
258
|
+
- `WorkflowRole` → `WorkflowLifecycle`: the definition key `role: 'workflow' | 'child'` is now `lifecycle: 'standalone' | 'child'` (read by `isStartableDefinition`), leaving `role`/`roleAliases` to mean lake authorization roles only. Definitions authored with `role` must move to `lifecycle` and redeploy.
|
|
259
|
+
- The README now disambiguates workflow fields from Sanity schema fields — including aliasing the `defineField` name clash — and documents the four nesting-level meanings of the `fields` key.
|
|
260
|
+
|
|
261
|
+
- 2829643: **BREAKING:** `field.updateWhere` / `field.removeWhere` now take a GROQ condition as `where` instead of the typed `OpPredicate` object, and the `OpPredicate` type is removed. Row matching is the same condition grammar as every filter, with two op-only variables bound: `$row` (the row under test) and `$params` (the firing action's args) — e.g. `where: "$row.status == 'done'"`. A truthy result matches; GROQ `null` (an unevaluable comparison) never matches. The `where` string is parse-validated by `validateDefinition` like every other condition (`_type` discovery scans rejected) — on activity, action, and transition ops alike — and an effect handler's completion-returned where-ops get the same checks at the completion boundary, failing with a labeled `EffectOpsInvalidError` before any mutation. The `where` scope's bound set is closed and deploy-enforced: reading any variable outside it (`$can`, `$assigned`, `$parent`, `$subworkflows`, a named-condition `$<name>`, or a typo) is rejected up front, because GROQ evaluates an unbound variable to `null` and the op would silently match no rows. Migrate `{type: 'field', field: 'x', equals: {type: 'literal', value: y}}` to `"$row.x == <y>"`, `all`/`any` compositions to `&&`/`||`, and `{type: 'param'}` comparisons to `$params.<name>`.
|
|
262
|
+
- a0443c4: Add `defineWorkflowConfig` and the `WorkflowConfig` type — a typed, validated deploy-config surface that binds a definition's logical resource handles to physical resources per environment (tag). A deployment's `resourceAliases` bindings (`{name, resource}[]`) are collapsed by `resourceAliasesToMap` into the `resourceAliases` map that `deployDefinitions` consumes.
|
|
263
|
+
|
|
264
|
+
### Patch Changes
|
|
265
|
+
|
|
266
|
+
- a0443c4: `defineWorkflowConfig` now validates that a `dataset` resource id has the `<projectId>.<dataset>` shape at the config boundary. A malformed id (e.g. missing the dot) previously validated clean and then threw a raw error late, when a client was built from it; it now fails with a path-prefixed config error up front. The other resource types carry an opaque id the engine never destructures, so a non-empty string remains their whole contract.
|
|
267
|
+
- a0443c4: `defineWorkflowConfig` now rejects a config whose deployments don't all have unique tags. A tag identifies which deployment to act on, so a duplicate is a config error rather than a silent ambiguity.
|
|
268
|
+
- 7a655bd: Document the terminal activity status vocabulary as a health axis: `done` means the work concluded (whatever was decided), `skipped` means it never applied, and `failed` is reserved for work that genuinely could not complete — only `done`/`skipped` satisfy `$allActivitiesDone`; a `failed` activity blocks it permanently. A routine decision (decline, send back, hold) resolves `done` and writes the decision into a field the transition filters read — never `status: 'failed'`. The API overview's cancel-from-anywhere guidance now prescribes the same pattern instead of `failed` + `$anyActivityFailed`.
|
|
269
|
+
- 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.
|
|
270
|
+
- 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.
|
|
271
|
+
- 113a796: Docs honesty sweep over the public TSDoc. The workflow API module doc now describes the real verb surface (`deployDefinitions`, `editField`, the admin overrides and pure reads) instead of a stale five-method framing that named a nonexistent `deployDefinition`. `createEngine`'s effect-handler doc points at `drainEffects` / `verifyDeployedDefinitions` — the functions that actually consume handlers — instead of an internal plan step. Actor, history, and guard-owner docs state the caller-asserted, advisory reality: the actor is provenance, not an authenticated principal; the guard `owner` field is an unenforced provenance stamp (with the self-lockout hazard spelled out); `WorkflowAccess.grants` documents the real `$can` mechanism (grants feed advisory `$can.*` params — there is no `permission-denied` verdict — and a failed grants fetch degrades open; the grants-fetch warning no longer claims a "permission gate" is being skipped). `validateDefinition`'s aggregated error now names `validateDefinition` and its deploy-time boundary instead of impersonating `defineWorkflow`.
|
|
272
|
+
- df00bbb: The reactive instance session now applies pushed self-docs newest-wins (by `_updatedAt`): a consumer store echo that lags behind the session's own committed reload can no longer regress the held instance to a state the session has already moved past. Content docs stay last-write-wins — the consumer's store owns content truth; the session owns instance truth.
|
|
273
|
+
|
|
274
|
+
## 0.13.0
|
|
275
|
+
|
|
276
|
+
### Minor Changes
|
|
277
|
+
|
|
278
|
+
- 416d4c9: Let an effect handler return `ops` from its completion — `field.*` ops applied in the completion commit through the same op applier (target resolution + value-kind validation) an action's field ops use. This gives an effect the state half of an action: it can put a produced result into the instance's own fields (e.g. `field.set` a created document's ref into `$fields`, or write a screened outcome into a field an activity/stage gate then reads) — folding away the proxy-action and `freshEffect` GROQ workarounds. Completion ops are the `field.*` subset only (a new exported `FieldOp` type, shared with transitions): an effect is outside the activity's own awaiting, so it reports its result as field state and never sets an activity status — `status.set` is rejected at the completion boundary, the same boundary a transition already draws. Returned ops are validated like an action's, are not actor-gated (no actor fires an effect), apply once per completion, and target workflow- or stage-scope fields. Adds the `EffectOpsInvalidError` export (thrown when returned ops don't parse / aren't field ops) and an `effect` origin on `opApplied` history entries.
|
|
279
|
+
- 5bbc3f0: **BREAKING:** Public functions that took 3+ positional parameters now take a single named options object. Notably the GDR ref constructors, the definition diff helpers, and the guard matcher:
|
|
280
|
+
- `refDataset(projectId, dataset, documentId, type)` → `refDataset({projectId, dataset, documentId, type})`
|
|
281
|
+
- `refCanvas(resourceId, documentId, type)` → `refCanvas({resourceId, documentId, type})`
|
|
282
|
+
- `refMediaLibrary(resourceId, documentId, type)` → `refMediaLibrary({resourceId, documentId, type})`
|
|
283
|
+
- `refDashboard(resourceId, documentId, type)` → `refDashboard({resourceId, documentId, type})`
|
|
284
|
+
- `gdrRef(res, documentId, type)` → `gdrRef({res, documentId, type})`
|
|
285
|
+
- `diffEntry(...)` / `computeDiffEntries(...)` and `guardMatches(...)` likewise now take a single options object.
|
|
286
|
+
|
|
287
|
+
Behavior is unchanged — only the calling convention. Update call sites to pass a single object.
|
|
288
|
+
|
|
289
|
+
- 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.
|
|
290
|
+
|
|
291
|
+
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`.
|
|
292
|
+
|
|
293
|
+
### Patch Changes
|
|
294
|
+
|
|
295
|
+
- 4dfe72a: Finish de-duplicating the internal idioms the structural clone detector misses (the deferred half of the engine dedup sweep), with no change to the public API surface. Each is now expressed once:
|
|
296
|
+
- `applyGateOutcome` in `engine/spawn.ts` is the single home for the "a satisfied resolution gate fired → flip the activity, attributed to the gate's system actor" idiom. All three gate-driven flips route through it — the auto-resolve sweep and ancestor propagation (`engine/cascade.ts`) and the on-activate resolution (`engine/activities.ts`) — so the gate-actor attribution can't drift across paths; `gateActor` becomes a spawn-internal detail. The `ctx.actor`-stamped and `machineStep`-stamped flips are deliberately left alone.
|
|
297
|
+
- Doc-id minting is consolidated into named helpers: `definitionDocId({tag, definition, version})` in `core/refs.ts` (pure, deterministic) and `instanceDocId(tag)` in `keys.ts` (shell-side — it draws `randomKey`). This removes `api/deploy.ts`'s local `definitionDocId` (which carried an unused resource param) and the inline `wf-instance` id templates in `api/workflow.ts` and `engine/spawn.ts`, and carries the "Sanity rejects `:` in document IDs" invariant once.
|
|
298
|
+
- The `ParsedGdr → WorkflowResource` mapping now lives in exactly one place. `resourceFromParsed` moves to its proper pure home in `core/refs.ts` (beside its inverse `gdrFromResource`), and `resourceFromGdrUri` becomes a thin parse-from-string front door over it (`tryParseGdr` then `resourceFromParsed`) — collapsing a second copy of the mapping and dropping its never-firing per-field guards. `guard-bindings.ts` drops its own `Resource` interface + `resourceOf` (a third copy) for the shared helper and no longer depends on the shell module `snapshot.ts`; `assertSingleResource` now returns a `WorkflowResource`. The public `resourceFromParsed` export is unchanged — only its re-export source moved (`snapshot.ts` → `core/refs.ts`).
|
|
299
|
+
- `findCurrentActivityEntry(host, activityName)` in `types/instance-state.ts` is the shared "current-stage activity entry by name" selector, replacing the `findOpenStageEntry(host)?.activities.find(...)` idiom at five standalone sites (`core/editability.ts`, `core/params.ts`, `engine/ops.ts`, `api/operations.ts`, `api/workflow.ts`). The reuse-local `stageEntry` variants that also mutate are left as-is.
|
|
300
|
+
|
|
301
|
+
- 0bf7503: Fix `defineWorkflow` (and the authoring/stored definition types) tripping `TS2589` — "Type instantiation is excessively deep and possibly infinite" — for consumers that resolve the **published** package, i.e. its bundled `.d.ts`. Code compiled against the engine source was unaffected, so the monorepo never saw it.
|
|
302
|
+
|
|
303
|
+
The node types (`WorkflowDefinition` / `Stage` / `Activity` / `Action` / `FieldEntry` and their `Authoring*` twins) are now hand-written named types pinned to their valibot schemas, instead of `v.InferOutput<typeof schema>` over the schema value. api-extractor inlines a non-pinned schema const's type into one multi-thousand-line `v.StrictObjectSchema<{…}>`; computing `InferOutput` over that inlined form instantiated eagerly and blew TypeScript's depth cap. Pinning each node schema to `v.GenericSchema<Named>` short-circuits the inlining, so the bundled `.d.ts` carries named types and the consumer never re-derives the full tree.
|
|
304
|
+
|
|
305
|
+
Runtime validation is unchanged — the schema values are identical, only how their types are expressed changed. The pin is bidirectional (the schema's inferred output must be the exact same shape as the hand-written type), so the schema and its type cannot drift in either direction without a compile error. The exported schema consts (`AuthoringWorkflowSchema`, …) are now typed as `v.GenericSchema<T>` rather than their concrete valibot schema type.
|
|
306
|
+
|
|
307
|
+
- 3229f41: De-duplicate several internal idioms that the structural clone detector misses (semantic/copy-paste-then-diverge repeats), with no change to the public API surface. Each is now expressed once:
|
|
308
|
+
- `toBareId` in `core/refs.ts` is the single "GDR URI → bare id" transform, now used directly by `core/params.ts` (`paramsForLake`) and the engine's cross-resource ref stripping in `cascade.ts`/`spawn.ts`. This removes both prior spellings — `core/params.ts`'s `bareId` (which passed a non-GDR colon string through) and `engine/context.ts`'s `gdrToBareId` (which used `uri.includes(':')` and threw on one) — collapsing the divergence onto the pass-through behavior.
|
|
309
|
+
- `tryParseGdr` in `core/refs.ts` is the single "parse-or-undefined" primitive, reused by `isGdrUri`, `resourceFromGdrUri`, `guards-query`, `discover`, and `snapshot`.
|
|
310
|
+
- `core/errors.ts` adds `rethrowWithContext` for the wrap-rethrow-with-cause shape shared by `params`, `field-resolution`, and `types/instance`.
|
|
311
|
+
- `engine/context.ts` adds `retryOnRevisionConflict` (the optimistic-locking retry shell shared by `fireAction`/`editField`) and the public verbs (`abort`/`setStage`/`invokeActivity`/`completeEffect`) now reload through the existing `loadCallContext` instead of hand-rolling the clock/`clientForGdr` spread.
|
|
312
|
+
- `engine/persist-deploy.ts` adds `persistThenMaybeRefresh` for the conditional stage-guard refresh shared by the action/edit/effect-completion paths.
|
|
313
|
+
- `api/deploy.ts` adds `loadDefinitionVersionsOrThrow` for the "no deployed definition" guard, and `api/operations.ts` adds `engineOptionsForActor` for the actor/clock/`clientForGdr` option spread.
|
|
314
|
+
|
|
315
|
+
## 0.12.0
|
|
316
|
+
|
|
317
|
+
### Minor Changes
|
|
318
|
+
|
|
319
|
+
- 5b10863: **BREAKING:** Compositional Field kinds, Sanity-aligned scalar names, and `todoList`/`notes` as sugar.
|
|
320
|
+
|
|
321
|
+
Lean into Sanity's model so any value shape composes instead of minting a bespoke array kind per row layout:
|
|
322
|
+
- **Add `object` and `array` kinds** — `{ type: 'object', name, fields: [...] }` and `{ type: 'array', name, of: [...] }`, mirroring Sanity's `object.fields` / `array.of`. `fields`/`of` are lightweight recursive field shapes (`{ type, name, title?, description?, fields?/of? }`) — no slot-level `source`/`editable`/`required`, since a sub-field's value comes from the parent value. Object/array entries persist their declared shape on the instance, and writes are validated against it.
|
|
323
|
+
- **Scalar names aligned to Sanity** — drop the `value.` prefix: `value.string`→`string`, `value.number`→`number`, `value.boolean`→`boolean`, `value.dateTime`→`datetime`, `value.url`→`url`, `value.actor`→`actor`. **Adds** `text` (multiline) and `date` (date-only, `YYYY-MM-DD`).
|
|
324
|
+
- **Add the singular `assignee` kind** — one `Assignee` (`{type:'user',id} | {type:'role',role}`), the singular of `assignees`. It's the value shape for a single user-or-role assignee (e.g. a `todoList` row's owner); it is NOT wired into the engine's `$assigned` / inbox gate (that remains the task-level plural `assignees`).
|
|
325
|
+
- **`todoList` and `notes` are now authoring sugar** that desugar to an `array` kind: `todoList` → `array of { label, status, assignee?, dueDate? }`, `notes` → `array of { body, actor, at }` (the `actor`/`at` sub-fields match the `audit` op's stamp names, so an `audit`-appended row lands in the declared shape). **The bespoke `checklist` kind is removed** — a plain checklist is a `todoList` used with `{label, status}` only.
|
|
326
|
+
|
|
327
|
+
The `ChecklistItem` / `NoteItem` value types are removed (the kinds they typed are gone); `FieldShape` is now exported. `@sanity/workflow-examples` and the `@sanity/workflow-mcp` authoring guide are updated to the new names and kinds.
|
|
328
|
+
|
|
329
|
+
- ae1434e: Give effect handlers a cross-resource router so they can reach subject docs in another Sanity resource. The `EffectHandler` ctx now carries `clientFor(ref)` alongside `client`: pass the subject's GDR (e.g. the URI a `$fields.subject._id` binding resolves to, or a `GlobalDocumentReference`) and get the client bound to that resource, falling back to the engine's own client for unmapped resources. In a split-dataset deploy the handler patches the subject through `clientFor` while the drainer's `completeEffect` writes the instance through the engine client — one client could not address both, which broke cross-resource drains. `clientFor` throws on a non-GDR ref rather than silently patching the wrong dataset. Additive: existing handlers that only read `client`/`params` are unaffected.
|
|
330
|
+
- d850aef: **BREAKING:** Workflow definitions are now immutable and content-addressed — the author no longer writes a `version`.
|
|
331
|
+
|
|
332
|
+
A definition's `version` (and a new `contentHash` fingerprint) are stamped onto the deployed _document_ at deploy time, derived from the content itself — the same envelope as `_id`/`_type`/`tag`. `defineWorkflow` no longer accepts a `version` (the authoring schema rejects it), and the `WorkflowDefinition` type no longer carries one.
|
|
333
|
+
|
|
334
|
+
`deployDefinitions` is now create-only: it hashes the authored content and compares it to the latest deployed version of that name. Identical content is a no-op (`unchanged`); any change mints the next version (`created`). It never patches a deployed version in place, so a definition can no longer change out from under the instances pinned to it. (Engine-side only — true immutability of the deployed document still requires a Content Lake guard; the engine enforces nothing.) Deploying two definitions with the same name in one batch is now rejected.
|
|
335
|
+
|
|
336
|
+
Instances pin `pinnedContentHash` alongside `pinnedVersion`, so a consumer can detect a deployed definition that has drifted from what an instance started on.
|
|
337
|
+
|
|
338
|
+
API changes:
|
|
339
|
+
- `defineWorkflow({ version })` / `WorkflowDefinition.version` removed — drop `version` from authored definitions.
|
|
340
|
+
- `DeployDefinitionResult.status` drops `'updated'` (now `'created' | 'unchanged'`); `DiffEntry.status` drops `'update'` (now `'create' | 'unchanged'`) — a content change is always a new version.
|
|
341
|
+
- `isDefinitionUnchanged` removed; the content fingerprint is now exposed as `hashDefinitionContent`. `DeployedDefinition` is exported for consumers reading deployed documents.
|
|
342
|
+
- Spawn refs (`task.subworkflows.definition.version`) are unchanged — you can still pin another definition's deployed version.
|
|
343
|
+
|
|
344
|
+
- 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.
|
|
345
|
+
|
|
346
|
+
`Action` (the actor-fired command) is unchanged.
|
|
347
|
+
|
|
348
|
+
The rename cascades across the whole surface:
|
|
349
|
+
- Authoring: `defineTask` → `defineActivity`; the `tasks:` array on a stage → `activities:`; `Task`/`AuthoringTask` types → `Activity`/`AuthoringActivity`.
|
|
350
|
+
- 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".
|
|
351
|
+
- Status: `TASK_STATUSES`/`TaskStatus` → `ACTIVITY_STATUSES`/`ActivityStatus` (and the terminal-status counterparts); status _values_ are unchanged.
|
|
352
|
+
- Predicate sentinels: `$tasks` → `$activities`, `$allTasksDone` → `$allActivitiesDone`, `$anyTaskFailed` → `$anyActivityFailed`, `$requiredTasksDone` → `$requiredActivitiesDone`, `$task` → `$activity`. Authored GROQ filters referencing the old names must be updated.
|
|
353
|
+
- 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.).
|
|
354
|
+
- `workflow-react`: the `fireAction` argument `{task}` → `{activity}`.
|
|
355
|
+
|
|
356
|
+
Nothing referencing "task" remains in the model. This is a deliberate pre-customer breaking change to land the correct, permanent vocabulary.
|
|
357
|
+
|
|
358
|
+
- 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.
|
|
359
|
+
|
|
360
|
+
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.
|
|
361
|
+
|
|
362
|
+
This replaces the hand-rolled, incomplete GROQ a content forwarder would otherwise write (which misses stage-scope refs, `release.ref`, and ancestors).
|
|
363
|
+
|
|
364
|
+
- e5a862a: **BREAKING:** Split the field `source` union into two purpose-built types.
|
|
365
|
+
|
|
366
|
+
One `Source` union served two unrelated jobs — how a field _initialises_ vs what
|
|
367
|
+
an op _writes_ — and an illegal mix was caught by a runtime `throw "…is a
|
|
368
|
+
field-entry origin, not a value"` rather than the type system. It is now two types:
|
|
369
|
+
- **`FieldSource`** — a field's seed recipe, exposed as the field's optional
|
|
370
|
+
`initialValue` property (renamed from `source`). Arms: `input` (renamed from
|
|
371
|
+
`init`), `query`, `literal`, `fieldRead`.
|
|
372
|
+
- **`ValueExpr`** — an op's `value` payload (property unchanged). Arms:
|
|
373
|
+
`literal`, `param`, `actor`, `now`, `self`, `stage`, `fieldRead`, `object`.
|
|
374
|
+
|
|
375
|
+
Authoring changes:
|
|
376
|
+
- Rename the field property `source` → `initialValue`; it is now **optional**.
|
|
377
|
+
- The `write` arm is **dropped** — an absent `initialValue` is working memory
|
|
378
|
+
(the slot starts empty and an op fills it later), the common default.
|
|
379
|
+
- The `init` arm is renamed to `input`.
|
|
380
|
+
- The exported `Source` type is replaced by `FieldSource` and `ValueExpr`.
|
|
381
|
+
|
|
382
|
+
The runtime field-entry-origin `throw` is gone: the split makes that state
|
|
383
|
+
unrepresentable.
|
|
384
|
+
|
|
385
|
+
**Also removes the `query` field _kind_ (the untyped escape hatch).** There is
|
|
386
|
+
no longer an `unknown`-valued field — every field declares a concrete kind, and
|
|
387
|
+
"computed" is just a concrete kind + `initialValue: {type:'query'}`. A
|
|
388
|
+
query-sourced field's GROQ result is validated against its declared kind:
|
|
389
|
+
- conforming results are stored (with `resolvedAt` stamped — now driven by the
|
|
390
|
+
`query` _source_, present on any kind, not the old `query` kind);
|
|
391
|
+
- a non-conforming result is **discarded** (the field falls to its default) and
|
|
392
|
+
a new `fieldQueryDiscarded` history entry is logged — fail-soft for runtime
|
|
393
|
+
lake data, rather than the previous hard throw. `FieldKind`, `FieldValueMap`,
|
|
394
|
+
`ResolvedFieldEntry` (`resolvedAt` is now optional) and `InitialFieldValue`
|
|
395
|
+
change accordingly.
|
|
396
|
+
|
|
397
|
+
Field `initialValue` lives only on deployed definitions (never on instances) and
|
|
398
|
+
the shared/value discriminator strings are unchanged, so this is a type-level
|
|
399
|
+
break only — redeploy definitions, no instance data migration.
|
|
400
|
+
|
|
401
|
+
### Patch Changes
|
|
402
|
+
|
|
403
|
+
- 3ea4953: Fix `roleAliases` with the universal `"*"` key failing to deploy. `"*"` is the only way to express "fulfills any gate" (e.g. `{ "*": ["administrator"] }`), but the Content Lake rejects `"*"` as a document attribute name, so a definition carrying it 400'd against a real dataset while passing in-bench. `defineWorkflow` now normalizes the authored `"*"` to a lake-safe key in the stored definition. Authoring is unchanged — keep writing `"*"` — and the baked `roles` gate and runtime `$assigned` resolution are unaffected.
|
|
404
|
+
|
|
405
|
+
## 0.11.0
|
|
406
|
+
|
|
407
|
+
### Minor Changes
|
|
408
|
+
|
|
409
|
+
- 3b8f3de: **BREAKING:** `TaskEvaluation` gains a required `kind` field. Additive for code
|
|
410
|
+
that _reads_ evaluation output (the engine always populates it), but a compile
|
|
411
|
+
break for any code that _constructs_ a `TaskEvaluation` (test mocks, fixtures).
|
|
412
|
+
|
|
413
|
+
Classify tasks by an advisory `kind` (BPMN-aligned: `user` / `service` /
|
|
414
|
+
`script` / `manual` / `receive`). `kind` is a label so tooling can render each
|
|
415
|
+
task as what it is — it changes no gating or how a task resolves; a transition
|
|
416
|
+
still fires exactly as the rest of the task's shape dictates.
|
|
417
|
+
- **Derived (P0):** every `TaskEvaluation` now carries a `kind`, computed from
|
|
418
|
+
the task's shape when not declared (`deriveTaskKind` / `taskKind`, also
|
|
419
|
+
exported): `actions` → `user`, else `effects` → `service`, else a
|
|
420
|
+
`completeWhen`/`subworkflows` wait → `receive`, else an inline machine step →
|
|
421
|
+
`script`. `manual` is never derived — it is explicit only.
|
|
422
|
+
- **Explicit (P1):** an optional `kind` field on the task definition, validated
|
|
423
|
+
at deploy against the task's shape (e.g. a `user` task needs an action, a
|
|
424
|
+
`receive` task waits and carries none) — every contradiction is reported in
|
|
425
|
+
one error. Plus an optional `manual` `target` deep-link (`{type:"url"}` or a
|
|
426
|
+
`{type:"field"}` reference to a document-valued entry, resolved at deploy).
|
|
427
|
+
- **Driver kind (P2):** the `actionFired` history entry records the firing
|
|
428
|
+
actor's `driverKind` (`person` / `agent` / `service` / `engine`, via the
|
|
429
|
+
exported `driverKind`) for the audit-trail glyph — any driver can fire any
|
|
430
|
+
kind.
|
|
431
|
+
|
|
432
|
+
`TASK_KIND_DISPLAY` / `DRIVER_KIND_DISPLAY` provide labels for each. Existing
|
|
433
|
+
definitions need no edits: `kind` is optional and derived when omitted.
|
|
434
|
+
|
|
435
|
+
## 0.10.0
|
|
436
|
+
|
|
437
|
+
### Minor Changes
|
|
438
|
+
|
|
439
|
+
- fae3553: **BREAKING:** Rename the domain concept **state → fields** across the whole API and persisted data model.
|
|
440
|
+
|
|
441
|
+
"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.
|
|
442
|
+
|
|
443
|
+
**Authoring DSL**
|
|
444
|
+
- `defineState(...)` → `defineField(...)`
|
|
445
|
+
- The `state: [...]` array on a workflow / stage / task → `fields: [...]`
|
|
446
|
+
- The `$state.<name>` GROQ scope token → `$fields.<name>`
|
|
447
|
+
- A `claim` action's `state:` reference → `field:`
|
|
448
|
+
- A `stateRead` source → `fieldRead`; its `state:` (entry name) → `field:`
|
|
449
|
+
- Op targets address the entry by `field:` instead of `state:`
|
|
450
|
+
|
|
451
|
+
**Persisted / wire format**
|
|
452
|
+
- Op type strings `state.set` / `state.unset` / `state.append` / `state.updateWhere` / `state.removeWhere` → `field.*`
|
|
453
|
+
- The stored `state[]` array on instances, stage entries, and task entries → `fields[]`
|
|
454
|
+
- `startInstance({ initialState })` → `startInstance({ initialFields })`
|
|
455
|
+
|
|
456
|
+
**Types & functions** (engine)
|
|
457
|
+
|
|
458
|
+
`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`.
|
|
459
|
+
|
|
460
|
+
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`.
|
|
461
|
+
|
|
462
|
+
## 0.9.0
|
|
463
|
+
|
|
464
|
+
### Minor Changes
|
|
465
|
+
|
|
466
|
+
- 2e4c980: Add a generic edit seam for declared-editable workflow state.
|
|
467
|
+
|
|
468
|
+
State slots can now declare `editable` (default off) and be written directly
|
|
469
|
+
through one `editState` verb — reassign, reschedule, claim-by-hand, append to a
|
|
470
|
+
log — instead of a bespoke action per field.
|
|
471
|
+
- `editable` on a state slot — default off. _Who_ may edit is expressed with
|
|
472
|
+
the gating actions already have, not a new permission concept: `true` (anyone
|
|
473
|
+
in the slot's scope window), a **role list** (the same `action.roles` sugar —
|
|
474
|
+
it desugars to the identical `count($actor.roles[@ in [...]]) > 0` membership
|
|
475
|
+
predicate), or a raw GROQ predicate (checked like `action.filter`). A stage
|
|
476
|
+
may TIGHTEN a slot via an `editable` override map — the override is ANDed with
|
|
477
|
+
the slot's baseline, so it can only narrow, never open a closed slot.
|
|
478
|
+
- `editState` engine verb (and `engine.editState` / `instanceSession.editState`):
|
|
479
|
+
gates on the slot's editability and scope window, applies the edit as a
|
|
480
|
+
`state.*` op (so provenance + history are stamped by the op path), refreshes
|
|
481
|
+
the stage's guards, then cascades — an edit to a value a transition reads can
|
|
482
|
+
move the instance. Advisory like every engine gate; the lake ACL + guards are
|
|
483
|
+
the only hard locks.
|
|
484
|
+
- Scope is the editable window: a workflow slot is editable while the instance
|
|
485
|
+
is live, a stage slot while its stage is current, a task slot while its task
|
|
486
|
+
is active.
|
|
487
|
+
- The projection gains `editableSlots`: each declared-editable slot in scope
|
|
488
|
+
with this actor's edit verdict and the current value's provenance (`setBy` /
|
|
489
|
+
`setAt`, read off `opApplied` history — no denormalised copy). `EditDisabledReason`
|
|
490
|
+
and `EditStateDeniedError` mirror the action verdict surface.
|
|
491
|
+
- `@sanity/workflow-react`'s controller exposes `editState`, threaded through
|
|
492
|
+
the Studio and App SDK adapters.
|
|
493
|
+
|
|
494
|
+
- fd6b866: Add role aliasing to the authoring DSL: a definition-level `roleAliases` map
|
|
495
|
+
("can be fulfilled by"). It's an authoring convenience — the same capability is
|
|
496
|
+
often carried by different role names depending on how a project deploys its
|
|
497
|
+
Content Lake roles, so rather than enumerate every equivalent role inline in
|
|
498
|
+
each `roles` gate (or fork the definition per deployment), declare once which
|
|
499
|
+
other roles fulfill it. Each key is a role a gate or `assignees` entry names;
|
|
500
|
+
its value lists the roles that also satisfy it. The reserved `"*"` key lists
|
|
501
|
+
roles that fulfill any gate (e.g. `{ "*": ["administrator"] }` for a
|
|
502
|
+
deployment's broad role).
|
|
503
|
+
|
|
504
|
+
The map expands the **required** side — never the actor's roles — and is applied
|
|
505
|
+
in both places roles are matched: the `roles` gate bakes the expanded membership
|
|
506
|
+
into its desugared GROQ at define time, and `$assigned` resolves the assignee's
|
|
507
|
+
role through the map at runtime. Expansion is one level (non-transitive).
|
|
508
|
+
|
|
509
|
+
This also corrects the misleading `Actor."*"` documentation: an actor's `roles`
|
|
510
|
+
are matched by literal membership only — there is no actor-side wildcard — with
|
|
511
|
+
the definition's `roleAliases` as the sole mechanism for widening, so the
|
|
512
|
+
advisory gate can never grant access the actor was not actually given. Like
|
|
513
|
+
every engine gate it is advisory; the registrant owns keeping the map true to
|
|
514
|
+
the deployment's real Content Lake ACLs.
|
|
515
|
+
|
|
516
|
+
## 0.8.0
|
|
517
|
+
|
|
518
|
+
### Minor Changes
|
|
519
|
+
|
|
520
|
+
- c298ee2: Make two guard deploy/rollback divergence cases loud instead of silent. (1) When a stage deploys multiple guards and a later one fails after an earlier one already landed, the move now rolls back and throws `WorkflowStateDivergedError` (the earlier lock is orphaned and can't be cleanly undone) instead of re-throwing the bare write error. (2) When a spawned child fails to settle after the spawn transaction committed — e.g. its initial-stage guard can't deploy — the failure now surfaces as `WorkflowStateDivergedError` at the parent boundary instead of a raw child error unwinding past the parent's rollback scope (which left the parent move committed with the intended scream never raised). A new exported `PartialGuardDeployError` carries the partial-deploy detail; a direct caller of `deployStageGuards` (outside the engine's rollback wrapper) can catch it by type. True cross-resource atomicity remains impossible (a guard can live in a different resource than the instance); the direction stays over-lock, but divergence is no longer silent.
|
|
521
|
+
- 8131dd5: Keep mutation guards coherent when an effect completes. The guard read mini-language (`match.idRefs` and `metadata` values) now accepts `$effects['<name>'][.path]` alongside `$self`/`$now`/`$state.<name>` — so a guard can bake a completed effect's output into its resolved metadata (the lake only ever sees the resolved value, never `$effects`). `completeEffect` writes outputs into `effectsContext`, but until now a guard reading one stayed stale unless a transition followed. The engine now refreshes the current stage's guards on the effect-completion path when the completion wrote outputs, with the same fail-loud rollback policy as the action-ops refresh. `materializeInstance` carries `effectsContext` so the refresh resolves `$effects` against the new context, and the shared commit-then-deploy primitives moved to their own module so the effect path can reuse them without a cyclic import.
|
|
522
|
+
- 8bf467e: Add a definition `role` and a `required` flag on state entries — so spawn-only child workflows can be kept out of standalone-start surfaces, and a load-bearing init slot fails fast instead of silently defaulting.
|
|
523
|
+
- **`role: 'workflow' | 'child'` on a definition (default `'workflow'`).** A `'child'` definition is spawn-only — instantiated by a parent's `task.subworkflows`, never started cold by a human. The exported `isStartableDefinition(definition)` predicate (plus the `WorkflowRole` type) is the single place that decides "can a human start this", so every consumer filters its start picker the same way. Advisory: the engine does **not** refuse a `startInstance` on a `'child'` def — it's a discoverability flag, not a gate. `@sanity/workflow-cli`'s `definition list` gains a **Startable** column derived from the predicate.
|
|
524
|
+
- **`required?: boolean` on a state entry — the start/spawn counterpart to action-param validation.** A missing `init`-sourced value previously resolved silently to `null`/`[]`, which is what let a cold-started child come up structurally incomplete (a review with no subject, no assignee). A `required` entry the caller doesn't supply — via `initialState` at start, or the parent's `subworkflows.with` at spawn — now throws the exported `RequiredStateNotProvidedError` and creates nothing, exactly as a missing `required` action param already does. Valid only on a workflow-scope `init` entry; `required` on any other scope or source is a deploy error (`defineWorkflow` rejects it with the offending field path).
|
|
525
|
+
|
|
526
|
+
The two compose: `role` keeps a child out of pickers; `required` stops the engine from constructing an incomplete instance if a UI (or a human) starts one anyway.
|
|
527
|
+
|
|
528
|
+
## 0.7.0
|
|
529
|
+
|
|
530
|
+
### Minor Changes
|
|
531
|
+
|
|
532
|
+
- ef927bc: Carry per-task assignees on `DiagnoseInput`, and stop exporting `openStage` / `assigneesOf`.
|
|
533
|
+
|
|
534
|
+
The "who is this task waiting on" answer is now derived once into `DiagnoseInput.assignees` — a `Record<string, Assignee[]>` keyed by task name — when `diagnoseInputFromEvaluation` builds the input, rather than left for each consumer to recompute. `openStage` and `assigneesOf`, which only ever existed to derive that data, are no longer part of the public surface; they stay internal to the diagnostics module. `DiagnoseInput.instance` also drops `stages`, which no longer has a reader now that the assignee lookup is precomputed.
|
|
535
|
+
|
|
536
|
+
- 32f5e64: Name the remediations that would unstick a stuck instance from the engine, not the CLI.
|
|
537
|
+
|
|
538
|
+
`workflow.diagnose` now returns `remediations` alongside the diagnosis — a `SuggestedRemediation[]`, empty unless the instance is `stuck`. Each entry names a surface-neutral `RemediationVerb` (`retry-effect` | `drain-effects` | `reset-task` | `set-stage` | `abort`), whether it's runnable as an engine operation today (`available`), and a one-line rationale. The new `remediationsFor` reasoning plus the `SuggestedRemediation` / `RemediationVerb` types are exported. Previously the cause→fix mapping — including which repair verbs exist versus are still planned — lived only in the CLI, so every other consumer (MCP, a future UI) would have had to re-derive it.
|
|
539
|
+
|
|
540
|
+
The CLI `diagnose` command now renders its "suggested fix" block from these remediations — a not-yet-runnable verb is dimmed and marked `(planned)` — instead of hardcoding the prose, and includes `remediations` in `--json` output.
|
|
541
|
+
|
|
542
|
+
- df7582f: Fix two stacked bugs that let a threshold-gated workflow auto-advance past a review it couldn't actually evaluate.
|
|
543
|
+
- **Filters fail closed on unevaluable operands.** A `task.filter` / `transition.filter` that evaluates to GROQ `null` (a referenced operand was missing or incomparable, e.g. `$state.subject.amount > 10000` when the subject isn't loaded) is no longer coerced to `false`. A `null` gate previously _skipped_ the task / fell through the transition — fail-open. Now an unevaluable `task.filter` keeps the task in scope (the stage holds rather than cascading through), an unevaluable transition filter halts selection, and the undecidability is recorded on `TaskEntry.filterEvaluation.unevaluable` so the audit trail distinguishes "genuinely below threshold" from "couldn't read the field". Unevaluability also propagates through nullary predicates (an unevaluable predicate binds `null`, not `false`).
|
|
544
|
+
- **Content hydration is draft-preferring.** When an instance has no explicit release perspective, content reads now use a drafts-preferring perspective (drafts overlay published) instead of a published-only point read — uniformly across `doc.ref`/`doc.refs` hydration, `type: "query"` state-entry resolution, and `subworkflows.forEach` discovery (single-sourced as `DEFAULT_CONTENT_PERSPECTIVE`). A subject that exists only as a draft — or whose in-flight edits live in the draft — is now what filters evaluate. Instance / ancestor / `system.release` documents continue to read raw. The reactive App SDK adapter (`@sanity/workflow-sdk`) matches: a content doc with no release is read under the `drafts` perspective instead of relying on the SDK's default, so a reactive session and the engine cascade resolve the same draft-aware view. (The Studio adapter already preferred `version ?? draft ?? published`.)
|
|
545
|
+
- **Diagnostics tell an undecidable hold from a dead-end.** `TransitionEvaluation` now carries `unevaluable`, and `diagnose` reports a new `transition-unevaluable` stuck cause when every task is resolved but an exit filter can't be evaluated (a recoverable hold — the cascade re-fires once the operand resolves) rather than mislabeling it the permanent `no-transition-fires` routing dead-end. `@sanity/workflow-cli`'s `diagnose` renders the distinct cause.
|
|
546
|
+
- **Single-document writes commit `visibility: 'sync'`.** `create` and `patch().commit()` now set `visibility: 'sync'` explicitly (via the new `WorkflowCommitOptions`) so the engine never reads a stale value back through a query. Adds `WorkflowCommitOptions` / `WorkflowVisibility` to the public client surface.
|
|
547
|
+
|
|
548
|
+
- 129258c: Add task `requirements` — a readiness axis for visible-but-not-yet-executable tasks, distinct from ACL and guards.
|
|
549
|
+
|
|
550
|
+
A task definition can now declare `requirements: { <name>: <groq-condition> }`: preconditions over the rendered scope that gate _executability_ without affecting visibility. While a requirement is unmet the task stays visible, and every action on it is reported disabled with a new `requirements-unmet` verdict that names the unmet requirements; `TaskEvaluation.unmetRequirements` summarises the same set at the task level so a consumer can explain why the whole task is gated. Requirements are all-of (express any-of inside a single condition), evaluated in the same scope as `filter`, and — like every engine-side check — advisory; the lake remains the enforcement point.
|
|
551
|
+
|
|
552
|
+
Diagnostics gain a `blocked` state: an active task held by an unmet requirement is reported as visible-but-not-yet-executable (healthy and transient), distinct from `stuck`, and a requirements-held task no longer masks an actionable sibling in the `waiting` verdict.
|
|
553
|
+
|
|
554
|
+
The CLI `fire-action` listing and the MCP server's disabled-reason copy both render the new reason; the CLI `diagnose` command renders the new `blocked` state.
|
|
555
|
+
|
|
556
|
+
### Patch Changes
|
|
557
|
+
|
|
558
|
+
- ed4174c: Route subworkflow spawn discovery by a `release.ref` subject, not just `doc.ref`. A release-subject workflow (e.g. content-release) carries only a `release.ref` state entry; spawn discovery previously picked its lake client from the first `doc.ref` entry only, so with no `doc.ref` it fell back to the engine's own resource. On a multi-dataset setup — engine docs in one dataset, content/version docs in another — the `forEach` then queried the wrong dataset and matched nothing, spawning zero children. Discovery now follows the first `doc.ref` _or_ `release.ref` subject GDR in declaration order, so a release workflow's `forEach` hits the content resource (under the already-threaded `[releaseName]` perspective) where the release's version docs live. A workflow that declared a `release.ref` before a `doc.ref` would now route by the earlier `release.ref` (no such workflow exists today; authors put the intended routing subject first).
|
|
559
|
+
|
|
560
|
+
Also fail loud on a related footgun: when spawn discovery runs against a foreign resource but a `subworkflows.with` value projects a **bare** id, that id would silently root at the parent (engine) resource — minting a child whose subject references a document that doesn't exist there. The engine now throws with guidance to project an explicit GDR URI in the `with` GROQ (`"dataset:<projectId>:<dataset>:" + _id`), instead of spawning a child that reads nothing.
|
|
561
|
+
|
|
562
|
+
## 0.6.0
|
|
563
|
+
|
|
564
|
+
### Minor Changes
|
|
565
|
+
|
|
566
|
+
- c6d1762: Model the engine tag as a single environment partition: `tags: string[]` becomes `tag: string`.
|
|
567
|
+
|
|
568
|
+
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).
|
|
569
|
+
|
|
570
|
+
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.
|
|
571
|
+
|
|
572
|
+
The test bench's `createBench({ tags })` option becomes `createBench({ tag })`.
|
|
573
|
+
|
|
574
|
+
## 0.5.0
|
|
575
|
+
|
|
576
|
+
### Minor Changes
|
|
577
|
+
|
|
578
|
+
- dbc4afa: Move the instance-reasoning behind the CLI's read commands into the engine so every consumer shares one implementation rather than re-deriving it. The engine now exports the stuck-instance classifier (`diagnoseInstance` with `Diagnosis` / `StuckCause` / `DiagnoseInput`, plus `abortReason`, `openStage`, `assigneesOf`, `diagnoseInputFromEvaluation`), the available-actions projection (`availableActions` with `AvailableAction`), and definition-diff classification (`diffEntry` / `computeDiffEntries` with `DiffEntry` / `DeployTarget`) — all reusing the engine's own doc-id minting and no-op verdict. Two instance-id-keyed verbs compose this with `evaluate`: `workflow.diagnose` and `workflow.availableActions` (also on the `Engine` wrapper).
|
|
579
|
+
|
|
580
|
+
The CLI's `diagnose`, `fire-action`, `deploy`, and `definition diff` commands keep only their rendering and now call the engine for the reasoning, and the MCP's `get_workflow_state` projects its action verdicts through the same shared `availableActions`. No behavioural change to the CLI or MCP surfaces.
|
|
581
|
+
|
|
582
|
+
- e2a6d5d: Add `workflow.deleteDefinition` — remove a deployed workflow definition from the lake by its `name`, every version by default or a single one via `version`. Definitions are the only thing the engine ever deletes: without `cascade` the delete refuses while non-terminal instances exist; with it those instances are hard-stopped through the same abort primitive as `workflow.abortInstance` (with ancestor propagation), and their documents remain in the lake as the audit record. Deletes refuse while any surviving deployed definition — another workflow, or a remaining version of the same one — still spawn-references a target (a version-pinned ref blocks its version; a version-less or `latest` ref blocks only the last version). Once the last version goes, the definition's orphaned guard docs are removed across every datasource its versions statically named.
|
|
583
|
+
|
|
584
|
+
Supporting changes: `WorkflowTransaction` gains a `delete(id)` member (transaction-only by design — the top-level client surface stays delete-free), the shared `abortAndPropagate` primitive backs both aborting verbs, and the `Engine` wrapper gains `deleteDefinition`.
|
|
585
|
+
|
|
586
|
+
### Patch Changes
|
|
587
|
+
|
|
588
|
+
- aa87a1c: Fix cross-resource mutation-guard deploy/retract routing. When a workflow instance lives in one resource (the engine's `workflowResource`) and its subject document lives in another (routed via the caller's `resourceClients`), a stage's `guards[]` are now WRITTEN and LIFTED through the SUBJECT's resource rather than silently falling back to the engine's own dataset. A guard stored in the wrong dataset is a silent no-op — that dataset's write boundary never enforces it.
|
|
589
|
+
|
|
590
|
+
The `fireAction`, `setStage`, `abortInstance`, `completeEffect`, and `invokeTask` engine entry points — and the reactive `InstanceSession.fireAction` — dropped the routed `clientForGdr` before reaching `deployStageGuards` / `retractStageGuards` / `refreshStageGuards`, falling back to the default client. `EngineCallOptions` now carries `clientForGdr`, and both the stateless API layer and the reactive session thread the resolver (built from `resourceClients`) through every guard-touching commit path. The transition/cascade path was already routed correctly. Cross-resource retraction (stage exit, lift, abort) now routes to the subject resource too, so a cross-resource-deployed guard is liftable.
|
|
591
|
+
|
|
592
|
+
## 0.4.0
|
|
593
|
+
|
|
594
|
+
### Minor Changes
|
|
595
|
+
|
|
596
|
+
- 4b5f59a: Add `workflow.abortInstance` — an admin hard-stop for in-flight instances. Aborting stamps a new `abortedAt` timestamp alongside `completedAt` (in-flight queries keep their `!defined(completedAt)` shape; `abortedAt` distinguishes aborted from completed), exits the open stage entry with task statuses frozen as-is, cancels every pending effect into `effectHistory`, appends a `workflow.history.aborted` audit entry, lifts the aborted stage's lake guards, and propagates to ancestors so a parent's spawn-completion gate re-evaluates. Aborting an already-terminal instance is a `{fired: false}` no-op.
|
|
597
|
+
|
|
598
|
+
Supporting behaviour fixes: a terminal instance never moves again — neither auto-transitions nor `setStage` fire once `completedAt` is stamped (previously a completed instance could be moved off its terminal stage into an incoherent half-state); spawn-completion counting treats any `completedAt`-stamped child as terminal so an aborted child can't hang its parent's gate; stage-guard retraction now lifts guards for a terminal instance still parked on its stage; and stage-guard deployment refuses aborted instances, so a stale reconcile racing the abort can't re-lock subjects that nothing could ever unlock again.
|
|
599
|
+
|
|
600
|
+
- fcf938a: Rebuild the authoring DSL and stored model around generic primitives plus
|
|
601
|
+
define-time sugar. Breaking across the board:
|
|
602
|
+
- **Name identity everywhere.** `name` replaces `id`/`workflowId` on
|
|
603
|
+
definitions, stages, tasks, actions, transitions, state entries, and
|
|
604
|
+
effects; instances carry `definition` + `currentStage` by name. The API
|
|
605
|
+
boundary follows: `startInstance({definition})`,
|
|
606
|
+
`guardsForDefinition({definition})`, `children({task})`, deploy results
|
|
607
|
+
carry `definition`, and guard provenance is `sourceDefinition` /
|
|
608
|
+
`sourceStage`. The pre-rebuild "slot" vocabulary is gone — state values
|
|
609
|
+
validate as state entries (`StateValueShapeError` with `entryType` /
|
|
610
|
+
`entryName`). Bare
|
|
611
|
+
in-array `type` discriminators replace namespaced `_type` kinds
|
|
612
|
+
(`doc.ref`, `assignees`, `state.set`, …). Engine-owned lake doc types are
|
|
613
|
+
`sanity.workflow.definition` / `sanity.workflow.instance`.
|
|
614
|
+
- **Two-layer schemas.** Authoring shapes (primitives + sugar) desugar at
|
|
615
|
+
define-time into the stored definition: the claim pair, the `audit` op
|
|
616
|
+
(stamped append), `roles`/`status` field sugar, lexical scope resolution
|
|
617
|
+
for op targets, and the default `$allTasksDone` transition filter.
|
|
618
|
+
State-entry names and predicate keys must be GROQ-safe identifiers (they
|
|
619
|
+
are referenced as `$state.<name>` / `$<name>` in conditions). Deploy
|
|
620
|
+
invariants reject duplicate names (stages, tasks, actions, transitions,
|
|
621
|
+
state entries per scope, guards across the definition, effects), unknown
|
|
622
|
+
targets, reserved-var shadowing, predicate cross-references and
|
|
623
|
+
caller-scoped vars in predicates, `$can` outside action filters, and
|
|
624
|
+
malformed guard reads.
|
|
625
|
+
- **Ops are the only mutation vocabulary.** `state.set` / `state.unset` /
|
|
626
|
+
`state.append` / `state.updateWhere` / `state.removeWhere` /
|
|
627
|
+
`status.set`, with `{scope, state}` targets resolved at deploy and
|
|
628
|
+
`status.set` as the single task-resolution mechanism (stamps completion +
|
|
629
|
+
history).
|
|
630
|
+
- **Lifecycle payloads.** Tasks own activation (`activation: 'auto' |
|
|
631
|
+
'manual'`, machine-step auto-resolution, `completeWhen`/`failWhen`,
|
|
632
|
+
`subworkflows` fan-out with `$subworkflows` completion gates and parent
|
|
633
|
+
`context` handoff); transitions own exit/arrival (always-filtered,
|
|
634
|
+
first-truthy wins, carry ops + effects); terminal stages are structural
|
|
635
|
+
(no transitions).
|
|
636
|
+
- **One gate mechanism.** Conditions are GROQ over the rendered scope
|
|
637
|
+
(`$self`, `$state`, `$actor`, `$assigned`, `$can`, `$effects`, `$tasks`,
|
|
638
|
+
`$subworkflows`, `$now`, …) with author predicates pre-bound as boolean
|
|
639
|
+
params. Everything engine-side is advisory; hard enforcement is the lake
|
|
640
|
+
ACL plus guards (`temp.system.guard` docs with `metadata` projections and
|
|
641
|
+
name-derived ids).
|
|
642
|
+
- **Effects registry.** Effects are `{name, bindings, input}` with
|
|
643
|
+
GROQ-string bindings over the rendered scope; completed-effect `outputs`
|
|
644
|
+
land in `effectsContext` namespaced under the effect's name and read as
|
|
645
|
+
`$effects['<effect name>'].<output>`.
|
|
646
|
+
|
|
647
|
+
The test bench mirrors the new surface (`task` instead of `taskId`,
|
|
648
|
+
name-keyed reads, `effectsContextMap` returning the rendered `$effects`
|
|
649
|
+
map).
|
|
650
|
+
|
|
651
|
+
- 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.
|
|
652
|
+
- 12e23b0: Live lake mutation guards now flow through the reactive stack and into evaluation.
|
|
653
|
+
|
|
654
|
+
**Engine:** `InstanceSession` gains `updateGuards(guards)`, and `evaluateFromSnapshot` accepts `guards` — every action commit is an `update` write to the instance doc, so a held guard that matches the instance and denies that write disables every action with the (previously declared but never produced) `mutation-guard-denied` reason, carrying the denying `guardIds`. A lifted guard (predicate `"true"`) allows; deny-all (`""`) denies; fail-closed. `fireAction`'s soft-gate uses the same projection, so firing while guard-denied throws `ActionDisabledError` with the structured reason. The stateless fetch path agrees: `workflow.evaluate` / `workflow.fireAction` load the instance's guards (new `verdictGuardsForInstance`) before evaluating, so both paths produce the same verdicts for the same state. Verdict loads are **physically scoped to the engine's own datasource** — a guard enforces solely within the datasource it lives in, so neither an honestly-stamped foreign guard nor a forged guard doc planted in a watched content dataset can ever flip a verdict (the cross-datasource `guardsForInstance` union remains housekeeping-only). `tick` (session and stateless) pre-flights the same instance write and fails fast with `MutationGuardDeniedError` before entering the multi-step cascade, instead of risking a partially-applied move; best-effort — the lake remains the final arbiter and the rollback machinery stays the backstop. New `instanceGuardQuery(instanceId)` is the single definition of the per-instance guard filter, shared by the verdict load and the adapters' subscriptions.
|
|
655
|
+
|
|
656
|
+
**Adapters:** `useWorkflowInstance` / `useWorkflowSession` expose a live `guards` field and feed the stream into the session, so action verdicts re-evaluate as guards are deployed on stage entry and lifted on exit. Driven by a new required `observeGuards` method on `WorkflowObserver` (breaking for custom observers; the first-party SDK and Studio observers implement it via the SDK query store / Studio's `listenQuery`, engine-dataset-scoped), built on the engine's `instanceGuardQuery` and workflow-react's shared `NO_GUARDS` unresolved snapshot.
|
|
657
|
+
|
|
658
|
+
- 12e23b0: New shared primitives so consumers and tests build the engine's shapes exactly the way the engine does:
|
|
659
|
+
- `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).
|
|
660
|
+
- `subscriptionDocument(ref)` — build one `SubscriptionDocument` from a `GlobalDocumentReference`, the same construction `subscriptionDocumentsForInstance` uses.
|
|
661
|
+
- `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.
|
|
662
|
+
|
|
663
|
+
## 0.3.0
|
|
664
|
+
|
|
665
|
+
### Minor Changes
|
|
666
|
+
|
|
667
|
+
- 41ddd80: Add a reactive, opt-in instance session so the engine can be driven by live document state — without owning any subscription or refetching content the consumer already holds.
|
|
668
|
+
- `engine.instance(instanceDoc)` returns a stateful session: `subscriptionDocuments` (the `WatchSet` to feed), `update(docs)` (last-write-wins replace of the held content — deep-copied so it's decoupled from the consumer's live objects, and gated single-writer so it can't swap state out from under an in-flight commit), `evaluate()` (best-effort projection on the held content; async only because filter eval is groq-js, no network), and `tick()` / `fireAction()` (advance on the held content, committing via `ifRevisionId`; a `_rev` conflict surfaces). The engine never observes or auto-ticks — the consumer drives it.
|
|
669
|
+
- `hydrateSnapshot` takes an optional content overlay (present id → held value; absent → load), threaded through the cascade so guards/filters/transitions evaluate against the consumer's optimistic content. The instance/ancestors keep reloading (their own writes); `release.ref` (`system.release`) docs read raw; content honours `instance.perspective`.
|
|
670
|
+
- Watch-set/inject surface: `subscriptionDocumentsForInstance` / `WatchSet` (incl. `release.ref`), `readsRaw` (the raw-vs-content read rule a consumer applies when resolving perspective) and `contentReleaseName` (turns the watch-set perspective into the per-doc release a store reads under), `evaluateFromSnapshot`, and `buildSnapshot` / `HydratedSnapshot` / `LoadedDoc` / `resourceFromParsed` are exported so a consumer can assemble and inject snapshots.
|
|
671
|
+
|
|
672
|
+
- 0c6ccde: Extract shared definition-query primitives so the "tag-scoped, latest-or-pinned `workflow.definition`" query lives in one place instead of being hand-written at each call site.
|
|
673
|
+
|
|
674
|
+
The engine now exports two reusable GROQ primitives:
|
|
675
|
+
- `WORKFLOW_DEFINITION_TYPE` / `WORKFLOW_INSTANCE_TYPE` — the lake document-type constants, replacing scattered string literals.
|
|
676
|
+
- `tagScopeFilter(param?)` — the tag-partition GROQ predicate (`count(tags[@ in $engineTags]) > 0`), defaulting to the `engineTags` parameter the `workflow.query` guard expects.
|
|
677
|
+
|
|
678
|
+
Internally these compose into a single `definitionLookupGroq` selector for the "latest-or-pinned definition" query. The engine's own lookups (`deploy`, `spawn`, `drain`) and the CLI (`definition show`/`list`, `list`, `deploy`) now build on these primitives, and every tag-scoped query in those paths standardizes on the `$engineTags` parameter — removing the prior `$tags`/`$engineTags` drift.
|
|
679
|
+
|
|
680
|
+
## 0.2.0
|
|
681
|
+
|
|
682
|
+
### Minor Changes
|
|
683
|
+
|
|
684
|
+
- 870188e: The auto-transition cascade now aborts a runaway loop with a typed
|
|
685
|
+
`CascadeLimitError` (carrying `instanceId` and `limit`) instead of a generic
|
|
686
|
+
`Error`. A runaway is the classic op-driven flip-flop: an `op.state.set` lands a
|
|
687
|
+
value that trips the very transition guard that fired it, and nothing clears it,
|
|
688
|
+
so two stages bounce forever. The new error is exported from the package root so
|
|
689
|
+
callers can `instanceof`-narrow it and read its fields. Ops need no separate
|
|
690
|
+
per-action limit — an action's `ops[]` is a finite list that runs once per
|
|
691
|
+
`fireAction`, so the transition cascade is the only place a runaway can live.
|
|
692
|
+
- a4f6d81: The `fireAction` commit is now safe against concurrent callers. Each attempt
|
|
693
|
+
reloads the instance and commits through `persist`'s existing `ifRevisionId`
|
|
694
|
+
guard, so two clients firing the same action on the same task can no longer
|
|
695
|
+
silently clobber each other's commit. A commit that loses the optimistic-locking
|
|
696
|
+
race reloads and retries — three attempts total — then throws a new
|
|
697
|
+
`ConcurrentFireActionError` (exported from the package root, carrying
|
|
698
|
+
`instanceId` / `taskId` / `action` / `attempts`). Reloading re-gates the action,
|
|
699
|
+
so a retry whose task the winning writer already completed fails with the normal
|
|
700
|
+
"task must be active" error rather than looping.
|
|
701
|
+
|
|
702
|
+
Scope: this covers the action-commit write. The pending→active auto-invoke and
|
|
703
|
+
the post-commit cascade are separate writes, still single-shot (a race there
|
|
704
|
+
surfaces as the underlying conflict, not `ConcurrentFireActionError`).
|
|
705
|
+
|
|
706
|
+
`drainEffects`' claim step now uses the same conflict predicate: a lost
|
|
707
|
+
`ifRevisionId` race still means "another drainer won, skip", but a genuine error
|
|
708
|
+
during the claim now surfaces instead of being silently swallowed as a missed
|
|
709
|
+
claim.
|
|
710
|
+
|
|
711
|
+
- eff2a77: Export `isDefinitionUnchanged` and `stripSystemFields` from the package root. Deploy-preview tooling (e.g. a `deploy --dry-run` that wants to predict whether `deployDefinitions` would treat a definition as a no-op) can now reuse the engine's exact "unchanged definition" verdict — comparing definition documents with Sanity-managed system fields (`_rev` / `_createdAt` / `_updatedAt`) stripped — instead of reimplementing it. `isDefinitionUnchanged` now accepts any `Record<string, unknown>` for the existing document (previously required a full `SanityDocument`).
|
|
712
|
+
- ce6960d: Guard provenance + housekeeping queries.
|
|
713
|
+
- 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`.
|
|
714
|
+
- 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).
|
|
715
|
+
- New exported `guardsForInstance` / `guardsForResource` helpers and a centralized `GUARD_DOC_TYPE` constant (single rename point for the eventual `temp.` prefix drop).
|
|
716
|
+
- The bench exposes `guardsForInstance`; its `listGuards` now delegates to `guardsForResource`.
|
|
717
|
+
|
|
718
|
+
- 782272a: Keep mutation guards coherent with workflow state. A guard snapshots the state its `match`/`metadata` Sources resolve to at deploy time; when an action's ops change that state without a stage transition, the engine now re-deploys the current stage's guards so the lock follows the new state ("use the engine, stay consistent"). Fail-loud on a failed re-deploy for now — the rollback-or-scream policy lands separately.
|
|
719
|
+
- 2b800a8: Roll a state move back (or scream) when its guards can't deploy. Guards deploy _after_ the state commits, through a shared `deployOrRollback` primitive — wired by `persistThenDeploy` for the transition and action paths, and called directly by the initial-prime path. If the deploy fails, the engine restores the pre-commit instance and re-throws the original error, so the caller sees the failure with state intact. When a clean rollback is impossible (the move spawned child docs a parent-only undo can't remove) or the rollback write itself fails, it throws the newly exported `WorkflowStateDivergedError` — divergence is loud, never silent. This replaces the fail-loud stub the refresh path previously left.
|
|
720
|
+
- 0432670: Injectable clock for deterministic time. The engine now threads a single
|
|
721
|
+
`Clock` (`() => string`, default wall-clock) through each verb-driven
|
|
722
|
+
operation, taking one reading per operation so `$now` in filters, the `now`
|
|
723
|
+
op-source, and the timestamps the engine stamps (history `at`, `completedAt`,
|
|
724
|
+
`enteredAt`, `lastChangedAt`, queued/ran effect stamps, query-slot `resolvedAt`)
|
|
725
|
+
all share one instant. Production passes nothing and is unchanged. The clock is
|
|
726
|
+
injected ONCE — via `createEngine({ clock })`, or the test bench's `setNow` /
|
|
727
|
+
`advance` — not as a per-verb option; the raw `workflow.*` verbs accept it only
|
|
728
|
+
through an internal seam, keeping the public `*Args` interfaces free of it. The
|
|
729
|
+
external effect drainer (`drainEffects`) is out of scope and still uses
|
|
730
|
+
wall-clock. The pure
|
|
731
|
+
core (`buildParams`, `resolveSource`) now takes `now` as a value rather than
|
|
732
|
+
reading wall-clock itself, restoring `core/`'s "no `new Date()`" contract.
|
|
733
|
+
`Clock` and `wallClock` are exported from the package root.
|
|
734
|
+
|
|
735
|
+
The bench (`@sanity/workflow-engine-test`) exposes the knob: `bench.setNow(iso)`
|
|
736
|
+
freezes time, `bench.advance(ms)` moves it forward, `bench.now()` reads it, and
|
|
737
|
+
`createBench({ now })` starts frozen. The clock is threaded into every wrapped
|
|
738
|
+
verb, so a time-based `completeWhen` / `failWhen` / SLA predicate fires by
|
|
739
|
+
advancing the clock and ticking — without mutating the subject document.
|
|
740
|
+
|
|
741
|
+
Also removes the never-implemented `dueAt` hint field from `TaskEntry`: nothing
|
|
742
|
+
ever wrote or read it, so it carried no behaviour. Re-add it with a real
|
|
743
|
+
implementation if a cron-indexing fast-path is built later.
|
|
744
|
+
|
|
745
|
+
- c2ad7a9: Resolve two reserved-but-unimplemented surfaces:
|
|
746
|
+
- **Removed the `source.kind: "computed"` state-slot reserved word.** It was
|
|
747
|
+
accepted by the schema but always rejected at deploy ("reserved (not
|
|
748
|
+
implemented)"), with no real usage — a dead word. Dropped from the authoring
|
|
749
|
+
schema, the deploy validator, and the resolver. The actual re-resolving-slot
|
|
750
|
+
feature remains deferred to a future version; it can be re-introduced as a
|
|
751
|
+
real source kind then.
|
|
752
|
+
- **Implemented `Source.self` inside ops.** `resolveOpSource` previously
|
|
753
|
+
returned `undefined` for `source: "self"` — a silent no-op. It now resolves to
|
|
754
|
+
the instance's own GDR URI, matching `$self` in filters and `Source.self` in
|
|
755
|
+
spawn sources, so an op can stamp the instance's own ref into a slot. (The
|
|
756
|
+
effect/binding resolver's `self` keeps returning the bare `_id` it already
|
|
757
|
+
did.)
|
|
758
|
+
|
|
759
|
+
### Patch Changes
|
|
760
|
+
|
|
761
|
+
- d04cbea: Fix actor/grants resolution crashing on `@sanity/client` >= 7.22. The engine pulled `request` off the client and called it detached, so the method's private-field access through `this` threw before any request was issued (`Cannot read private member #httpRequest…`). The client's `request` is now bound at the single point it leaves the client, so consumers no longer need to pre-bind `client.request` before calling `evaluate`.
|
|
762
|
+
- dff88ff: Guard reconcile is now gated on the instance's live committed stage. Deploy/retract run as a post-persist step outside the instance's `ifRevisionId` guard, so a stale or retried reconcile could previously land after a newer transition had already moved the instance on — re-enforcing a stage the instance had left, or lifting the lock it was actively under. `deployStageGuards` now only deploys while the instance is still committed to that stage, and `retractStageGuards` only lifts while it is no longer there. The cross-resource TOCTOU between the live-stage read and the guard write remains a documented seam (same non-atomicity as orphan accumulation).
|
|
763
|
+
- 53a0598: Replace zod with valibot for authoring-schema and slot-value validation. Validation lives in two places — the `defineWorkflow` authoring schema (`@sanity/workflow-engine/define`) and per-slot value validation — both of which a consumer bundles into a client. Tree-shaken, that surface drops from ~67 KB to ~5 KB gzip (the full package entry from ~105 KB to ~42 KB), because valibot's modular API only pulls in the validators actually used. Authoring error messages, paths, and validation behaviour are unchanged; cross-field invariant checks now run as an explicit second phase after the structural parse.
|
|
764
|
+
|
|
765
|
+
## 0.1.0
|
|
766
|
+
|
|
767
|
+
### Minor Changes
|
|
768
|
+
|
|
769
|
+
- 43d6b81: Initial release.
|
|
770
|
+
- **`@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`.
|
|
771
|
+
- **`@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).
|
|
772
|
+
|
|
773
|
+
### Patch Changes
|
|
774
|
+
|
|
775
|
+
- 127be08: Split the two largest source files into folders of focused, single-concern modules. `src/engine.ts` (2321 lines) becomes `src/engine/` (`results`, `context`, `mutation`, `effects`, `ops`, `spawn`, `tasks`, `stages`, `actions`, `cascade`), and `src/api.ts` (1876 lines) becomes `src/api/` (`types`, `validate`, `deploy`, `operations`, `workflow`, `engine-factory`, `drain`). There is no internal barrel — each module imports the concrete files it needs directly, and the public surface (`src/index.ts`) re-exports the same symbols from those files. No public API change — every exported symbol keeps its name and shape.
|
|
776
|
+
|
|
777
|
+
The one irreducible cycle (`mutation.persist` ⇄ `cascade`, the essential commit/spawned-child-lifecycle mutual recursion centralized at the single transactional commit chokepoint) is documented with a `fallow-ignore-file` directive explaining why it cannot be topologically ordered without breaking synchronous nested-workflow semantics.
|
|
778
|
+
|
|
779
|
+
- bf62213: Reorganize the engine's type model: the monolithic `src/types.ts` is split into a layered `src/types/` directory (`enums`, `ids`, `actor`, `state-slots`, `instance-state`, `effects`, `authorization`, `history`, `client`, `instance`, `evaluation`). The modules form a strict dependency DAG, and `define/schema.ts` imports its value enums from the schema-free `types/enums.ts` leaf so the schema ↔ type-model relationship is acyclic by construction. There is no internal barrel — each module imports the concrete type files it needs directly; the package's public surface (`src/index.ts`) re-exports the same symbols from those files. No public API change — every exported symbol keeps its name and shape.
|