@sanity/workflow-engine 0.15.0 → 0.17.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 +79 -0
- package/DATAMODEL.md +177 -33
- package/dist/_chunks-cjs/invariants.cjs +1377 -330
- package/dist/_chunks-es/invariants.js +1319 -326
- package/dist/define.cjs +3 -608
- package/dist/define.d.cts +125 -416
- package/dist/define.d.ts +125 -416
- package/dist/define.js +2 -607
- package/dist/index.cjs +5370 -3906
- package/dist/index.d.cts +1439 -597
- package/dist/index.d.ts +1439 -597
- package/dist/index.js +5191 -3829
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# @sanity/workflow-engine
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9389e5: **BREAKING:** the evaluation nodes gain a required causal-autonomy slot — `WorkflowEvaluation.autonomy` (`WorkflowAutonomy`), `StageEvaluation.autonomy` (`StageAutonomy`), `ActivityEvaluation.autonomy` (`AutonomyAnswer`). Code that constructs evaluation shapes by hand (test fixtures, adapters) must now supply them; reading consumers are unaffected.
|
|
8
|
+
|
|
9
|
+
Causal autonomy analysis: `deriveWorkflowAutonomy(definition, {children?})` answers "will this activity complete without a caller, and if not, what is it waiting on?" by static dataflow over the stored definition, instance-free. Each gate's reads are classified by their producers — writer actions (caller- vs cascade-fired, followed transitively), `input` seeds, the edit seam, effect completions (autonomous given the host's drain), spawned children (recursed when a `children` map resolves them), lake content reads, `$now` — and rolled up per activity, stage, and workflow as `completesWithoutCaller: 'yes' | 'no' | 'conditional'` plus structured `waitsOn` entries. A cascade-fired action's `roles` execute pin counts as a dependency: the runtime holds a pinned trigger armed until a capable token cascades, so a pinned automation reports `conditional` with an `execute-pin` wait naming the roles. This is the causal refinement of the mechanical `ExecutorClassification`: an all-`when` activity whose triggers only read caller-written fields classifies `autonomous` but answers `no` here, naming the caller action it waits on (role pin recovered from the desugared membership filter). Advisory like every insight — a narratable answer, never a gate.
|
|
10
|
+
|
|
11
|
+
Narration joins the insight render surface: `describeAutonomyWait(wait, ctx)` phrases one wait, `narrateAutonomyWaits(waits, ctx)` phrases and de-duplicates a list, `autonomySummary(autonomy, ctx)` gives the workflow-level line ("this workflow runs itself except at …"). `stageAutonomyOf(autonomy, stageName)` / `activityAutonomyOf(stageAutonomy, activityName)` are the total rollup lookups (throw on a miss), and the engine root now also re-exports `atomReadsDataset` alongside the rest of the `@sanity/groq-condition-describe` surface.
|
|
12
|
+
|
|
13
|
+
- c3eed2e: Export `entryDocRefs` — the canonical `doc.ref`/`doc.refs` GDR extraction over field entries (with its untrusted-lake-data narrowing) — and add `limit` to `InstancesQueryFilter`: a capped read returns the NEWEST rows (`startedAt desc`, sliced), so bounded consumers stop streaming unbounded datasets.
|
|
14
|
+
- 092a0d4: **BREAKING:** runtime-supplied refs written into field state are gated on the deployment's declared resource surface.
|
|
15
|
+
- A `doc.ref` / `doc.refs` / `release.ref` value a caller or effect handler supplies — start `initialFields` input, an action's param-sourced op values, `editField` values, effect-completion ops — must target a declared resource: the `workflowResource` itself or a resource the `resourceClients` resolver serves. An off-surface ref throws the new `RefResourceUndeclaredError` and the commit aborts, instead of writing cleanly and failing later when something dereferences it. The check is advisory like every engine check; stored documents are unchanged.
|
|
16
|
+
- Definition-authored refs are exempt: literal `initialValue` seeds, `type: 'query'` texts, spawn `with` projections, and op values that bind no action param were validated (and alias-expanded) at deploy, so they materialize into field state without re-checking.
|
|
17
|
+
- Resource aliases stay a deploy-time abstraction: `deployDefinitions({definitions, resourceAliases?})` expands every `@<alias>:` reference to its physical GDR before fingerprinting, and nothing alias-shaped survives past deploy — the runtime holds no alias map, and an alias-shaped ref supplied to a runtime verb is rejected as malformed like any other non-GDR string.
|
|
18
|
+
- `createBench` takes `serveResources` (dataset siblings served — and thereby declared — straight from the bench's own store) plus a raw `resourceClients` resolver for anything beyond same-store siblings. The engine also exports `sameResource` alongside the existing GDR helpers.
|
|
19
|
+
- The CLI wires no `resourceClients`, so refs supplied at runtime through it (`start --field`, `fire-action` ref params) admit the workflow resource only.
|
|
20
|
+
|
|
21
|
+
- 5a1a9fe: **BREAKING:** engine reads of persisted documents now parse instead of cast — a malformed `sanity.workflow.instance` or guard document fails the read with a `PersistedDocShapeError` naming the document and the offending fields, where it previously flowed through typed casts and corrupted behavior downstream. Reads that silently tolerated corruption now fail hard: `children` throws on a malformed spawn ref in history (previously skipped the row), and the reactive session rejects malformed self-doc pushes and guard updates with the typed error.
|
|
22
|
+
|
|
23
|
+
Runtime valibot schemas are the single source of truth for each persisted doc shape: `WorkflowInstanceSchema` and `MutationGuardDocSchema` (the definition already had one), pinned field-by-field to the hand-written types so drift is a compile error. The parses are tolerant of unknown keys at every level (the data-model round-trip rule) and of unknown future `history[]` variants, and exact on everything the engine interprets. New exports: `parseInstanceDocument`, `parseGuardDocument`, `readInstanceDoc` (model gate + parse composed), `PersistedDocShapeError`, the two schemas, and `schemaTreeShape` (the declared field tree of a schema — what the model-surface ledger now pins for the instance and guard doc types).
|
|
24
|
+
|
|
25
|
+
- e3a7ba2: **BREAKING:** a supplied `instanceId` is now `startInstance`'s idempotency key. When the id already exists under the engine's tag for the same start, the call RESUMES the outstanding prime/cascade commits — the retry path for a start that failed after its create landed — instead of failing on the `_id` collision. Reusing an id for a different start (definition or explicit version mismatch), or for an unfinished start that was aborted, throws `ContractViolationError`. The mid-sequence failures are typed and carry the retry id: a prime failure after the create committed throws the new `StartNotPrimedError` (a failed but resumable start — the message names the id to retry with), and a first-cascade failure after a successful prime throws the new `StartNotSettledError` (carrying the primed instance when re-readable) — the run exists by then and must not be reported as a failed start; `WorkflowStateDivergedError` still passes through unwrapped. For standalone (`startInstance`) starts the `Editorial Workflows Instance Started` telemetry event fires once the instance is primed (started = created + primed) rather than after the first cascade settles; spawned children keep emitting after they fully settle. New exports: `isUnprimed` (classifies a created-but-unprimed instance — an unfinished, resumable start) and `instanceDocId` (mint an id to hold across start attempts).
|
|
26
|
+
- 30fed9e: **BREAKING:** deploy now rejects a set of authoring shapes that previously deployed clean and failed silently at runtime. Already-deployed definitions keep running — the rejections fire on `defineWorkflow` and (re)deploy only:
|
|
27
|
+
- A `$row` read at any condition site that doesn't bind it (only a spawn `with` projection and a where-op `where` do) is rejected — a bare `$row` in a transition `when`, activity `filter`, predicate, effect binding, `spawn.forEach`, or `spawn.context` evaluated as GROQ null and could wedge transition selection.
|
|
28
|
+
- `spawn.forEach` is deploy-checked against the workflow-scope `$fields` window it actually evaluates with — a forEach reading a stage/activity field previously passed deploy and resolved null at spawn time, spawning nothing.
|
|
29
|
+
- The workflow `name` and every guard `name` must match the lake-id-segment grammar (`^[a-z0-9][a-z0-9-]*$`) — both interpolate into deployed document ids. Previously-deployable uppercase, underscore, and dotted names are now rejected: a dot collides with the id's own `.` separator, and looser spellings failed the whole deploy batch (or, for guards, the stage-entry commit) with an opaque lake 400.
|
|
30
|
+
- Static `$fields.<name>.<path>` reads inside condition, forEach, and `start.allowed` GROQ are checked against the entry's declared value shape, the reference envelopes included — a `release.ref` read of `._id` (the envelope key is `id`) silently inverted a fan-out match. Reads resolve to the innermost shadowing entry; windows spanning several possible contexts reject a pathed read only when it fits none of the same-named declarations, so a shape valid in any possible context is never a false rejection. In `start.allowed`, references bind as their raw envelopes — a singular `doc.ref` included, since nothing hydrates at the gate or the pre-flight — so content-field reads are rejected there.
|
|
31
|
+
|
|
32
|
+
The `content-release` example's spawn `forEach` now matches on the release envelope's `id` key (it read `._id`, which is not an envelope key, so reviews spawned for articles NOT in the release), and the guard `predicate` TSDoc spells out the polarity: strictly-`true` ALLOWS, anything else DENIES.
|
|
33
|
+
|
|
34
|
+
- 1321ba5: **BREAKING:** `start.filter` splits into visibility + permission. `filter` is now browse-time-pure — candidate document as root, `$tag`/`$definition`/`$now`, dataset scans — and any `$fields` read in it is deploy-rejected (inputs don't exist at browse time; the unbound-`$fields` evaluation let permission-style filters pass vacuously). The new `start.allowed` is the permission predicate — same context vars plus `$fields` (the caller's `initialFields` as GDR envelopes), never a candidate root — and `startInstance` enforces it: declaring the expression is the opt-in, a false or GROQ-null verdict throws the new `StartNotAllowedError` (kind `start-not-allowed`, `ConditionInsight` attached), no override arg. New reads: the `evaluateStart` verb pre-flights the gates (`StartEvaluation`: `allowed`/`outcome`/`insight`/`unboundReads`/`missingRequired`) — bindability-aware: a predicate reading a not-yet-supplied entry reports `'unevaluable'` with the entries named in `unboundReads`, never the collapsed verdict GROQ equality gives on a missing operand (at the gate absence is final, so `startInstance` evaluates it as-is). `explainStartAllowed` + `startFieldsParam` + `unboundAllowedReads` are the underlying primitives. API renames: `StartFilterScope` is now `StartScope` and no longer carries `fields` (the `$fields` map is `explainStartAllowed`'s own argument); `START_ALLOWED_VARS` joins the condition-scope inventory. For definitions already deployed with a `$fields`-reading `filter`, the read is GROQ null on every surface (nothing binds `$fields` there anymore) and the rule is inert — whether the filter then hides the definition (`defined()`-style reads fail closed) or surfaces it vacuously (`count(...) == 0`-style rules match nothing) depends on its shape; redeploy rejects it until the expression moves to `allowed`.
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- d0c62ea: A store echo buffered during a session commit that fails read validation no longer throws from the commit's settle path — which could replace a successful commit's result (or shadow its real error). The failure is deferred to the next `update` call, the seam consumers already treat as the validation surface, after that call's own docs are processed.
|
|
39
|
+
- df4bd80: Docs: an unbound start-context binding or dangling `$fields` condition read evaluates to GROQ null per read — whether the expression then fails closed or passes vacuously depends on where the null lands, so blanket "fails closed" / "silently never passes" claims are corrected to the per-shape truth across the start-context TSDoc, the deploy-rejection messages (now "silently misevaluates"), and the reference doc. The condition-evaluation module header in `groq-condition-describe` states the same per-read truth ("'unevaluable' only where the null lands as the final result"). A test now pins the genuinely-null-result unevaluable case (an ordered comparison against a missing attribute — total `==` equality can only produce a definite false). No evaluation behavior change.
|
|
40
|
+
- e683875: The evaluation projection derives every condition scope from the same rendered-scope assembly the engine's commit paths use (`renderConditionScope` in the pure core), so projection/commit parity holds by construction. Previously the projection bound workflow-scope `$fields` only, while the cascade merged the open stage's field overlay — a transition trigger routing on a stage-scope field (the idiomatic "route on state an action wrote") routed in the cascade but misreported `whenSatisfied`/`unevaluable` in `evaluate`, and the author's predicates pre-evaluated over the narrower scope. Activity `filter` insights and cascade-fired action gates are now also explained caller-free in their activity context — the scope stage entry actually binds — and `queryInScope` binds that same caller-free scope (the open stage's overlay plus the pre-evaluated `$<predicate>` booleans).
|
|
41
|
+
- a8ace4d: TSDoc: `WorkflowTransaction.delete` now names both of its consumers — engine-side `deleteDefinition` housekeeping, and the CLI's `nuke` reset outside the engine — instead of claiming `deleteDefinition` is the sole one.
|
|
42
|
+
- Updated dependencies [f9389e5]
|
|
43
|
+
- Updated dependencies [df4bd80]
|
|
44
|
+
- @sanity/groq-condition-describe@0.3.0
|
|
45
|
+
|
|
46
|
+
## 0.16.0
|
|
47
|
+
|
|
48
|
+
### Minor Changes
|
|
49
|
+
|
|
50
|
+
- 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.
|
|
51
|
+
- 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.
|
|
52
|
+
- 79c54db: Reactive session reads now match the engine's own reads.
|
|
53
|
+
- 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.
|
|
54
|
+
- 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.
|
|
55
|
+
- `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.
|
|
56
|
+
|
|
57
|
+
- 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.
|
|
58
|
+
- `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.
|
|
59
|
+
- `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.
|
|
60
|
+
|
|
61
|
+
- 35b9b85: Commit/recovery hardening and a locale-independent content fingerprint:
|
|
62
|
+
- `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`).
|
|
63
|
+
- 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.
|
|
64
|
+
- `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.
|
|
65
|
+
|
|
66
|
+
- e5f5b77: **BREAKING:** actions become the only payload mechanism — every op, effect, and spawn in the system lives on an action; structure never does.
|
|
67
|
+
- 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'`.
|
|
68
|
+
- 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.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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).
|
|
71
|
+
- 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.
|
|
72
|
+
- Spawn registry rows key per (activity entry, spawning action), so two spawn actions on one activity can never cross-adopt each other's children.
|
|
73
|
+
- `fireAction` rejects a `when` action (`cascade-fired` verdict); evaluation projects triggered actions as narratable (`triggered: true` + `whenInsight`), never as buttons.
|
|
74
|
+
|
|
75
|
+
### Patch Changes
|
|
76
|
+
|
|
77
|
+
- 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.
|
|
78
|
+
- 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.
|
|
79
|
+
- Updated dependencies [7510fd5]
|
|
80
|
+
- @sanity/groq-condition-describe@0.2.0
|
|
81
|
+
|
|
3
82
|
## 0.15.0
|
|
4
83
|
|
|
5
84
|
### Minor Changes
|
package/DATAMODEL.md
CHANGED
|
@@ -16,14 +16,22 @@ declared; the model-surface gates keep undeclared drift red:
|
|
|
16
16
|
file per `DATA_MODEL_VERSION`, **append-only**: only the current version's
|
|
17
17
|
file is ever regenerated (`vitest -u` on the gate test), so past model
|
|
18
18
|
shapes stay checked in verbatim — the historical corpus future upgrade
|
|
19
|
-
machinery tests against.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
machinery tests against. The instance and guard shapes are **declared**
|
|
20
|
+
trees, derived from each doc type's runtime schema
|
|
21
|
+
(`WorkflowInstanceSchema` / `MutationGuardDocSchema` via
|
|
22
|
+
`schemaTreeShape`) — the schema is the single source of truth for the
|
|
23
|
+
stored shape, and a declared tree pins optional fields no fixture
|
|
24
|
+
reaches. The definition document and the persist write-set stay
|
|
25
|
+
**concrete** trees built through the real constructors (the deploy plan's
|
|
26
|
+
envelope and the persist field-set are constructor facts). The ledger also
|
|
27
|
+
pins the stored grammar's vocabulary (every enum's values, every variant
|
|
28
|
+
discriminator), so a value rename is as red as a key rename.
|
|
23
29
|
- **Coverage tests** (`src/__tests__/model-surface.test.ts`) walk the stored
|
|
24
30
|
definition schema and fail when any declared key, discriminator, or
|
|
25
31
|
instantiable enum value stops appearing in the canonical document — the
|
|
26
|
-
grammar cannot grow past the fixture silently.
|
|
32
|
+
grammar cannot grow past the fixture silently. Parse gates keep the
|
|
33
|
+
schemas and the constructors in lockstep: the canonical constructor-built
|
|
34
|
+
instance and guard must parse cleanly through their doc schemas.
|
|
27
35
|
- **The lifecycle ledger** (`model-shapes/model-<N>.lifecycle.json` in
|
|
28
36
|
`workflow-engine-test`) drives real instances through the verbs —
|
|
29
37
|
including the recovery paths (expired-lease sweep, loop-back re-entry
|
|
@@ -32,13 +40,9 @@ declared; the model-surface gates keep undeclared drift red:
|
|
|
32
40
|
`SubworkflowEntry` rows, pending effects (claim included), effect
|
|
33
41
|
history, the idempotency ledger, and ALL `HistoryEntry` variants (the
|
|
34
42
|
accounting is compile-checked against the union, so a new variant fails
|
|
35
|
-
the build until the scenarios produce it).
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
variant (the scenario's first), so an _optional_ key that only appears on
|
|
39
|
-
some entries of an already-covered variant — or a conditionally-written
|
|
40
|
-
field no fixture or scenario reaches — still needs a reviewer to demand a
|
|
41
|
-
ledger-visible sample. Fixture width is part of reviewing a model change.
|
|
43
|
+
the build until the scenarios produce it). Every verb-driven document
|
|
44
|
+
must ALSO parse cleanly through the instance schema — a verb writing a
|
|
45
|
+
tree the read boundary would reject fails here, not in production.
|
|
42
46
|
|
|
43
47
|
This is **not** definition-content versioning: `version` / `pinnedVersion` /
|
|
44
48
|
`contentHash` track what an author deployed; the model stamp tracks the shape
|
|
@@ -79,19 +83,48 @@ never matches the new type).
|
|
|
79
83
|
every stamp `0..N`. There is no supported-version floor for _old_ docs;
|
|
80
84
|
introducing one is a deliberate future mechanism, never a side effect of
|
|
81
85
|
a shape change.
|
|
82
|
-
- **Fail hard on `minReaderModel` ahead
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
client gate can run — their safety rests entirely on rule 3's
|
|
86
|
+
- **Fail hard on `minReaderModel` ahead.** Every engine read of a stamped
|
|
87
|
+
doc type (definition, instance) passes through `assertReadableModel` —
|
|
88
|
+
point reads, list queries, the drained-children projection, snapshot
|
|
89
|
+
hydration's raw reads. A floor ahead of the engine's `DATA_MODEL_VERSION`
|
|
90
|
+
throws `ModelVersionAheadError` ("upgrade `@sanity/workflow-engine`")
|
|
91
|
+
instead of silently misreading a shape this engine predates. Deliberately
|
|
92
|
+
wholesale: one such document in a read's scope fails the whole read (a
|
|
93
|
+
listing, a verdict computation) rather than degrading silently. Two
|
|
94
|
+
structural exceptions: existence-only probes (no shape is interpreted),
|
|
95
|
+
and lake-side GROQ filters, which evaluate persisted fields server-side
|
|
96
|
+
before any client gate can run — their safety rests entirely on rule 3's
|
|
94
97
|
additive-only constraint.
|
|
98
|
+
- **Parse, don't cast — the shape gate behind the model gate.** After the
|
|
99
|
+
floor check, every engine read of an instance document parses it through
|
|
100
|
+
the runtime schema (`parseInstanceDocument`; `readInstanceDoc` composes
|
|
101
|
+
gate + parse), and every guard read parses through
|
|
102
|
+
`parseGuardDocument` — a malformed tree throws `PersistedDocShapeError`
|
|
103
|
+
naming the document and the offending fields, instead of corrupting
|
|
104
|
+
behavior downstream. The parse is TOLERANT where governance requires it:
|
|
105
|
+
unknown keys pass at every level (the round-trip rule), and `history[]`
|
|
106
|
+
admits unknown future variants (audit rows an engine only appends and
|
|
107
|
+
filters by known `_type`s). It is EXACT on everything the engine
|
|
108
|
+
interprets — statuses, discriminators, refs, timestamps — which makes
|
|
109
|
+
vocabulary growth inside a known persisted shape a **breaking** change
|
|
110
|
+
under rule 5 (a validating old reader rejects the new value): widen the
|
|
111
|
+
fleet's readers before any writer emits it, or raise the floor. The
|
|
112
|
+
definition document deliberately has no read-side content parse: deploys
|
|
113
|
+
are create-only and the content is strictly parsed at that sole write
|
|
114
|
+
boundary; a read-side strict parse would wrongly reject additive
|
|
115
|
+
newer-model definitions.
|
|
116
|
+
- **Consumer surfaces adopt the gate at their own direct reads.** The
|
|
117
|
+
reactive studio/sdk adapters validate every engine-owned stream at the
|
|
118
|
+
observer boundary (gate + parse, surfaced as an explicit invalid/
|
|
119
|
+
model-ahead session state instead of a throw); the CLI's direct reads, and
|
|
120
|
+
the MCP tools and Studio-plugin reads that interpret rows from the
|
|
121
|
+
deliberately-ungated `engine.query` escape hatch, apply
|
|
122
|
+
`assertReadableModel` per document — list projections carry the stamp
|
|
123
|
+
pair so projected rows gate too. Those surfaces are deliberately
|
|
124
|
+
gate-only (no shape parse): projections structurally can't shape-parse,
|
|
125
|
+
and the CLI's full-doc readers are INSPECTION surfaces — an operator must
|
|
126
|
+
be able to open the very document being investigated, so a corrupt doc
|
|
127
|
+
renders best-effort there instead of refusing.
|
|
95
128
|
- **Old writers restamp down — the round-trip rule.** An older engine
|
|
96
129
|
persisting a newer-but-compatible instance stamps its own `modelVersion`
|
|
97
130
|
(honest conforms-to; the stamp oscillates in mixed fleets, harmlessly).
|
|
@@ -113,11 +146,12 @@ never matches the new type).
|
|
|
113
146
|
upgrade consumers before producers). And like every engine-side check the
|
|
114
147
|
floor is advisory — it gates our own clients; the Content Lake stays the
|
|
115
148
|
sole enforcement point.
|
|
116
|
-
- **The gate ships from the engine.**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
149
|
+
- **The gate ships from the engine.** `assertReadableModel`, the parse
|
|
150
|
+
functions, and the error classes are exported from
|
|
151
|
+
`@sanity/workflow-engine`, so every consumer surface composes the same
|
|
152
|
+
primitives — the reactive adapters at gate + parse, the CLI/MCP/plugin
|
|
153
|
+
direct reads at gate only (see "Consumer surfaces adopt the gate" above
|
|
154
|
+
for the split and its rationale).
|
|
121
155
|
|
|
122
156
|
## Rules for changing the model
|
|
123
157
|
|
|
@@ -166,13 +200,123 @@ rules, not folklore:
|
|
|
166
200
|
— always read as changed, so the first redeploy after upgrade mints a
|
|
167
201
|
fresh version.
|
|
168
202
|
|
|
169
|
-
### Model 1 — stamping introduced (reader floor: 0)
|
|
203
|
+
### Model 1 — stamping introduced; actions-only payload model (reader floor: 0)
|
|
170
204
|
|
|
171
205
|
The `modelVersion` + `minReaderModel` pair is stamped on the definition and
|
|
172
206
|
instance doc types; the guard doc deliberately carries none (foreign lake
|
|
173
|
-
contract — see "The stamp pair").
|
|
174
|
-
|
|
175
|
-
|
|
207
|
+
contract — see "The stamp pair"). The floor is 0: model-0 documents keep
|
|
208
|
+
reading exactly as before, and every engine from the amendment below onward
|
|
209
|
+
reads model-1 documents fine (the amendment's sanction — no readers or
|
|
210
|
+
writers existed outside this repo — is what made the pre-amendment window
|
|
211
|
+
irrelevant).
|
|
212
|
+
|
|
213
|
+
**Amended in place before any consumer existed** (a sanctioned exception to
|
|
214
|
+
rule 3 — model 1 had no readers or writers outside this repo when the
|
|
215
|
+
reshape landed, so the freeze point moved rather than minting a model 2):
|
|
216
|
+
actions became the only payload mechanism, and the persisted trees reshaped
|
|
217
|
+
accordingly. One caveat was weighed and accepted when the exception was
|
|
218
|
+
confirmed: released engines predating the amendment stamp model 1 while
|
|
219
|
+
writing the pre-amendment shapes, so documents they wrote pass the stamp
|
|
220
|
+
gate and then fail ungoverned (a raw shape error, not
|
|
221
|
+
`ModelVersionAheadError`). Those documents are declared disposable — every
|
|
222
|
+
dataset holding them is a this-repo test dataset and must be reset before a
|
|
223
|
+
post-amendment engine touches it. Nothing migrates them.
|
|
224
|
+
|
|
225
|
+
Definition tree (the stored authoring model):
|
|
226
|
+
|
|
227
|
+
- Activities carry no payload: `activation`, `ops`, `effects`,
|
|
228
|
+
`completeWhen`, `failWhen`, `subworkflows`, and `kind` are gone from the
|
|
229
|
+
activity tree. `target` survives as the off-system marker.
|
|
230
|
+
- Actions gained `when` (a cascade-fired trigger condition), `spawn` (the
|
|
231
|
+
subworkflow block formerly on the activity), and `roles` (stored verbatim
|
|
232
|
+
only on cascade-fired actions — the execute pin; fireAction actions still
|
|
233
|
+
fold roles into `filter` at desugar).
|
|
234
|
+
- Transitions are pure edges: `{name, when, to}` plus presentation —
|
|
235
|
+
`filter` renamed to `when`, `ops`/`effects` gone.
|
|
236
|
+
|
|
237
|
+
Instance tree:
|
|
238
|
+
|
|
239
|
+
- `effectsContext` renamed to `context` (entry `_type`s
|
|
240
|
+
`effectsContext.<kind>` → `context.<kind>`); it now holds ONLY the
|
|
241
|
+
start-time seed and a parent's spawn handoff. Completed effects' outputs
|
|
242
|
+
live solely on `effectHistory[].outputs` (the `$effects` read derives from
|
|
243
|
+
there).
|
|
244
|
+
- `ActivityEntry` statuses lost `pending` (entries are `active` from stage
|
|
245
|
+
entry, or `skipped`) and gained `firedActions: string[]` — the per-visit
|
|
246
|
+
ledger of cascade-fired actions.
|
|
247
|
+
- `SubworkflowEntry` gained required `action` (the spawning action's name;
|
|
248
|
+
rows key per activity + action).
|
|
249
|
+
- History: the `activityActivated` variant is gone; `actionFired` gained
|
|
250
|
+
`triggered?: true`; `opApplied` lost its `transition` origin key;
|
|
251
|
+
`EffectOrigin.kind` is only ever `'action'`.
|
|
252
|
+
|
|
253
|
+
**Pre-adoption model-1 amendment: moved `applicableWhen` to `start.filter`,
|
|
254
|
+
added `start.kind` — a deliberate freeze bypass; no external consumers
|
|
255
|
+
exist.** Same sanction as the amendment above (rule 3 untouched, the freeze
|
|
256
|
+
point moves, no model 2 minted). The definition root gained an optional
|
|
257
|
+
`start` block: `kind` (`'interactive' | 'autonomous'`, stored explicitly —
|
|
258
|
+
desugar fills the default; the block itself stays absent on definitions that
|
|
259
|
+
never declared one, and an absent block reads as interactive with no filter)
|
|
260
|
+
and `filter` — the former `applicableWhen`, re-scoped from a pure
|
|
261
|
+
candidate-doc predicate to the start-filter context (`$tag` / `$definition` /
|
|
262
|
+
`$now` / `$fields` bound, `*[...]` reading the workflow dataset; the
|
|
263
|
+
`startFilterVars` vocabulary joins the ledger). `start.filter` is a READ-SIDE
|
|
264
|
+
visibility rule — evaluated by `definitionsForDocument`/applicability and the
|
|
265
|
+
Studio start control to decide what appears in Start lists; `startInstance`
|
|
266
|
+
never reads it and never throws on it. An earlier iteration of this amendment
|
|
267
|
+
added a `startFilterOverridden` instance-history variant (paired with a
|
|
268
|
+
throwing start-time gate and an `overrideStartFilter` verb arg); that gate was
|
|
269
|
+
removed before any consumer existed, so this same in-place amendment ALSO
|
|
270
|
+
removed the `startFilterOverridden` variant — no gate, no override, nothing to
|
|
271
|
+
audit. The instance tree is back to its pre-amendment history variants.
|
|
272
|
+
Would an old reader misread (rule 5)? No reader exists outside this repo —
|
|
273
|
+
which is the sanction; post-amendment engines read pre-amendment model-1
|
|
274
|
+
definitions fine (no `start` block ⇒ the defaults above). The released-engine
|
|
275
|
+
caveat above covers this amendment identically: definitions a released engine
|
|
276
|
+
wrote with `applicableWhen` stamp model 1 and then fail the strict parse
|
|
277
|
+
ungoverned — same disposition, those datasets are this-repo test data and are
|
|
278
|
+
reset, not migrated.
|
|
279
|
+
|
|
280
|
+
**Pre-adoption model-1 amendment: split `start.filter` into visibility +
|
|
281
|
+
permission — the definition root's `start` block gained `allowed`, and
|
|
282
|
+
`filter` tightened to browse-time-pure.** Same sanction as the amendments
|
|
283
|
+
above (rule 3 untouched, the freeze point moves, no model 2 minted; the
|
|
284
|
+
stored definition parse is strict, so even an additive `start` key would
|
|
285
|
+
fence out released readers — the sanction is what makes that acceptable).
|
|
286
|
+
The two keys answer different questions with different evaluation contexts:
|
|
287
|
+
|
|
288
|
+
- `filter` — visibility: "should a start surface offer this workflow for
|
|
289
|
+
this document?" Browse-time-pure — candidate document as root,
|
|
290
|
+
`$tag`/`$definition`/`$now` bound (`startFilterVars` in the ledger shrank
|
|
291
|
+
accordingly), `*[...]` reading the workflow dataset — and any `$fields`
|
|
292
|
+
read is deploy-rejected with a pointer to `allowed`: `$fields` cannot
|
|
293
|
+
exist before inputs do, and an unbound `$fields` made permission-style
|
|
294
|
+
filters pass vacuously (GROQ null matches no row, so a
|
|
295
|
+
`count(...) == 0` dedupe rule surfaced a definition despite an open run).
|
|
296
|
+
Evaluated by `definitionsForDocument`/applicability and start controls,
|
|
297
|
+
exactly as before; `startInstance` still never reads it.
|
|
298
|
+
- `allowed` — permission: "given these concrete inputs, may a run start
|
|
299
|
+
now?" (dedupe, WIP limits, overlap rules). Same context vars plus
|
|
300
|
+
`$fields` (the caller's `initialFields` as GDR envelopes —
|
|
301
|
+
`startAllowedVars` joins the ledger vocabulary), never a candidate root
|
|
302
|
+
(deploy-rejected; the subject rides `$fields.<entry>.id`). `startInstance`
|
|
303
|
+
ENFORCES it: declaring the expression is the opt-in, false or GROQ null
|
|
304
|
+
throws `StartNotAllowedError` (kind `start-not-allowed`), no override arg.
|
|
305
|
+
The `evaluateStart` verb pre-flights the same verdict as an
|
|
306
|
+
insight-carrying read. Advisory under races like every engine-side check —
|
|
307
|
+
the Content Lake stays the only enforcement point.
|
|
308
|
+
|
|
309
|
+
Would an old reader misread (rule 5)? No reader exists outside this repo —
|
|
310
|
+
the sanction. A post-amendment engine reads pre-amendment model-1
|
|
311
|
+
definitions fine: an absent `allowed` means no permission gate, and a
|
|
312
|
+
pre-amendment `$fields`-reading `filter` still evaluates with the read as
|
|
313
|
+
GROQ null (no surface binds `$fields` there anymore) — the rule is inert,
|
|
314
|
+
and whether the filter hides the definition (`defined()`-style reads fail
|
|
315
|
+
closed) or surfaces it vacuously (the count-dedupe shape above) depends on
|
|
316
|
+
its shape. Redeploying one fails validation until the expression moves to
|
|
317
|
+
`allowed`.
|
|
318
|
+
The released-engine caveat above covers this amendment identically: those
|
|
319
|
+
datasets are this-repo test data and are reset, not migrated.
|
|
176
320
|
|
|
177
321
|
## Pending governed changes
|
|
178
322
|
|