@sanity/workflow-engine 0.16.0 → 0.18.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 +87 -0
- package/DATAMODEL.md +244 -42
- package/README.md +23 -0
- package/dist/_chunks-cjs/invariants.cjs +1029 -225
- package/dist/_chunks-es/invariants.js +944 -226
- package/dist/define.cjs +6 -1
- package/dist/define.d.cts +220 -48
- package/dist/define.d.ts +220 -48
- package/dist/define.js +7 -2
- package/dist/index.cjs +4371 -2591
- package/dist/index.d.cts +1456 -302
- package/dist/index.d.ts +1456 -302
- package/dist/index.js +4162 -2477
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# @sanity/workflow-engine
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3af2ca0: Derive each persisted definition and instance reader floor from the model features it actually contains. Export the append-only data-model change manifest and feature diagnostics so compatible model-2 documents remain readable by older engines while typed choice lists still fail closed.
|
|
8
|
+
- 7276702: Insight phrases resolve effect titles and drop the "settled" jargon. `$effectStatus` requirement phrases and autonomy effect waits now render the effect's declared `title` — an untitled effect keeps its registry name as-is — and the requirement verbs are plain outcomes: "must have succeeded" / "must have failed" / "must have finished". The `requirement.effect-*` and `autonomy-wait.effect` phrase params carry both `effect` (the registry name) and the resolved `title`, so consumers restyling by phrase id needn't re-derive either.
|
|
9
|
+
- 58f8211: Export `expandResourceAliases`, `refsOf`, and the `LogicalRef` type from the package root. Deploy-adjacent tooling can now run the alias expansion as an eager validation (fail-closed on unbound `@<handle>:` references before any write) and walk a definition's spawn references without re-implementing the walk.
|
|
10
|
+
- 00c089d: **BREAKING:** Require every definition deployment and workflow deployment configuration to state an `expectedMinReaderModel` literal that exactly matches the installed engine's writer capability.
|
|
11
|
+
|
|
12
|
+
Deploy and definition-diff paths now reject missing or stale acknowledgements before client access or telemetry, with a structured error and readers-first rollout guidance.
|
|
13
|
+
|
|
14
|
+
- cd03973: **BREAKING:** Reject initial workflow field values that cannot be consumed and expose structured preflight issues.
|
|
15
|
+
|
|
16
|
+
Calls that supplied unknown, wrong-kind, non-input, non-workflow-scope, or duplicate initial field rows previously succeeded while discarding those rows. They now fail before creating an instance with structured remediation details.
|
|
17
|
+
|
|
18
|
+
Deploy also rejects `spawn.with` keys that the resolved child definition cannot consume because the workflow field is missing or is not input-sourced. `SpawnContractsInvalidError` exposes every rejected parent/child row for machine-readable remediation.
|
|
19
|
+
|
|
20
|
+
- d26cc1a: Add the engine-owned Actor resolution contract with client-backed resolution for server, CLI, and MCP runtimes; add native Studio and App SDK project-user integrations; and migrate the Studio plugin member picker to the shared directory.
|
|
21
|
+
- 9126299: **BREAKING:** Delete persisted stage guard documents when their stage exits, and remove the lifted-record `GUARD_LIFTED_PREDICATE` and `isGuardLifted` exports.
|
|
22
|
+
|
|
23
|
+
Guard deletion now uses the observed document revision so stale exit reconciliation cannot delete a guard reactivated by a newer stage visit. Reactive guard streams contain only active persisted guards.
|
|
24
|
+
|
|
25
|
+
- 72018af: Add an extensible action semantics array with decision.accept and decision.decline as the initial engine-owned vocabulary.
|
|
26
|
+
- 5cd8ffd: **BREAKING:** Effect handlers now receive each host's concrete Sanity client unchanged, preserving its complete runtime and type surface. Handler traffic is no longer automatically tagged as `workflow.effect` or pinned to the engine API version; handlers own that configuration.
|
|
27
|
+
- b82bda4: **BREAKING:** `startInstance` rejects any `initialFields` row that would not land on an input-sourced declared entry — an undeclared name, a kind mismatch, or an entry that seeds itself (query/literal/fieldRead/working memory) — with `InitialFieldsInvalidError`. Previously such rows were silently ignored and the instance started without the caller's value.
|
|
28
|
+
- 55a54b3: **BREAKING:** the subject is now a first-class field kind, and the subject-entry rule keys on it.
|
|
29
|
+
|
|
30
|
+
New `subject` field kind: an elevated single `doc.ref` (same GDR value, validation, and `types` facet) that names THE document a workflow is about. Deploy enforces its placement — workflow scope only, at most one per workflow, never a nested sub-field. A subject entry dereferences in conditions, joins the reactive watch-set, routes spawn discovery, and canonicalises query/spawn refs exactly like a `doc.ref`. The persisted data model grows the value additively (`DATA_MODEL_VERSION` is now 2). The subject kind itself does not raise the reader floor — model 2's floor of 2 comes from the typed-choice-list change shipping alongside; see `DATAMODEL.md`.
|
|
31
|
+
|
|
32
|
+
The breaking half: `isSubjectEntry`, applicability (`acceptsDocumentType` / `definitionsForDocument` / document pickers), the autonomous-start deploy invariant, and the root-reading `start.filter` check now identify the subject by the `subject` kind — a required `doc.ref` / `doc.refs` input no longer counts. A deployed definition using that convention still parses and runs, but stops surfacing from document pickers (and stops satisfying the autonomous/root-read invariants on redeploy) until it declares a `subject` entry.
|
|
33
|
+
|
|
34
|
+
New exports: `isSingleDocRefKind` (single-GDR kinds — `doc.ref` and `subject`), `isSingleDocRefEntry` (its resolved-entry narrowing), and `refKindAcceptsTypes` (the kinds carrying the accepted-target `types` facet).
|
|
35
|
+
|
|
36
|
+
- b82bda4: Add the opt-in `$subjectHasInFlightInstance` start variable for composing subject-exclusive visibility and permission rules.
|
|
37
|
+
- 4490442: Add inclusive validation.min/max bounds for workflow string, text, and number declarations, with Studio input constraints and MCP authoring guidance.
|
|
38
|
+
- 1540dce: **BREAKING:** Definitions and instances written by this release require a model-2 engine reader; upgrade every reader before deploying its writers.
|
|
39
|
+
|
|
40
|
+
Workflow fields, nested field shapes, effect outputs, and action parameters can declare typed scalar choices with `options.list`. The engine validates authored lists and rejects runtime values outside them, Studio renders one shared select control and displays each selected choice by its title, and the MCP authoring guide explains the syntax.
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- 239d9f6: Fix action operation values to follow the intended lexical lookup order by reading fields on their current activity before the stage and workflow, including writes made earlier in the same transaction.
|
|
45
|
+
- 71cd58e: Explain that effect-completion field operations require an explicit workflow or stage target scope when validation rejects a missing scope, and document the requirement on `EffectHandler`.
|
|
46
|
+
|
|
47
|
+
## 0.17.0
|
|
48
|
+
|
|
49
|
+
### Minor Changes
|
|
50
|
+
|
|
51
|
+
- 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.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
- 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.
|
|
58
|
+
- 092a0d4: **BREAKING:** runtime-supplied refs written into field state are gated on the deployment's declared resource surface.
|
|
59
|
+
- 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.
|
|
60
|
+
- 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.
|
|
61
|
+
- 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.
|
|
62
|
+
- `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.
|
|
63
|
+
- The CLI wires no `resourceClients`, so refs supplied at runtime through it (`start --field`, `fire-action` ref params) admit the workflow resource only.
|
|
64
|
+
|
|
65
|
+
- 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.
|
|
66
|
+
|
|
67
|
+
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).
|
|
68
|
+
|
|
69
|
+
- 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).
|
|
70
|
+
- 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:
|
|
71
|
+
- 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.
|
|
72
|
+
- `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.
|
|
73
|
+
- 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.
|
|
74
|
+
- 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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
- 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`.
|
|
79
|
+
|
|
80
|
+
### Patch Changes
|
|
81
|
+
|
|
82
|
+
- 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.
|
|
83
|
+
- 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.
|
|
84
|
+
- 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).
|
|
85
|
+
- 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.
|
|
86
|
+
- Updated dependencies [f9389e5]
|
|
87
|
+
- Updated dependencies [df4bd80]
|
|
88
|
+
- @sanity/groq-condition-describe@0.3.0
|
|
89
|
+
|
|
3
90
|
## 0.16.0
|
|
4
91
|
|
|
5
92
|
### Minor Changes
|
package/DATAMODEL.md
CHANGED
|
@@ -6,7 +6,7 @@ The engine owns three standalone document types in the Content Lake:
|
|
|
6
6
|
| ---------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
7
7
|
| `sanity.workflow.definition` | `planDefinitionDeploy` (`src/api/deploy.ts`) | never — deploys are create-only |
|
|
8
8
|
| `sanity.workflow.instance` | `buildInstanceBase` (`src/instance.ts`) | every persist writes the canonical state via `instanceStateFields` (`src/engine/mutation.ts`) |
|
|
9
|
-
| `temp.system.guard` | `compileGuard` (`src/core/guards.ts`) | full-body refresh on stage re-entry;
|
|
9
|
+
| `temp.system.guard` | `compileGuard` (`src/core/guards.ts`) | full-body refresh on stage re-entry; revision-conditional delete on retract |
|
|
10
10
|
|
|
11
11
|
These field trees are **our contract with every past and future engine
|
|
12
12
|
version reading the same dataset**. This file is where changes to them are
|
|
@@ -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
|
|
@@ -47,7 +51,8 @@ of the documents the engine itself writes.
|
|
|
47
51
|
## The stamp pair
|
|
48
52
|
|
|
49
53
|
The two doc types whose format the engine owns outright — definition and
|
|
50
|
-
instance — carry two numbers (`
|
|
54
|
+
instance — carry two numbers (built together by `modelStampFor` in
|
|
55
|
+
`src/core/model-version.ts`).
|
|
51
56
|
Provenance and compatibility are deliberately separate, because consumers
|
|
52
57
|
sharing a dataset upgrade months apart (Functions lag Studios lag CLIs) and
|
|
53
58
|
"newer" must not mean "incompatible".
|
|
@@ -65,13 +70,12 @@ never matches the new type).
|
|
|
65
70
|
Instances are re-stamped on every full persist (and rollback restore).
|
|
66
71
|
Partial patches deliberately leave the pair alone — they don't normalize
|
|
67
72
|
the shape, so restamping there would lie.
|
|
68
|
-
- **`minReaderModel` — the reader floor.** The
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
must upgrade readers before such writers deploy).
|
|
73
|
+
- **`minReaderModel` — the document's reader floor.** The oldest engine model
|
|
74
|
+
that can safely _interpret this particular document_. Writers derive it
|
|
75
|
+
from compatibility-bearing features present in the canonical stored tree.
|
|
76
|
+
`DATA_MODEL_MIN_READER` is the maximum floor the current writer can emit,
|
|
77
|
+
not a value stamped on every document. Additive features contribute floor
|
|
78
|
+
0; only an incompatible feature fences older readers out of that document.
|
|
75
79
|
- **Missing stamp = model 0** (floor 0) — the document was last written
|
|
76
80
|
before the stamp existed. A `modelVersion` with no `minReaderModel` is
|
|
77
81
|
malformed foreign data and gates conservatively: the stamp is the floor.
|
|
@@ -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).
|
|
@@ -100,10 +133,9 @@ never matches the new type).
|
|
|
100
133
|
round-trip**. That is a load-bearing invariant: an additive change whose
|
|
101
134
|
fields would NOT survive an old writer's round-trip is not additive — it
|
|
102
135
|
is breaking, and must raise the floor.
|
|
103
|
-
- **The floor is raise-only.** A rewrite writes
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
model may still require it. (The failure mode this fences off is
|
|
136
|
+
- **The floor is raise-only.** A rewrite writes `max(stored minReaderModel, floor derived from the complete post-state)` — it never lowers or drops the
|
|
137
|
+
stored floor, because preserved unknown fields or a historical semantic
|
|
138
|
+
requirement may still require it. (The failure mode this fences off is
|
|
107
139
|
documented across the industry: a rewriter that drops the compatibility
|
|
108
140
|
gate it doesn't understand silently reopens the door the floor closed.)
|
|
109
141
|
- **Raising the floor is an availability decision, sequenced readers-first.**
|
|
@@ -113,14 +145,31 @@ never matches the new type).
|
|
|
113
145
|
upgrade consumers before producers). And like every engine-side check the
|
|
114
146
|
floor is advisory — it gates our own clients; the Content Lake stays the
|
|
115
147
|
sole enforcement point.
|
|
116
|
-
- **The gate ships from the engine.**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
148
|
+
- **The gate ships from the engine.** `assertReadableModel`, the parse
|
|
149
|
+
functions, and the error classes are exported from
|
|
150
|
+
`@sanity/workflow-engine`, so every consumer surface composes the same
|
|
151
|
+
primitives — the reactive adapters at gate + parse, the CLI/MCP/plugin
|
|
152
|
+
direct reads at gate only (see "Consumer surfaces adopt the gate" above
|
|
153
|
+
for the split and its rationale).
|
|
121
154
|
|
|
122
155
|
## Rules for changing the model
|
|
123
156
|
|
|
157
|
+
`DATA_MODEL_CHANGES` is the append-only, machine-readable counterpart of the
|
|
158
|
+
model log. Every independently reasoned persisted-model feature records a
|
|
159
|
+
stable id, introduction model, affected document types, compatibility class,
|
|
160
|
+
reader floor, and summary. The manifest drives writer-side floor derivation
|
|
161
|
+
and diagnostics; this prose log remains authoritative for the reasoning,
|
|
162
|
+
absent-value behavior, and old-writer round-trip proof.
|
|
163
|
+
|
|
164
|
+
Feature applicability is explicit. A **detectable** feature contributes its
|
|
165
|
+
floor only when its stable marker is present in the canonical stored tree. An
|
|
166
|
+
**unconditional semantic requirement** applies to every newly written document
|
|
167
|
+
of its declared type when no reliable marker exists. Readers trust the
|
|
168
|
+
persisted floor; they do not recompute features they may be too old to know.
|
|
169
|
+
Every reader-floor increase is represented as a manifest feature and classified
|
|
170
|
+
this way; raising the package maximum never by itself raises every document's
|
|
171
|
+
floor.
|
|
172
|
+
|
|
124
173
|
1. **Any change to a persisted field tree is a model change** and must be
|
|
125
174
|
declared: update the model-surface snapshot, add an entry to the model
|
|
126
175
|
log below, and decide — in the same change — whether `DATA_MODEL_VERSION`
|
|
@@ -147,6 +196,18 @@ never matches the new type).
|
|
|
147
196
|
just fail to parse?) is exactly what decides whether
|
|
148
197
|
`DATA_MODEL_MIN_READER` moves, and no snapshot can make it — each model
|
|
149
198
|
log entry states it explicitly.
|
|
199
|
+
6. **Manifest and prose move together.** Every structural model change adds a
|
|
200
|
+
manifest entry and matching model-log declaration. The ledger snapshots
|
|
201
|
+
the stored grammar and manifest. A semantic-only implementation change may
|
|
202
|
+
have no observable schema delta; review must still classify and log any
|
|
203
|
+
change to how persisted values are interpreted.
|
|
204
|
+
7. **The PR compatibility callout is mandatory and explicit.** Near the top of
|
|
205
|
+
the PR body, state the writer's `modelVersion`, name every feature introduced
|
|
206
|
+
or changed by the PR that contributes a `minReaderModel`, and state the floor
|
|
207
|
+
for documents where those features are absent. Say plainly whether model-0
|
|
208
|
+
readers still work for those documents. Give the rollout order and scope:
|
|
209
|
+
readers-first before any writer can persist a floor-bearing feature, not a
|
|
210
|
+
misleading fleet-wide minimum when floor derivation is document-specific.
|
|
150
211
|
|
|
151
212
|
## Model log
|
|
152
213
|
|
|
@@ -168,6 +229,9 @@ rules, not folklore:
|
|
|
168
229
|
|
|
169
230
|
### Model 1 — stamping introduced; actions-only payload model (reader floor: 0)
|
|
170
231
|
|
|
232
|
+
Manifest feature: `governed-model-stamps` (definition + instance, additive,
|
|
233
|
+
floor 0).
|
|
234
|
+
|
|
171
235
|
The `modelVersion` + `minReaderModel` pair is stamped on the definition and
|
|
172
236
|
instance doc types; the guard doc deliberately carries none (foreign lake
|
|
173
237
|
contract — see "The stamp pair"). The floor is 0: model-0 documents keep
|
|
@@ -243,6 +307,144 @@ wrote with `applicableWhen` stamp model 1 and then fail the strict parse
|
|
|
243
307
|
ungoverned — same disposition, those datasets are this-repo test data and are
|
|
244
308
|
reset, not migrated.
|
|
245
309
|
|
|
310
|
+
**Pre-adoption model-1 amendment: split `start.filter` into visibility +
|
|
311
|
+
permission — the definition root's `start` block gained `allowed`, and
|
|
312
|
+
`filter` tightened to browse-time-pure.** Same sanction as the amendments
|
|
313
|
+
above (rule 3 untouched, the freeze point moves, no model 2 minted; the
|
|
314
|
+
stored definition parse is strict, so even an additive `start` key would
|
|
315
|
+
fence out released readers — the sanction is what makes that acceptable).
|
|
316
|
+
The two keys answer different questions with different evaluation contexts:
|
|
317
|
+
|
|
318
|
+
- `filter` — visibility: "should a start surface offer this workflow for
|
|
319
|
+
this document?" Browse-time-pure — candidate document as root,
|
|
320
|
+
`$tag`/`$definition`/`$now` bound (`startFilterVars` in the ledger shrank
|
|
321
|
+
accordingly), `*[...]` reading the workflow dataset — and any `$fields`
|
|
322
|
+
read is deploy-rejected with a pointer to `allowed`: `$fields` cannot
|
|
323
|
+
exist before inputs do, and an unbound `$fields` made permission-style
|
|
324
|
+
filters pass vacuously (GROQ null matches no row, so a
|
|
325
|
+
`count(...) == 0` dedupe rule surfaced a definition despite an open run).
|
|
326
|
+
Evaluated by `definitionsForDocument`/applicability and start controls,
|
|
327
|
+
exactly as before; `startInstance` still never reads it.
|
|
328
|
+
- `allowed` — permission: "given these concrete inputs, may a run start
|
|
329
|
+
now?" (dedupe, WIP limits, overlap rules). Same context vars plus
|
|
330
|
+
`$fields` (the caller's `initialFields` as GDR envelopes —
|
|
331
|
+
`startAllowedVars` joins the ledger vocabulary), never a candidate root
|
|
332
|
+
(deploy-rejected; the subject rides `$fields.<entry>.id`). `startInstance`
|
|
333
|
+
ENFORCES it: declaring the expression is the opt-in, false or GROQ null
|
|
334
|
+
throws `StartNotAllowedError` (kind `start-not-allowed`), no override arg.
|
|
335
|
+
The `evaluateStart` verb pre-flights the same verdict as an
|
|
336
|
+
insight-carrying read. Advisory under races like every engine-side check —
|
|
337
|
+
the Content Lake stays the only enforcement point.
|
|
338
|
+
|
|
339
|
+
Would an old reader misread (rule 5)? No reader exists outside this repo —
|
|
340
|
+
the sanction. A post-amendment engine reads pre-amendment model-1
|
|
341
|
+
definitions fine: an absent `allowed` means no permission gate, and a
|
|
342
|
+
pre-amendment `$fields`-reading `filter` still evaluates with the read as
|
|
343
|
+
GROQ null (no surface binds `$fields` there anymore) — the rule is inert,
|
|
344
|
+
and whether the filter hides the definition (`defined()`-style reads fail
|
|
345
|
+
closed) or surfaces it vacuously (the count-dedupe shape above) depends on
|
|
346
|
+
its shape. Redeploying one fails validation until the expression moves to
|
|
347
|
+
`allowed`.
|
|
348
|
+
The released-engine caveat above covers this amendment identically: those
|
|
349
|
+
datasets are this-repo test data and are reset, not migrated.
|
|
350
|
+
|
|
351
|
+
### Model 2 — subject fields, scalar constraints, and action semantics (reader floor: 2)
|
|
352
|
+
|
|
353
|
+
Four additive growths — a new field kind and three optional facets — ship
|
|
354
|
+
together as model 2.
|
|
355
|
+
|
|
356
|
+
**The `subject` field kind.** An elevated single `doc.ref` (same GDR value
|
|
357
|
+
shape and validation, the `types` facet included) that names THE document a
|
|
358
|
+
workflow is about — workflow scope only, at most one, both deploy-enforced.
|
|
359
|
+
Both stamped trees grow the value:
|
|
360
|
+
|
|
361
|
+
- **Definition tree** — `fields[].type` admits `'subject'`, and a `subject`
|
|
362
|
+
entry may carry `types` like `doc.ref` / `doc.refs`.
|
|
363
|
+
- **Instance tree** — resolved `fields[]` entries admit `_type: 'subject'`
|
|
364
|
+
(the same arm shape as `doc.ref`, optional `types` included).
|
|
365
|
+
|
|
366
|
+
Manifest feature: `subject-field-kind` (definition + instance, additive,
|
|
367
|
+
detectable, floor 0).
|
|
368
|
+
|
|
369
|
+
Would an old reader misread the kind (rule 5)? No — it refuses loudly, never
|
|
370
|
+
silently: the definition document has no read-side content parse, and a
|
|
371
|
+
model-1 engine that loaded a subject-carrying **instance** would fail the
|
|
372
|
+
shape gate (the strict `fields[]` variant rejects the unknown discriminator
|
|
373
|
+
with `PersistedDocShapeError`) before interpreting anything — under the
|
|
374
|
+
document-specific floor derived below only fences it when another floor-bearing
|
|
375
|
+
feature (a choice list or scalar bounds) is also present. The subject kind
|
|
376
|
+
itself leaves the floor at 0.
|
|
377
|
+
|
|
378
|
+
One evaluation-semantics reconciliation rides the same change, declared
|
|
379
|
+
here under rule 5's judgment: the subject-entry rule (applicability's type
|
|
380
|
+
matching, the autonomous-start invariant, the root-read deploy check) now
|
|
381
|
+
keys on the `subject` kind, where it previously classified any required
|
|
382
|
+
`doc.ref` / `doc.refs` input as the subject. A stored pre-subject
|
|
383
|
+
definition still parses and runs everywhere, but no longer surfaces in
|
|
384
|
+
document pickers until redeployed with a `subject` entry — a deliberate
|
|
385
|
+
refuse-to-guess, not a misread.
|
|
386
|
+
|
|
387
|
+
**Typed scalar choice lists.** Definition value declarations and resolved
|
|
388
|
+
instance field entries gained an optional `options.list[]` tree. Each item
|
|
389
|
+
carries a display `title` and a typed scalar `value`; an absent `options`
|
|
390
|
+
retains the unconstrained behavior of every model-0/model-1 declaration and
|
|
391
|
+
instance entry.
|
|
392
|
+
|
|
393
|
+
Manifest feature: `typed-scalar-choice-lists` (definition + instance,
|
|
394
|
+
reader-floor, detectable, floor 2).
|
|
395
|
+
|
|
396
|
+
The field tree grows additively, and an absent `options` keeps every older
|
|
397
|
+
document's meaning unchanged. A document carrying `options.list` nevertheless
|
|
398
|
+
requires reader floor 2: an older engine would interpret the declaration as
|
|
399
|
+
unconstrained and could commit a value outside the list. That is semantic
|
|
400
|
+
misinterpretation, not a presentation downgrade. Writers of model 2 must
|
|
401
|
+
therefore deploy only after every engine reader has upgraded to model 2; the
|
|
402
|
+
floor makes an older reader fail explicitly instead of silently bypassing
|
|
403
|
+
the declaration. A model-2 definition or instance without a choice list retains
|
|
404
|
+
the floor derived from its other features (often 0). Resolved entries carry the
|
|
405
|
+
same optional tree so field writes remain self-validating. Full persists derive
|
|
406
|
+
from the complete post-state and preserve any higher stored floor.
|
|
407
|
+
|
|
408
|
+
**Action semantics.** Ordinary stored definition actions gained an optional
|
|
409
|
+
`semantics[]` bag whose initial closed vocabulary is `decision.accept` and
|
|
410
|
+
`decision.decline`. The values express advisory workflow meaning shared by
|
|
411
|
+
consumers; they change no execution, gate, transition, status, history, or
|
|
412
|
+
instance state. An absent `semantics` retains the generic behavior of every
|
|
413
|
+
older definition.
|
|
414
|
+
|
|
415
|
+
Manifest feature: `action-semantics` (definition, additive, detectable, floor
|
|
416
|
+
0).
|
|
417
|
+
|
|
418
|
+
Would an old reader misread (rule 5)? No. Definition content has no strict
|
|
419
|
+
read-side parse, and an engine predating the facet ignores the unknown optional
|
|
420
|
+
key, treating the action as generic. That loses advisory information but does
|
|
421
|
+
not change workflow execution. Definition documents are immutable after create,
|
|
422
|
+
so an old writer never rewrites and drops the array. The feature therefore
|
|
423
|
+
leaves the reader floor unchanged.
|
|
424
|
+
|
|
425
|
+
**Inclusive scalar bounds.** Workflow value declarations gained an optional
|
|
426
|
+
`validation` tree with finite `min` / `max` bounds. On `string` and `text`
|
|
427
|
+
declarations the bounds constrain character length and must be non-negative
|
|
428
|
+
integers; on `number` declarations they constrain the numeric value.
|
|
429
|
+
|
|
430
|
+
The same tree is available on action params, top-level fields, and nested field
|
|
431
|
+
shapes. Resolved instance field entries carry it so every later field write
|
|
432
|
+
validates against the declaration that created the instance. Definitions and
|
|
433
|
+
instances that omit `validation` retain their unconstrained behavior.
|
|
434
|
+
|
|
435
|
+
Manifest feature: `inclusive-scalar-bounds` (definition + instance,
|
|
436
|
+
reader-floor, detectable, floor 2).
|
|
437
|
+
|
|
438
|
+
The shape growth is additive, but a document carrying `validation` requires
|
|
439
|
+
reader floor 2. An older engine would preserve the unknown tree while
|
|
440
|
+
interpreting the value as unconstrained, and could therefore commit an
|
|
441
|
+
out-of-range value. That is semantic misinterpretation under rule 5, so older
|
|
442
|
+
readers must fail at the model gate. A model-2 definition or instance without
|
|
443
|
+
scalar bounds retains the floor derived from its other features (often 0).
|
|
444
|
+
Spread-based mutation copies preserve `validation` across every full persist;
|
|
445
|
+
the derived floor prevents older writers from reaching constrained model-2
|
|
446
|
+
instances.
|
|
447
|
+
|
|
246
448
|
## Pending governed changes
|
|
247
449
|
|
|
248
450
|
- **`temp.system.guard` → `system.guard`** — the guard doc type's `temp.`
|
package/README.md
CHANGED
|
@@ -43,6 +43,29 @@ different at each — read it by where it sits:
|
|
|
43
43
|
4. **Resolved runtime values** — `fields` on a workflow instance document: the
|
|
44
44
|
declared entries with their current values, written by ops and edits.
|
|
45
45
|
|
|
46
|
+
## Resolving actors to current project users
|
|
47
|
+
|
|
48
|
+
An `Actor` stores durable provenance, not mutable profile data. CLI, MCP, and
|
|
49
|
+
server consumers resolve through the client already bound to their engine:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const result = await engine.resolveActor({actor: effect.actor, projectId})
|
|
53
|
+
|
|
54
|
+
if (result.status === 'resolved') {
|
|
55
|
+
await notify(result.user)
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The package also exports `resolveClientActor(client, {actor, projectId})` for
|
|
60
|
+
code that has a plain `WorkflowClient` but no constructed engine. UI adapters
|
|
61
|
+
implement `ProjectUserDirectory<User>` with their native user type and pass it
|
|
62
|
+
to `resolveActor(directory, actor)`.
|
|
63
|
+
|
|
64
|
+
The result distinguishes `resolved`, `missing`, `inaccessible`, and
|
|
65
|
+
`not-person`. Agent and system actor IDs are never sent to a project-user API.
|
|
66
|
+
An integration may also provide centralized `findByEmail`; consumers should
|
|
67
|
+
not page and scan a project directory independently.
|
|
68
|
+
|
|
46
69
|
## License
|
|
47
70
|
|
|
48
71
|
[MIT](./LICENSE)
|