@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/DATAMODEL.md ADDED
@@ -0,0 +1,252 @@
1
+ # The engine's persisted data model
2
+
3
+ The engine owns three standalone document types in the Content Lake:
4
+
5
+ | Doc type | Constructed by | Mutated after create |
6
+ | ---------------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------- |
7
+ | `sanity.workflow.definition` | `planDefinitionDeploy` (`src/api/deploy.ts`) | never — deploys are create-only |
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; predicate lift on retract |
10
+
11
+ These field trees are **our contract with every past and future engine
12
+ version reading the same dataset**. This file is where changes to them are
13
+ declared; the model-surface gates keep undeclared drift red:
14
+
15
+ - **The shape ledger** — `src/__tests__/model-shapes/model-<N>.json`, one
16
+ file per `DATA_MODEL_VERSION`, **append-only**: only the current version's
17
+ file is ever regenerated (`vitest -u` on the gate test), so past model
18
+ shapes stay checked in verbatim — the historical corpus future upgrade
19
+ machinery tests against. It pins the field trees of every doc
20
+ type built through the real constructors, plus the stored grammar's
21
+ vocabulary (every enum's values, every variant discriminator), so a value
22
+ rename is as red as a key rename.
23
+ - **Coverage tests** (`src/__tests__/model-surface.test.ts`) walk the stored
24
+ definition schema and fail when any declared key, discriminator, or
25
+ instantiable enum value stops appearing in the canonical document — the
26
+ grammar cannot grow past the fixture silently.
27
+ - **The lifecycle ledger** (`model-shapes/model-<N>.lifecycle.json` in
28
+ `workflow-engine-test`) drives real instances through the verbs —
29
+ including the recovery paths (expired-lease sweep, loop-back re-entry
30
+ adoption, dead-end propagation, abort) — and pins the interior trees a
31
+ fresh construction can't reach: `StageEntry` / `ActivityEntry` /
32
+ `SubworkflowEntry` rows, pending effects (claim included), effect
33
+ history, the idempotency ledger, and ALL `HistoryEntry` variants (the
34
+ accounting is compile-checked against the union, so a new variant fails
35
+ the build until the scenarios produce it).
36
+
37
+ Residual review responsibility: each ledger entry pins one shape per
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.
42
+
43
+ This is **not** definition-content versioning: `version` / `pinnedVersion` /
44
+ `contentHash` track what an author deployed; the model stamp tracks the shape
45
+ of the documents the engine itself writes.
46
+
47
+ ## The stamp pair
48
+
49
+ The two doc types whose format the engine owns outright — definition and
50
+ instance — carry two numbers (`MODEL_STAMP` in `src/core/model-version.ts`).
51
+ Provenance and compatibility are deliberately separate, because consumers
52
+ sharing a dataset upgrade months apart (Functions lag Studios lag CLIs) and
53
+ "newer" must not mean "incompatible".
54
+
55
+ **The guard doc carries NO stamp.** `temp.system.guard` is a foreign
56
+ contract: the persisted format mirrors the Content Lake's forthcoming guard
57
+ primitive, and the engine must not grow fields on a shape it doesn't own —
58
+ the lake dictates that format long-term. The guard doc's versioning event is
59
+ the `_type` cutover itself (an engine that predates `system.guard` simply
60
+ never matches the new type).
61
+
62
+ - **`modelVersion` — provenance ("conforms-to").** The value of
63
+ `DATA_MODEL_VERSION` at write time: "this document conforms to model N
64
+ now". Bumps on **every** declared shape change, additive included.
65
+ Instances are re-stamped on every full persist (and rollback restore).
66
+ Partial patches deliberately leave the pair alone — they don't normalize
67
+ the shape, so restamping there would lie.
68
+ - **`minReaderModel` — the reader floor.** The value of
69
+ `DATA_MODEL_MIN_READER` at write time: the oldest engine model that can
70
+ safely _interpret_ this document. An **additive** change leaves the floor
71
+ untouched — older engines keep reading and writing newer docs, so
72
+ mixed-version fleets interoperate. Only a **breaking** change raises it,
73
+ and raising it is a declared decision to fence out older readers (fleet
74
+ must upgrade readers before such writers deploy).
75
+ - **Missing stamp = model 0** (floor 0) — the document was last written
76
+ before the stamp existed. A `modelVersion` with no `minReaderModel` is
77
+ malformed foreign data and gates conservatively: the stamp is the floor.
78
+ - **Backward compatibility is unconditional.** An engine at model N reads
79
+ every stamp `0..N`. There is no supported-version floor for _old_ docs;
80
+ introducing one is a deliberate future mechanism, never a side effect of
81
+ a shape change.
82
+ - **Fail hard on `minReaderModel` ahead — and only that.** Every engine read
83
+ of a stamped doc type (definition, instance) passes through
84
+ `assertReadableModel` — point reads, list queries, the drained-children
85
+ projection, snapshot hydration's raw reads. A floor ahead of the engine's
86
+ `DATA_MODEL_VERSION` throws
87
+ `ModelVersionAheadError` ("upgrade `@sanity/workflow-engine`") instead of
88
+ silently misreading a shape this engine predates. Deliberately wholesale:
89
+ one such document in a read's scope fails the whole read (a listing, a
90
+ verdict computation) rather than degrading silently. Two structural
91
+ exceptions: existence-only probes (no shape is interpreted), and lake-side
92
+ GROQ filters, which evaluate persisted fields server-side before any
93
+ client gate can run — their safety rests entirely on rule 3's
94
+ additive-only constraint.
95
+ - **Old writers restamp down — the round-trip rule.** An older engine
96
+ persisting a newer-but-compatible instance stamps its own `modelVersion`
97
+ (honest conforms-to; the stamp oscillates in mixed fleets, harmlessly).
98
+ This is safe because full persists are set-only patches and mutation
99
+ copies are spreads, so fields the older engine doesn't know **survive the
100
+ round-trip**. That is a load-bearing invariant: an additive change whose
101
+ fields would NOT survive an old writer's round-trip is not additive — it
102
+ is breaking, and must raise the floor.
103
+ - **The floor is raise-only.** A rewrite writes
104
+ `max(stored minReaderModel, own DATA_MODEL_MIN_READER)` — it never lowers
105
+ or drops the stored floor, because the preserved unknown fields of a newer
106
+ model may still require it. (The failure mode this fences off is
107
+ documented across the industry: a rewriter that drops the compatibility
108
+ gate it doesn't understand silently reopens the door the floor closed.)
109
+ - **Raising the floor is an availability decision, sequenced readers-first.**
110
+ Before any writer that raises `DATA_MODEL_MIN_READER` deploys, every
111
+ reader in the fleet — customer-deployed Functions foremost — must already
112
+ run an engine at or above the new floor (the BACKWARD deployment order:
113
+ upgrade consumers before producers). And like every engine-side check the
114
+ floor is advisory — it gates our own clients; the Content Lake stays the
115
+ sole enforcement point.
116
+ - **The gate ships from the engine.** Consumer surfaces that read these docs
117
+ without routing through the engine (the CLI's direct reads, MCP query
118
+ projections, the reactive studio/sdk adapters) adopt `assertReadableModel`
119
+ with the parse-don't-cast follow-up; until then they render newer-model
120
+ docs unchecked.
121
+
122
+ ## Rules for changing the model
123
+
124
+ 1. **Any change to a persisted field tree is a model change** and must be
125
+ declared: update the model-surface snapshot, add an entry to the model
126
+ log below, and decide — in the same change — whether `DATA_MODEL_VERSION`
127
+ moves **and whether the reader floor moves with it**. Nothing lands
128
+ "incidentally".
129
+ 2. **Additive optional fields are allowed** without machinery: bump
130
+ `DATA_MODEL_VERSION`, leave `DATA_MODEL_MIN_READER` untouched, and
131
+ declare the tolerant read (what an absent value means on docs written
132
+ before the field existed) plus why the field survives an old writer's
133
+ round-trip.
134
+ 3. **Renames, removals, and semantic changes are not allowed yet.** They
135
+ require versioned upgrade machinery (per-model normalization steps applied
136
+ on read) that does not exist; until it does, the model may only grow
137
+ additively. When such a change eventually lands, it raises
138
+ `DATA_MODEL_MIN_READER` — a declared fence against older readers, shipped
139
+ only after the fleet story is written.
140
+ 4. **`DATA_MODEL_VERSION` moves when compatibility reasoning changes** — when
141
+ a reader would need to know which shape it is looking at. A purely
142
+ internal refactor that provably writes an identical tree does not bump.
143
+ 5. **Classify by misinterpretation, not parseability.** The snapshot gate
144
+ catches structural drift only. A change can be structurally additive yet
145
+ change the MEANING of existing fields — old readers parse it fine and
146
+ silently misinterpret. That judgment (would an old reader misread, not
147
+ just fail to parse?) is exactly what decides whether
148
+ `DATA_MODEL_MIN_READER` moves, and no snapshot can make it — each model
149
+ log entry states it explicitly.
150
+
151
+ ## Model log
152
+
153
+ ### Model 0 — pre-governance
154
+
155
+ Everything written before the stamp existed. Documents carry no
156
+ `modelVersion`. The shape history of model 0 was absorbed by tolerant reads
157
+ that predate this file — recorded here so they are declared compatibility
158
+ rules, not folklore:
159
+
160
+ - **Instances without `subworkflows[]`** (persisted before the subworkflow
161
+ registry existed) — read as `[]`; the next full persist materializes it.
162
+ - **Instances without `processedRequests[]`** (persisted before the
163
+ idempotency ledger existed) — read as `[]`; the next full persist
164
+ materializes it.
165
+ - **Definitions without `contentHash`** (deployed before content-addressing)
166
+ — always read as changed, so the first redeploy after upgrade mints a
167
+ fresh version.
168
+
169
+ ### Model 1 — stamping introduced; actions-only payload model (reader floor: 0)
170
+
171
+ The `modelVersion` + `minReaderModel` pair is stamped on the definition and
172
+ instance doc types; the guard doc deliberately carries none (foreign lake
173
+ contract — see "The stamp pair"). The floor is 0: model-0 documents keep
174
+ reading exactly as before, and every engine from the amendment below onward
175
+ reads model-1 documents fine (the amendment's sanction — no readers or
176
+ writers existed outside this repo — is what made the pre-amendment window
177
+ irrelevant).
178
+
179
+ **Amended in place before any consumer existed** (a sanctioned exception to
180
+ rule 3 — model 1 had no readers or writers outside this repo when the
181
+ reshape landed, so the freeze point moved rather than minting a model 2):
182
+ actions became the only payload mechanism, and the persisted trees reshaped
183
+ accordingly. One caveat was weighed and accepted when the exception was
184
+ confirmed: released engines predating the amendment stamp model 1 while
185
+ writing the pre-amendment shapes, so documents they wrote pass the stamp
186
+ gate and then fail ungoverned (a raw shape error, not
187
+ `ModelVersionAheadError`). Those documents are declared disposable — every
188
+ dataset holding them is a this-repo test dataset and must be reset before a
189
+ post-amendment engine touches it. Nothing migrates them.
190
+
191
+ Definition tree (the stored authoring model):
192
+
193
+ - Activities carry no payload: `activation`, `ops`, `effects`,
194
+ `completeWhen`, `failWhen`, `subworkflows`, and `kind` are gone from the
195
+ activity tree. `target` survives as the off-system marker.
196
+ - Actions gained `when` (a cascade-fired trigger condition), `spawn` (the
197
+ subworkflow block formerly on the activity), and `roles` (stored verbatim
198
+ only on cascade-fired actions — the execute pin; fireAction actions still
199
+ fold roles into `filter` at desugar).
200
+ - Transitions are pure edges: `{name, when, to}` plus presentation —
201
+ `filter` renamed to `when`, `ops`/`effects` gone.
202
+
203
+ Instance tree:
204
+
205
+ - `effectsContext` renamed to `context` (entry `_type`s
206
+ `effectsContext.<kind>` → `context.<kind>`); it now holds ONLY the
207
+ start-time seed and a parent's spawn handoff. Completed effects' outputs
208
+ live solely on `effectHistory[].outputs` (the `$effects` read derives from
209
+ there).
210
+ - `ActivityEntry` statuses lost `pending` (entries are `active` from stage
211
+ entry, or `skipped`) and gained `firedActions: string[]` — the per-visit
212
+ ledger of cascade-fired actions.
213
+ - `SubworkflowEntry` gained required `action` (the spawning action's name;
214
+ rows key per activity + action).
215
+ - History: the `activityActivated` variant is gone; `actionFired` gained
216
+ `triggered?: true`; `opApplied` lost its `transition` origin key;
217
+ `EffectOrigin.kind` is only ever `'action'`.
218
+
219
+ **Pre-adoption model-1 amendment: moved `applicableWhen` to `start.filter`,
220
+ added `start.kind` — a deliberate freeze bypass; no external consumers
221
+ exist.** Same sanction as the amendment above (rule 3 untouched, the freeze
222
+ point moves, no model 2 minted). The definition root gained an optional
223
+ `start` block: `kind` (`'interactive' | 'autonomous'`, stored explicitly —
224
+ desugar fills the default; the block itself stays absent on definitions that
225
+ never declared one, and an absent block reads as interactive with no filter)
226
+ and `filter` — the former `applicableWhen`, re-scoped from a pure
227
+ candidate-doc predicate to the start-filter context (`$tag` / `$definition` /
228
+ `$now` / `$fields` bound, `*[...]` reading the workflow dataset; the
229
+ `startFilterVars` vocabulary joins the ledger). `start.filter` is a READ-SIDE
230
+ visibility rule — evaluated by `definitionsForDocument`/applicability and the
231
+ Studio start control to decide what appears in Start lists; `startInstance`
232
+ never reads it and never throws on it. An earlier iteration of this amendment
233
+ added a `startFilterOverridden` instance-history variant (paired with a
234
+ throwing start-time gate and an `overrideStartFilter` verb arg); that gate was
235
+ removed before any consumer existed, so this same in-place amendment ALSO
236
+ removed the `startFilterOverridden` variant — no gate, no override, nothing to
237
+ audit. The instance tree is back to its pre-amendment history variants.
238
+ Would an old reader misread (rule 5)? No reader exists outside this repo —
239
+ which is the sanction; post-amendment engines read pre-amendment model-1
240
+ definitions fine (no `start` block ⇒ the defaults above). The released-engine
241
+ caveat above covers this amendment identically: definitions a released engine
242
+ wrote with `applicableWhen` stamp model 1 and then fail the strict parse
243
+ ungoverned — same disposition, those datasets are this-repo test data and are
244
+ reset, not migrated.
245
+
246
+ ## Pending governed changes
247
+
248
+ - **`temp.system.guard` → `system.guard`** — the guard doc type's `temp.`
249
+ prefix drops when the lake's own guard primitive ships (see
250
+ `GUARD_DOC_TYPE` in `src/types/authorization.ts`). A breaking type change
251
+ for already-persisted guard docs; it must land as a declared model change
252
+ with a migration story, through this process.
package/README.md CHANGED
@@ -38,8 +38,8 @@ different at each — read it by where it sits:
38
38
  lightweight `{type, name}` shapes for the value's structure, mirroring
39
39
  Sanity's `object.fields` / `array.of`.
40
40
  3. **Object value expressions** — a `{type: 'object', fields: {...}}` value
41
- expression in an op or `initialValue`: one expression per key of the
42
- computed object value.
41
+ expression in an op payload: one expression per key of the computed
42
+ object value.
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