@sanity/workflow-engine 0.28.0 → 0.29.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.
@@ -525,6 +525,7 @@ function desugarWorkflow(authoring) {
525
525
  return {
526
526
  ...stripUndefined({
527
527
  name: stage.name,
528
+ semantics: stage.semantics,
528
529
  title: stage.title,
529
530
  description: stage.description,
530
531
  groups: stage.groups,
@@ -548,6 +549,7 @@ function desugarWorkflow(authoring) {
548
549
  definition: {
549
550
  ...stripUndefined({
550
551
  name: authoring.name,
552
+ semantics: authoring.semantics,
551
553
  title: authoring.title,
552
554
  description: authoring.description,
553
555
  groups: authoring.groups,
@@ -774,6 +776,7 @@ function desugarActivity({activity: activity, path: path, stageEnv: stageEnv, ct
774
776
  return {
775
777
  ...stripUndefined({
776
778
  name: activity.name,
779
+ semantics: activity.semantics,
777
780
  title: activity.title,
778
781
  description: activity.description,
779
782
  groups: activity.groups,
@@ -1245,7 +1248,58 @@ function isTerminalActivityStatus(status) {
1245
1248
  return TERMINAL_ACTIVITY_STATUSES.includes(status);
1246
1249
  }
1247
1250
 
1248
- const ACTION_SEMANTICS = [ "decision.accept", "decision.decline" ], FIELD_SCOPES = [ "workflow", "stage", "activity" ], DOCUMENT_VALUE_PERMISSIONS = [ "create", "read", "update" ], MUTATION_GUARD_ACTIONS = [ "create", "update", "delete", "publish", "unpublish" ], ACTIVITY_KINDS = [ "user", "service", "script", "manual", "receive" ], EXECUTOR_CLASSIFICATIONS = [ "autonomous", "interactive", "off-system", "hybrid" ], GROUP_KINDS = [ "core", "details" ], DRIVER_KINDS = [ "person", "agent", "service", "engine" ], CONDITION_VARS = [ {
1251
+ const SIGNAL_SEMANTICS = [ "signal.positive", "signal.caution", "signal.critical" ], DECISION_SEMANTICS = [ "decision.accept", "decision.decline" ], ACTION_SEMANTICS = [ ...DECISION_SEMANTICS, ...SIGNAL_SEMANTICS ], FIELD_SCOPES = [ "workflow", "stage", "activity" ], DOCUMENT_VALUE_PERMISSIONS = [ "create", "read", "update" ], MUTATION_GUARD_ACTIONS = [ "create", "update", "delete", "publish", "unpublish" ], ACTIVITY_KINDS = [ "user", "service", "script", "manual", "receive" ], EXECUTOR_CLASSIFICATIONS = [ "autonomous", "interactive", "off-system", "hybrid" ], GROUP_KINDS = [ "core", "details" ], DRIVER_KINDS = [ "person", "agent", "service", "engine" ];
1252
+
1253
+ function releaseDocId(releaseName) {
1254
+ return `_.releases.${releaseName}`;
1255
+ }
1256
+
1257
+ function releaseRef({res: res, releaseName: releaseName}) {
1258
+ if (releaseName.length === 0) throw new ContractViolationError("releaseRef: releaseName must be a non-empty release name");
1259
+ return {
1260
+ id: gdrFromResource(res, releaseDocId(releaseName)),
1261
+ type: "system.release",
1262
+ releaseName: releaseName
1263
+ };
1264
+ }
1265
+
1266
+ function isAlwaysArrayFieldKind(kind) {
1267
+ return kind === "doc.refs" || kind === "assignees" || kind === "array";
1268
+ }
1269
+
1270
+ function isSingleDocRefKind(kind) {
1271
+ return kind === "doc.ref" || kind === "subject";
1272
+ }
1273
+
1274
+ function refKindAcceptsTypes(kind) {
1275
+ return isSingleDocRefKind(kind) || kind === "doc.refs";
1276
+ }
1277
+
1278
+ function isSingleDocRefEntry(entry) {
1279
+ return isSingleDocRefKind(entry._type);
1280
+ }
1281
+
1282
+ function isTodoListItem(row) {
1283
+ if (typeof row != "object" || row === null) return !1;
1284
+ const candidate = row, status = candidate.status;
1285
+ return typeof candidate._key == "string" && typeof candidate.label == "string" && (status == null || typeof status == "string");
1286
+ }
1287
+
1288
+ function declaredRowColumns(entry) {
1289
+ if (("_type" in entry ? entry._type : entry.type) === "array") return new Set((("of" in entry ? entry.of : void 0) ?? []).map(shape => shape.name));
1290
+ }
1291
+
1292
+ function isTodoListEntry(entry) {
1293
+ const columns = declaredRowColumns(entry);
1294
+ return columns !== void 0 && columns.has("label") && columns.has("status");
1295
+ }
1296
+
1297
+ function isNotesEntry(entry) {
1298
+ const columns = declaredRowColumns(entry);
1299
+ return columns !== void 0 && columns.has("body") && columns.has("actor") && columns.has("at");
1300
+ }
1301
+
1302
+ const CONDITION_VARS = [ {
1249
1303
  name: "self",
1250
1304
  binding: "always",
1251
1305
  label: "this workflow instance",
@@ -1320,6 +1374,11 @@ const ACTION_SEMANTICS = [ "decision.accept", "decision.decline" ], FIELD_SCOPES
1320
1374
  binding: "caller",
1321
1375
  label: "your permissions",
1322
1376
  description: "Advisory per-permission booleans computed from the caller's grants; `undefined` without grants. Bound wherever grants ride the evaluation: the projection's rendered scope (fireAction-action filters, activity requirements, editability predicates) and the fireAction/editField commit gates. Deploy rejects it at every site that evaluates without grants: transition `when`s, activity filters, cascade-fired actions' `when`/`filter`, effect bindings, where-op `where`s, and the spawn `forEach`/`with`/`context` sites."
1377
+ }, {
1378
+ name: "attributes",
1379
+ binding: "caller",
1380
+ label: "your attributes",
1381
+ description: "Advisory org-level User Attributes for the caller (Enterprise — same values as lake `user::attributes()`), keyed by attribute name with each active scalar or array value. `undefined` on expected HTTP absence; unexpected fetch failures throw; empty page binds `{}`. Bound wherever grants ride the evaluation (same sites as `$can`). Soft-gate paths fetch at most 100 attributes (no further pages) and warn when the envelope reports `hasMore: true` (partial bag still binds). Not a security boundary — the Content Lake remains the only enforcement point."
1323
1382
  }, {
1324
1383
  name: "row",
1325
1384
  binding: "spawn",
@@ -1485,54 +1544,7 @@ function documentIdOf(doc) {
1485
1544
  return "(unknown id)";
1486
1545
  }
1487
1546
 
1488
- const ACTOR_KINDS = [ "person", "agent", "system" ];
1489
-
1490
- function releaseDocId(releaseName) {
1491
- return `_.releases.${releaseName}`;
1492
- }
1493
-
1494
- function releaseRef({res: res, releaseName: releaseName}) {
1495
- if (releaseName.length === 0) throw new ContractViolationError("releaseRef: releaseName must be a non-empty release name");
1496
- return {
1497
- id: gdrFromResource(res, releaseDocId(releaseName)),
1498
- type: "system.release",
1499
- releaseName: releaseName
1500
- };
1501
- }
1502
-
1503
- function isSingleDocRefKind(kind) {
1504
- return kind === "doc.ref" || kind === "subject";
1505
- }
1506
-
1507
- function refKindAcceptsTypes(kind) {
1508
- return isSingleDocRefKind(kind) || kind === "doc.refs";
1509
- }
1510
-
1511
- function isSingleDocRefEntry(entry) {
1512
- return isSingleDocRefKind(entry._type);
1513
- }
1514
-
1515
- function isTodoListItem(row) {
1516
- if (typeof row != "object" || row === null) return !1;
1517
- const candidate = row, status = candidate.status;
1518
- return typeof candidate._key == "string" && typeof candidate.label == "string" && (status == null || typeof status == "string");
1519
- }
1520
-
1521
- function declaredRowColumns(entry) {
1522
- if (("_type" in entry ? entry._type : entry.type) === "array") return new Set((("of" in entry ? entry.of : void 0) ?? []).map(shape => shape.name));
1523
- }
1524
-
1525
- function isTodoListEntry(entry) {
1526
- const columns = declaredRowColumns(entry);
1527
- return columns !== void 0 && columns.has("label") && columns.has("status");
1528
- }
1529
-
1530
- function isNotesEntry(entry) {
1531
- const columns = declaredRowColumns(entry);
1532
- return columns !== void 0 && columns.has("body") && columns.has("actor") && columns.has("at");
1533
- }
1534
-
1535
- const ANONYMOUS_IDENTITY = "<anonymous>", SYSTEM_IDENTITY = "<system>", E_PREFIXED_PROJECT_ID = /^e-(.+)$/;
1547
+ const ACTOR_KINDS = [ "person", "agent", "system" ], ANONYMOUS_IDENTITY = "<anonymous>", SYSTEM_IDENTITY = "<system>", E_PREFIXED_PROJECT_ID = /^e-(.+)$/;
1536
1548
 
1537
1549
  function classifyPrincipalId(id) {
1538
1550
  if (id === ANONYMOUS_IDENTITY || id === SYSTEM_IDENTITY) return {
@@ -1961,6 +1973,10 @@ function opSchemas(targetSchema) {
1961
1973
  type: v.literal("field.set"),
1962
1974
  target: targetSchema,
1963
1975
  value: ValueExprSchema
1976
+ }), v.strictObject({
1977
+ type: v.literal("field.setIfMissing"),
1978
+ target: targetSchema,
1979
+ value: ValueExprSchema
1964
1980
  }), v.strictObject({
1965
1981
  type: v.literal("field.unset"),
1966
1982
  target: targetSchema
@@ -1968,6 +1984,14 @@ function opSchemas(targetSchema) {
1968
1984
  type: v.literal("field.append"),
1969
1985
  target: targetSchema,
1970
1986
  value: ValueExprSchema
1987
+ }), v.strictObject({
1988
+ type: v.literal("field.inc"),
1989
+ target: targetSchema,
1990
+ value: v.optional(ValueExprSchema)
1991
+ }), v.strictObject({
1992
+ type: v.literal("field.dec"),
1993
+ target: targetSchema,
1994
+ value: v.optional(ValueExprSchema)
1971
1995
  }), v.strictObject({
1972
1996
  type: v.literal("field.updateWhere"),
1973
1997
  target: targetSchema,
@@ -2184,17 +2208,27 @@ const TodoListFieldSchema = pinned()(v.strictObject(listSugarFields("todoList"))
2184
2208
  required: v.optional(v.boolean()),
2185
2209
  options: v.optional(ChoiceOptionsSchema),
2186
2210
  validation: v.optional(ScalarValidationSchema)
2187
- }), choiceOptionsCheck(), scalarValidationCheck());
2211
+ }), choiceOptionsCheck(), scalarValidationCheck()), CUSTOM_SEMANTIC_HINT = "`custom.<camelCaseMeaning>`", CustomSemanticSchema = v.custom(input => typeof input == "string" && /^custom\.[a-z][a-zA-Z0-9]*$/.test(input)), SemanticSchema = v.union([ picklist(SIGNAL_SEMANTICS), CustomSemanticSchema ], `expected ${SIGNAL_SEMANTICS.join(", ")}, or ${CUSTOM_SEMANTIC_HINT}`), ActionSemanticSchema = v.union([ picklist(ACTION_SEMANTICS), CustomSemanticSchema ], `expected ${ACTION_SEMANTICS.join(", ")}, or ${CUSTOM_SEMANTIC_HINT}`);
2212
+
2213
+ function semanticNamespace(semantic) {
2214
+ return semantic.startsWith("custom.") ? semantic : semantic.split(".", 1)[0] ?? semantic;
2215
+ }
2188
2216
 
2189
2217
  function hasUniqueSemanticNamespaces(semantics) {
2190
- const namespaces = semantics.map(semantic => semantic.split(".", 1)[0]);
2218
+ const namespaces = semantics.map(semanticNamespace);
2191
2219
  return new Set(namespaces).size === namespaces.length;
2192
2220
  }
2193
2221
 
2222
+ function semanticsFieldSchema(semantic) {
2223
+ return v.optional(v.pipe(v.array(semantic), v.minLength(1, "declare at least one semantic, or omit `semantics`"), v.check(hasUniqueSemanticNamespaces, "declare at most one semantic from each namespace")));
2224
+ }
2225
+
2226
+ const SemanticsFieldSchema = semanticsFieldSchema(SemanticSchema), ActionSemanticsFieldSchema = semanticsFieldSchema(ActionSemanticSchema);
2227
+
2194
2228
  function actionFields(op, group) {
2195
2229
  return {
2196
2230
  name: NonEmpty,
2197
- semantics: v.optional(v.pipe(v.array(picklist(ACTION_SEMANTICS)), v.minLength(1, "declare at least one semantic, or omit `semantics`"), v.check(hasUniqueSemanticNamespaces, "declare at most one semantic from each namespace"))),
2231
+ semantics: ActionSemanticsFieldSchema,
2198
2232
  title: v.optional(v.string()),
2199
2233
  description: v.optional(v.string()),
2200
2234
  group: v.optional(group),
@@ -2241,6 +2275,7 @@ const StoredActionSchema = pinned()(v.strictObject({
2241
2275
  function activityFields({field: field, action: action, target: target, group: group}) {
2242
2276
  return {
2243
2277
  name: NonEmpty,
2278
+ semantics: SemanticsFieldSchema,
2244
2279
  title: v.optional(v.string()),
2245
2280
  description: v.optional(v.string()),
2246
2281
  groups: v.optional(v.array(GroupSchema)),
@@ -2314,6 +2349,7 @@ const GuardSchema = v.strictObject(guardFields(NonEmpty)), AuthoringGuardSchema
2314
2349
  function stageFields({field: field, activity: activity, transition: transition, guard: guard, editable: editable}) {
2315
2350
  return {
2316
2351
  name: NonEmpty,
2352
+ semantics: SemanticsFieldSchema,
2317
2353
  title: v.optional(v.string()),
2318
2354
  description: v.optional(v.string()),
2319
2355
  groups: v.optional(v.array(GroupSchema)),
@@ -2352,6 +2388,7 @@ const StoredStartSchema = pinned()(v.strictObject(startFields(picklist(START_KIN
2352
2388
  function workflowFields({field: field, stage: stage, start: start}) {
2353
2389
  return {
2354
2390
  name: NonEmpty,
2391
+ semantics: SemanticsFieldSchema,
2355
2392
  title: NonEmpty,
2356
2393
  description: v.optional(v.string()),
2357
2394
  groups: v.optional(v.array(GroupSchema)),
@@ -2548,11 +2585,20 @@ function checkStageReachability({def: def, stageNames: stageNames, issues: issue
2548
2585
  });
2549
2586
  }
2550
2587
 
2588
+ function actionSites(def) {
2589
+ return def.stages.flatMap((stage, i) => (stage.activities ?? []).flatMap((activity, j) => (activity.actions ?? []).map((action, a) => ({
2590
+ action: action,
2591
+ activity: activity,
2592
+ stage: stage,
2593
+ path: [ "stages", i, "activities", j, "actions", a ]
2594
+ }))));
2595
+ }
2596
+
2551
2597
  function effectNameSites(def) {
2552
2598
  const sites = [];
2553
- for (const [i, stage] of def.stages.entries()) for (const [j, activity] of (stage.activities ?? []).entries()) for (const [a, action] of (activity.actions ?? []).entries()) collectEffects({
2599
+ for (const {action: action, path: path} of actionSites(def)) collectEffects({
2554
2600
  effects: action.effects,
2555
- path: [ "stages", i, "activities", j, "actions", a, "effects" ],
2601
+ path: [ ...path, "effects" ],
2556
2602
  sites: sites
2557
2603
  });
2558
2604
  return sites;
@@ -2683,13 +2729,22 @@ function checkUnboundConditionVars(def, issues) {
2683
2729
  }
2684
2730
  }
2685
2731
 
2732
+ const SOFT_GATE_VARS = {
2733
+ can: {
2734
+ noun: "the caller's grants"
2735
+ },
2736
+ attributes: {
2737
+ noun: "the caller's org-level user attributes"
2738
+ }
2739
+ }, SOFT_GATE_VAR_NAMES = Object.keys(SOFT_GATE_VARS);
2740
+
2686
2741
  function unboundVarsAt(site) {
2687
2742
  const callerVars = unboundCallerVars(site.policy);
2688
2743
  return site.bindsRow === !0 ? callerVars : [ ...callerVars, "row" ];
2689
2744
  }
2690
2745
 
2691
2746
  function unboundCallerVars(policy) {
2692
- return policy === "cascade" ? CALLER_BOUND_VARS : policy === "caller-bound" ? [ "params" ] : policy === "triggered-payload" ? [ "can", "params" ] : [ "can" ];
2747
+ return policy === "cascade" ? CALLER_BOUND_VARS : policy === "caller-bound" ? [ "params" ] : policy === "triggered-payload" ? [ ...SOFT_GATE_VAR_NAMES, "params" ] : SOFT_GATE_VAR_NAMES;
2693
2748
  }
2694
2749
 
2695
2750
  const ROW_BINDING_CLAUSE = "$row (the discovered row in a spawn projection; the stored row under test in a where-op) is bound only while a spawn `with` projection or a where-op `where` evaluates";
@@ -2699,7 +2754,12 @@ function rowVarMessage(site) {
2699
2754
  }
2700
2755
 
2701
2756
  function callerVarMessage(site, name) {
2702
- return site.policy === "cascade" ? `${site.label} reads $${name} — cascade gates (transition \`when\`s, activity \`filter\`s, a cascade-fired action's \`when\`/\`filter\`) must resolve identically no matter whose token drives the cascade ($assigned is constant false, the other caller vars hold no value); gate on instance state (e.g. a field an action wrote), or pin executing identities with \`roles\`` : site.policy === "caller-bound" ? `${site.label} reads $${name} — $params (the firing action's args) is bound only while the action's effect bindings and where-op \`where\`s evaluate; this site never binds it, so the condition could never pass. Bind $params in an effect binding or a where-op instead, or gate on a field an action wrote` : site.policy === "triggered-payload" && name === "params" ? `${site.label} reads $params — a cascade-fired action has no caller to supply args, so $params never holds a value in its payload; read fields or effect outputs instead` : `${site.label} reads $can — $can (the caller's grants) is bound only in the caller-bound projection (action filters, requirements, editable predicates) and never holds a value in cascade conditions; move the check to one of those sites or drop $can`;
2757
+ if (site.policy === "cascade") return `${site.label} reads $${name} — cascade gates (transition \`when\`s, activity \`filter\`s, a cascade-fired action's \`when\`/\`filter\`) must resolve identically no matter whose token drives the cascade ($assigned is constant false, the other caller vars hold no value); gate on instance state (e.g. a field an action wrote), or pin executing identities with \`roles\``;
2758
+ if (site.policy === "caller-bound") return `${site.label} reads $${name} — $params (the firing action's args) is bound only while the action's effect bindings and where-op \`where\`s evaluate; this site never binds it, so the condition could never pass. Bind $params in an effect binding or a where-op instead, or gate on a field an action wrote`;
2759
+ if (site.policy === "triggered-payload" && name === "params") return `${site.label} reads $params — a cascade-fired action has no caller to supply args, so $params never holds a value in its payload; read fields or effect outputs instead`;
2760
+ const softGate = SOFT_GATE_VARS[name];
2761
+ if (softGate !== void 0) return `${site.label} reads $${name} — $${name} (${softGate.noun}) is bound only in the caller-bound projection (action filters, requirements, editable predicates); this site never binds it. Move the check to one of those sites or drop $${name}`;
2762
+ throw new Error(`callerVarMessage: unreachable for $${name} at ${site.label} (policy ${site.policy})`);
2703
2763
  }
2704
2764
 
2705
2765
  function conditionSites(def) {
@@ -2954,9 +3014,9 @@ function belowScopeNestedSites(args) {
2954
3014
  }
2955
3015
 
2956
3016
  function checkLevelKindEffectOutputs(def, issues) {
2957
- for (const [i, stage] of def.stages.entries()) for (const [j, activity] of (stage.activities ?? []).entries()) for (const [a, action] of (activity.actions ?? []).entries()) pushOutputIssues({
3017
+ for (const {action: action, path: path} of actionSites(def)) pushOutputIssues({
2958
3018
  action: action,
2959
- path: [ "stages", i, "activities", j, "actions", a ],
3019
+ path: path,
2960
3020
  issues: issues
2961
3021
  });
2962
3022
  }
@@ -3038,18 +3098,18 @@ function storedRolesIssue(action) {
3038
3098
  }
3039
3099
 
3040
3100
  function checkStoredRolesPlacement(def, issues) {
3041
- for (const [i, stage] of def.stages.entries()) for (const [j, activity] of (stage.activities ?? []).entries()) for (const [a, action] of (activity.actions ?? []).entries()) {
3101
+ for (const {action: action, path: path} of actionSites(def)) {
3042
3102
  const message = storedRolesIssue(action);
3043
3103
  message !== void 0 && issues.push({
3044
- path: [ "stages", i, "activities", j, "actions", a, "roles" ],
3104
+ path: [ ...path, "roles" ],
3045
3105
  message: message
3046
3106
  });
3047
3107
  }
3048
3108
  }
3049
3109
 
3050
3110
  function checkTriggeredActionParams(def, issues) {
3051
- for (const [i, stage] of def.stages.entries()) for (const [j, activity] of (stage.activities ?? []).entries()) for (const [a, action] of (activity.actions ?? []).entries()) action.when === void 0 || (action.params ?? []).length === 0 || issues.push({
3052
- path: [ "stages", i, "activities", j, "actions", a, "params" ],
3111
+ for (const {action: action, path: path} of actionSites(def)) action.when === void 0 || (action.params ?? []).length === 0 || issues.push({
3112
+ path: [ ...path, "params" ],
3053
3113
  message: `action "${action.name}" declares params but is cascade-fired (\`when\`) — no caller ever supplies args to a trigger. Drop the params, or drop \`when\` to make it a fireAction-fired action`
3054
3114
  });
3055
3115
  }
@@ -3094,13 +3154,20 @@ function checkSingleSubjectRequirements(def, issues) {
3094
3154
 
3095
3155
  function checkStartFilterReads(def, issues) {
3096
3156
  const filter = def.start?.filter;
3097
- filter !== void 0 && (conditionParameterNames(filter).has("fields") && issues.push({
3157
+ if (filter === void 0) return;
3158
+ const params = conditionParameterNames(filter);
3159
+ params.has("fields") && issues.push({
3098
3160
  path: [ "start", "filter" ],
3099
3161
  message: "start.filter reads $fields, but the filter is browse-time-pure — a start surface evaluates it per document, before any inputs exist, so $fields cannot be bound. Move the input-dependent rule to a start requirement, the start-time readiness predicate that binds $fields and is enforced by startInstance"
3100
- }), readsRootDocument(filter) && !(def.fields ?? []).some(isSubjectEntry) && issues.push({
3162
+ });
3163
+ for (const name of CALLER_BOUND_VARS) params.has(name) && issues.push({
3164
+ path: [ "start", "filter" ],
3165
+ message: `start.filter reads $${name} — start.filter is a read-side document-visibility rule (definitionsForDocument / start controls); startInstance never evaluates it, and no caller bag binds there, so the read is GROQ null and the filter can never match. Drop $${name} from start.filter. A "who may start" rule belongs in a start requirement, not in the browse-time filter`
3166
+ });
3167
+ readsRootDocument(filter) && !(def.fields ?? []).some(isSubjectEntry) && issues.push({
3101
3168
  path: [ "start", "filter" ],
3102
3169
  message: "start.filter reads the candidate document (its root), but the definition declares no `subject` entry — a read surface would never have a document to bind as root, so every root read is GROQ null and the filter silently misevaluates. Declare a `subject` entry (the document the workflow is about), or gate on the dataset instead"
3103
- }));
3170
+ });
3104
3171
  }
3105
3172
 
3106
3173
  function checkStartRequirementReads(def, issues) {
@@ -3396,15 +3463,13 @@ function seedEarlierSiblingTarget(args) {
3396
3463
  }
3397
3464
 
3398
3465
  function opSites(def) {
3399
- const sites = [];
3400
- for (const [i, stage] of def.stages.entries()) for (const [j, activity] of (stage.activities ?? []).entries()) for (const [a, action] of (activity.actions ?? []).entries()) sites.push({
3466
+ return actionSites(def).map(({action: action, activity: activity, stage: stage, path: path}) => ({
3401
3467
  ops: action.ops,
3402
- path: [ "stages", i, "activities", j, "actions", a, "ops" ],
3468
+ path: [ ...path, "ops" ],
3403
3469
  label: `action "${action.name}"`,
3404
3470
  stage: stage,
3405
3471
  activity: activity
3406
- });
3407
- return sites;
3472
+ }));
3408
3473
  }
3409
3474
 
3410
3475
  function checkFieldReadOpValues(def, issues) {
@@ -3421,7 +3486,7 @@ function checkFieldReadOpValues(def, issues) {
3421
3486
  }
3422
3487
 
3423
3488
  function checkOpsFieldReads({ops: ops, path: path, label: label, ...ctx}) {
3424
- for (const [o, op] of (ops ?? []).entries()) if ("value" in op) for (const {read: read, path: readPath} of fieldReadsIn(op.value, [ ...path, o, "value" ])) checkOpFieldRead({
3489
+ for (const [o, op] of (ops ?? []).entries()) if (!(!("value" in op) || op.value === void 0)) for (const {read: read, path: readPath} of fieldReadsIn(op.value, [ ...path, o, "value" ])) checkOpFieldRead({
3425
3490
  ...ctx,
3426
3491
  read: read,
3427
3492
  path: readPath,
@@ -3472,35 +3537,55 @@ function opFieldReadMissMessage({read: read, where: where, hosts: hosts}) {
3472
3537
  return `${where} reads field "${read.field}", which is not declared at ${searched} — the read resolves to undefined at op time, so the write silently lands empty. Known: ${known.join(", ") || "(none)"}`;
3473
3538
  }
3474
3539
 
3475
- function checkUpdateWhereOps(def, issues) {
3540
+ function checkFieldTargetOps(def, issues) {
3476
3541
  const workflow = def.fields ?? [];
3477
3542
  for (const site of opSites(def)) for (const [o, op] of (site.ops ?? []).entries()) {
3478
- if (op.type !== "field.updateWhere") continue;
3479
- const scopes = {
3480
- workflow: workflow,
3481
- stage: site.stage.fields ?? [],
3482
- activity: site.activity.fields ?? []
3483
- };
3484
- checkUpdateWhereTargetKind({
3543
+ const path = [ ...site.path, o ], rule = TARGET_KIND_RULES[op.type];
3544
+ rule !== void 0 && "target" in op && checkTargetKind({
3485
3545
  op: op,
3486
- path: [ ...site.path, o ],
3546
+ path: [ ...path, "target" ],
3487
3547
  label: site.label,
3488
- scopes: scopes,
3548
+ scopes: opTargetScopes(workflow, site),
3549
+ rule: rule,
3489
3550
  issues: issues
3490
- }), checkUpdateWhereMergeKeys({
3551
+ }), op.type === "field.updateWhere" && checkUpdateWhereMergeKeys({
3491
3552
  op: op,
3492
- path: [ ...site.path, o ],
3553
+ path: path,
3493
3554
  label: site.label,
3494
3555
  issues: issues
3495
3556
  });
3496
3557
  }
3497
3558
  }
3498
3559
 
3499
- function checkUpdateWhereTargetKind({op: op, path: path, label: label, scopes: scopes, issues: issues}) {
3560
+ const arithmeticTargetRule = {
3561
+ accepts: target => target.type === "number",
3562
+ issue: () => "arithmetic ops target `number` entries only"
3563
+ }, TARGET_KIND_RULES = {
3564
+ "field.inc": arithmeticTargetRule,
3565
+ "field.dec": arithmeticTargetRule,
3566
+ "field.setIfMissing": {
3567
+ accepts: target => !isAlwaysArrayFieldKind(target.type),
3568
+ issue: () => "setIfMissing applies to nullable entries only; an empty array entry already holds []"
3569
+ },
3570
+ "field.updateWhere": {
3571
+ accepts: target => target.type === "array",
3572
+ issue: target => "updateWhere merges declared row sub-fields, so its target must be an `array` entry" + rowOpsHint(target.type)
3573
+ }
3574
+ };
3575
+
3576
+ function opTargetScopes(workflow, site) {
3577
+ return {
3578
+ workflow: workflow,
3579
+ stage: site.stage.fields ?? [],
3580
+ activity: site.activity.fields ?? []
3581
+ };
3582
+ }
3583
+
3584
+ function checkTargetKind({op: op, path: path, label: label, scopes: scopes, rule: rule, issues: issues}) {
3500
3585
  const target = scopes[op.target.scope]?.find(entry => entry.name === op.target.field);
3501
- target === void 0 || target.type === "array" || issues.push({
3502
- path: [ ...path, "target" ],
3503
- message: `${label} field.updateWhere targets ${op.target.scope}-scope "${op.target.field}" (${target.type}) — updateWhere merges declared row sub-fields, so its target must be an \`array\` entry${rowOpsHint(target.type)}`
3586
+ target === void 0 || rule.accepts(target) || issues.push({
3587
+ path: path,
3588
+ message: `${label} ${op.type} targets ${op.target.scope}-scope "${op.target.field}" (${target.type}) — ${rule.issue(target)}`
3504
3589
  });
3505
3590
  }
3506
3591
 
@@ -3732,11 +3817,11 @@ function checkWorkflowInvariants(def) {
3732
3817
  }), checkEffectNames(def, issues), checkGuardNames(def, issues), checkFieldEntryNames(def, issues),
3733
3818
  checkRequiredField(def, issues), checkStart(def, issues), checkPredicates(def, issues),
3734
3819
  checkUnboundConditionVars(def, issues), checkConditionFieldReads(def, issues), checkFieldReadSeeds(def, issues),
3735
- checkFieldReadOpValues(def, issues), checkUpdateWhereOps(def, issues), checkGuardFieldReads(def, issues),
3820
+ checkFieldReadOpValues(def, issues), checkFieldTargetOps(def, issues), checkGuardFieldReads(def, issues),
3736
3821
  checkAssigneesEntries(def, issues), checkDueDateEntries(def, issues), checkSubjectEntries(def, issues),
3737
3822
  checkLevelKindEffectOutputs(def, issues), checkActivityTerminalPaths(def, issues),
3738
3823
  checkTerminalStageActivities(def, issues), checkTriggeredActionParams(def, issues),
3739
3824
  checkStoredRolesPlacement(def, issues), checkGroups(def, issues), issues;
3740
3825
  }
3741
3826
 
3742
- export { ACTION_SEMANTICS, ACTIVITY_KINDS, ACTIVITY_STATUSES, ACTOR_KINDS, ANONYMOUS_IDENTITY, ActorShape, AuthoringActionSchema, AuthoringActivitySchema, AuthoringFieldEntrySchema, AuthoringGuardSchema, AuthoringOpSchema, AuthoringStageSchema, AuthoringTransitionSchema, AuthoringWorkflowSchema, CALLER_BOUND_VARS, CONDITION_VARS, ContractViolationError, DEFAULT_TRANSITION_WHEN, DOCUMENT_VALUE_PERMISSIONS, DRIVER_KINDS, DefinitionInUseError, DefinitionNotFoundError, EFFECTS_READ, EXECUTOR_CLASSIFICATIONS, EffectNotFoundError, EffectSchema, FIELD_READ, FIELD_SCOPES, FIELD_VALUE_KINDS, FILTER_SCOPE_VARS, FieldValueShapeError, GROUP_KINDS, GUARD_PREDICATE_VARS, GdrShape, GroupSchema, InstanceNotFoundError, IsoTimestamp, MUTATION_GUARD_ACTIONS, NonEmptyString, PersistedDocShapeError, RESERVED_CONDITION_VARS, RESOURCE_ALIAS_NAME_SOURCE, START_FILTER_VARS, START_REQUIREMENT_VARS, SYSTEM_IDENTITY, SpawnContractsInvalidError, StoredFieldOpSchema, VersionSpecificDatasetGdrError, WORKFLOW_DEFINITION_TYPE, WorkflowConfigSchema, WorkflowError, actorFulfillsRole, andConditions, checkWorkflowInvariants, choiceValueIssues, classifyPrincipalId, clientConfigFromResource, conditionEffectReads, conditionFieldReadNames, conditionParameterNames, conditionSyntaxIssues, datasetResourceParts, definitionDocId, deriveActivityKind, deriveExecutorClassification, desugarWorkflow, directoryBridgeId, driverKind, errorMessage, evaluateCondition, evaluateConditionOutcome, evaluatePredicates, extractDocumentId, fieldValueSchemas, firstCarriedGlobalId, formatIssuePath, formatIssues, formatValidationError, gdrFromResource, gdrRef, gdrResourcePrefix, gdrUri, groq, groupMembershipNames, isBareSeedId, isCascadeFired, isGdr, isGdrUri, isGuardReadExpr, isInputSourced, isNotesEntry, isParseableInstant, isSingleDocRefEntry, isSingleDocRefKind, isStartableDefinition, isSubjectEntry, isTerminalActivityStatus, isTodoListEntry, isTodoListItem, isUnevaluable, labelFor, lakePrincipalId, parseFieldValue, parseGdr, parseOrThrow, parsePersistedDoc, parseResourceGdr, parseStoredDefinition, readsRootDocument, refCanvas, refDashboard, refDataset, refKindAcceptsTypes, refMediaLibrary, refTypeIssues, rejectedRefTypes, releaseDocId, releaseRef, resourceAliasesToMap, resourceFromGdrUri, resourceFromParsed, resourceGdr, rethrowWithContext, runGroq, sameResource, scalarValidationIssues, schemaTreeShape, selfGdr, startKindOf, tagScopeFilter, toBareId, toPhysicalGdr, tolerantEntries, tolerantObject, tryParseGdr, validateFieldAppendItem, validateFieldValue, validateResourceAliasName, validateTag };
3827
+ export { ACTION_SEMANTICS, ACTIVITY_KINDS, ACTIVITY_STATUSES, ACTOR_KINDS, ANONYMOUS_IDENTITY, ActorShape, AuthoringActionSchema, AuthoringActivitySchema, AuthoringFieldEntrySchema, AuthoringGuardSchema, AuthoringOpSchema, AuthoringStageSchema, AuthoringTransitionSchema, AuthoringWorkflowSchema, CALLER_BOUND_VARS, CONDITION_VARS, ContractViolationError, DECISION_SEMANTICS, DEFAULT_TRANSITION_WHEN, DOCUMENT_VALUE_PERMISSIONS, DRIVER_KINDS, DefinitionInUseError, DefinitionNotFoundError, EFFECTS_READ, EXECUTOR_CLASSIFICATIONS, EffectNotFoundError, EffectSchema, FIELD_READ, FIELD_SCOPES, FIELD_VALUE_KINDS, FILTER_SCOPE_VARS, FieldValueShapeError, GROUP_KINDS, GUARD_PREDICATE_VARS, GdrShape, GroupSchema, InstanceNotFoundError, IsoTimestamp, MUTATION_GUARD_ACTIONS, NonEmptyString, PersistedDocShapeError, RESERVED_CONDITION_VARS, RESOURCE_ALIAS_NAME_SOURCE, SIGNAL_SEMANTICS, START_FILTER_VARS, START_REQUIREMENT_VARS, SYSTEM_IDENTITY, SpawnContractsInvalidError, StoredFieldOpSchema, VersionSpecificDatasetGdrError, WORKFLOW_DEFINITION_TYPE, WorkflowConfigSchema, WorkflowError, actorFulfillsRole, andConditions, checkWorkflowInvariants, choiceValueIssues, classifyPrincipalId, clientConfigFromResource, conditionEffectReads, conditionFieldReadNames, conditionParameterNames, conditionSyntaxIssues, datasetResourceParts, definitionDocId, deriveActivityKind, deriveExecutorClassification, desugarWorkflow, directoryBridgeId, driverKind, errorMessage, evaluateCondition, evaluateConditionOutcome, evaluatePredicates, extractDocumentId, fieldValueSchemas, firstCarriedGlobalId, formatIssuePath, formatIssues, formatValidationError, gdrFromResource, gdrRef, gdrResourcePrefix, gdrUri, groq, groupMembershipNames, isAlwaysArrayFieldKind, isBareSeedId, isCascadeFired, isGdr, isGdrUri, isGuardReadExpr, isInputSourced, isNotesEntry, isParseableInstant, isSingleDocRefEntry, isSingleDocRefKind, isStartableDefinition, isSubjectEntry, isTerminalActivityStatus, isTodoListEntry, isTodoListItem, isUnevaluable, labelFor, lakePrincipalId, parseFieldValue, parseGdr, parseOrThrow, parsePersistedDoc, parseResourceGdr, parseStoredDefinition, readsRootDocument, refCanvas, refDashboard, refDataset, refKindAcceptsTypes, refMediaLibrary, refTypeIssues, rejectedRefTypes, releaseDocId, releaseRef, resourceAliasesToMap, resourceFromGdrUri, resourceFromParsed, resourceGdr, rethrowWithContext, runGroq, sameResource, scalarValidationIssues, schemaTreeShape, selfGdr, startKindOf, tagScopeFilter, toBareId, toPhysicalGdr, tolerantEntries, tolerantObject, tryParseGdr, validateFieldAppendItem, validateFieldValue, validateResourceAliasName, validateTag };