@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.
@@ -539,6 +539,7 @@ function desugarWorkflow(authoring) {
539
539
  return {
540
540
  ...stripUndefined({
541
541
  name: stage.name,
542
+ semantics: stage.semantics,
542
543
  title: stage.title,
543
544
  description: stage.description,
544
545
  groups: stage.groups,
@@ -562,6 +563,7 @@ function desugarWorkflow(authoring) {
562
563
  definition: {
563
564
  ...stripUndefined({
564
565
  name: authoring.name,
566
+ semantics: authoring.semantics,
565
567
  title: authoring.title,
566
568
  description: authoring.description,
567
569
  groups: authoring.groups,
@@ -788,6 +790,7 @@ function desugarActivity({activity: activity, path: path, stageEnv: stageEnv, ct
788
790
  return {
789
791
  ...stripUndefined({
790
792
  name: activity.name,
793
+ semantics: activity.semantics,
791
794
  title: activity.title,
792
795
  description: activity.description,
793
796
  groups: activity.groups,
@@ -1259,7 +1262,58 @@ function isTerminalActivityStatus(status) {
1259
1262
  return TERMINAL_ACTIVITY_STATUSES.includes(status);
1260
1263
  }
1261
1264
 
1262
- 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 = [ {
1265
+ 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" ];
1266
+
1267
+ function releaseDocId(releaseName) {
1268
+ return `_.releases.${releaseName}`;
1269
+ }
1270
+
1271
+ function releaseRef({res: res, releaseName: releaseName}) {
1272
+ if (releaseName.length === 0) throw new ContractViolationError("releaseRef: releaseName must be a non-empty release name");
1273
+ return {
1274
+ id: gdrFromResource(res, releaseDocId(releaseName)),
1275
+ type: "system.release",
1276
+ releaseName: releaseName
1277
+ };
1278
+ }
1279
+
1280
+ function isAlwaysArrayFieldKind(kind) {
1281
+ return kind === "doc.refs" || kind === "assignees" || kind === "array";
1282
+ }
1283
+
1284
+ function isSingleDocRefKind(kind) {
1285
+ return kind === "doc.ref" || kind === "subject";
1286
+ }
1287
+
1288
+ function refKindAcceptsTypes(kind) {
1289
+ return isSingleDocRefKind(kind) || kind === "doc.refs";
1290
+ }
1291
+
1292
+ function isSingleDocRefEntry(entry) {
1293
+ return isSingleDocRefKind(entry._type);
1294
+ }
1295
+
1296
+ function isTodoListItem(row) {
1297
+ if (typeof row != "object" || row === null) return !1;
1298
+ const candidate = row, status = candidate.status;
1299
+ return typeof candidate._key == "string" && typeof candidate.label == "string" && (status == null || typeof status == "string");
1300
+ }
1301
+
1302
+ function declaredRowColumns(entry) {
1303
+ if (("_type" in entry ? entry._type : entry.type) === "array") return new Set((("of" in entry ? entry.of : void 0) ?? []).map(shape => shape.name));
1304
+ }
1305
+
1306
+ function isTodoListEntry(entry) {
1307
+ const columns = declaredRowColumns(entry);
1308
+ return columns !== void 0 && columns.has("label") && columns.has("status");
1309
+ }
1310
+
1311
+ function isNotesEntry(entry) {
1312
+ const columns = declaredRowColumns(entry);
1313
+ return columns !== void 0 && columns.has("body") && columns.has("actor") && columns.has("at");
1314
+ }
1315
+
1316
+ const CONDITION_VARS = [ {
1263
1317
  name: "self",
1264
1318
  binding: "always",
1265
1319
  label: "this workflow instance",
@@ -1334,6 +1388,11 @@ const ACTION_SEMANTICS = [ "decision.accept", "decision.decline" ], FIELD_SCOPES
1334
1388
  binding: "caller",
1335
1389
  label: "your permissions",
1336
1390
  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."
1391
+ }, {
1392
+ name: "attributes",
1393
+ binding: "caller",
1394
+ label: "your attributes",
1395
+ 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."
1337
1396
  }, {
1338
1397
  name: "row",
1339
1398
  binding: "spawn",
@@ -1499,54 +1558,7 @@ function documentIdOf(doc) {
1499
1558
  return "(unknown id)";
1500
1559
  }
1501
1560
 
1502
- const ACTOR_KINDS = [ "person", "agent", "system" ];
1503
-
1504
- function releaseDocId(releaseName) {
1505
- return `_.releases.${releaseName}`;
1506
- }
1507
-
1508
- function releaseRef({res: res, releaseName: releaseName}) {
1509
- if (releaseName.length === 0) throw new ContractViolationError("releaseRef: releaseName must be a non-empty release name");
1510
- return {
1511
- id: gdrFromResource(res, releaseDocId(releaseName)),
1512
- type: "system.release",
1513
- releaseName: releaseName
1514
- };
1515
- }
1516
-
1517
- function isSingleDocRefKind(kind) {
1518
- return kind === "doc.ref" || kind === "subject";
1519
- }
1520
-
1521
- function refKindAcceptsTypes(kind) {
1522
- return isSingleDocRefKind(kind) || kind === "doc.refs";
1523
- }
1524
-
1525
- function isSingleDocRefEntry(entry) {
1526
- return isSingleDocRefKind(entry._type);
1527
- }
1528
-
1529
- function isTodoListItem(row) {
1530
- if (typeof row != "object" || row === null) return !1;
1531
- const candidate = row, status = candidate.status;
1532
- return typeof candidate._key == "string" && typeof candidate.label == "string" && (status == null || typeof status == "string");
1533
- }
1534
-
1535
- function declaredRowColumns(entry) {
1536
- if (("_type" in entry ? entry._type : entry.type) === "array") return new Set((("of" in entry ? entry.of : void 0) ?? []).map(shape => shape.name));
1537
- }
1538
-
1539
- function isTodoListEntry(entry) {
1540
- const columns = declaredRowColumns(entry);
1541
- return columns !== void 0 && columns.has("label") && columns.has("status");
1542
- }
1543
-
1544
- function isNotesEntry(entry) {
1545
- const columns = declaredRowColumns(entry);
1546
- return columns !== void 0 && columns.has("body") && columns.has("actor") && columns.has("at");
1547
- }
1548
-
1549
- const ANONYMOUS_IDENTITY = "<anonymous>", SYSTEM_IDENTITY = "<system>", E_PREFIXED_PROJECT_ID = /^e-(.+)$/;
1561
+ const ACTOR_KINDS = [ "person", "agent", "system" ], ANONYMOUS_IDENTITY = "<anonymous>", SYSTEM_IDENTITY = "<system>", E_PREFIXED_PROJECT_ID = /^e-(.+)$/;
1550
1562
 
1551
1563
  function classifyPrincipalId(id) {
1552
1564
  if (id === ANONYMOUS_IDENTITY || id === SYSTEM_IDENTITY) return {
@@ -1975,6 +1987,10 @@ function opSchemas(targetSchema) {
1975
1987
  type: v__namespace.literal("field.set"),
1976
1988
  target: targetSchema,
1977
1989
  value: ValueExprSchema
1990
+ }), v__namespace.strictObject({
1991
+ type: v__namespace.literal("field.setIfMissing"),
1992
+ target: targetSchema,
1993
+ value: ValueExprSchema
1978
1994
  }), v__namespace.strictObject({
1979
1995
  type: v__namespace.literal("field.unset"),
1980
1996
  target: targetSchema
@@ -1982,6 +1998,14 @@ function opSchemas(targetSchema) {
1982
1998
  type: v__namespace.literal("field.append"),
1983
1999
  target: targetSchema,
1984
2000
  value: ValueExprSchema
2001
+ }), v__namespace.strictObject({
2002
+ type: v__namespace.literal("field.inc"),
2003
+ target: targetSchema,
2004
+ value: v__namespace.optional(ValueExprSchema)
2005
+ }), v__namespace.strictObject({
2006
+ type: v__namespace.literal("field.dec"),
2007
+ target: targetSchema,
2008
+ value: v__namespace.optional(ValueExprSchema)
1985
2009
  }), v__namespace.strictObject({
1986
2010
  type: v__namespace.literal("field.updateWhere"),
1987
2011
  target: targetSchema,
@@ -2198,17 +2222,27 @@ const TodoListFieldSchema = pinned()(v__namespace.strictObject(listSugarFields("
2198
2222
  required: v__namespace.optional(v__namespace.boolean()),
2199
2223
  options: v__namespace.optional(ChoiceOptionsSchema),
2200
2224
  validation: v__namespace.optional(ScalarValidationSchema)
2201
- }), choiceOptionsCheck(), scalarValidationCheck());
2225
+ }), choiceOptionsCheck(), scalarValidationCheck()), CUSTOM_SEMANTIC_HINT = "`custom.<camelCaseMeaning>`", CustomSemanticSchema = v__namespace.custom(input => typeof input == "string" && /^custom\.[a-z][a-zA-Z0-9]*$/.test(input)), SemanticSchema = v__namespace.union([ picklist(SIGNAL_SEMANTICS), CustomSemanticSchema ], `expected ${SIGNAL_SEMANTICS.join(", ")}, or ${CUSTOM_SEMANTIC_HINT}`), ActionSemanticSchema = v__namespace.union([ picklist(ACTION_SEMANTICS), CustomSemanticSchema ], `expected ${ACTION_SEMANTICS.join(", ")}, or ${CUSTOM_SEMANTIC_HINT}`);
2226
+
2227
+ function semanticNamespace(semantic) {
2228
+ return semantic.startsWith("custom.") ? semantic : semantic.split(".", 1)[0] ?? semantic;
2229
+ }
2202
2230
 
2203
2231
  function hasUniqueSemanticNamespaces(semantics) {
2204
- const namespaces = semantics.map(semantic => semantic.split(".", 1)[0]);
2232
+ const namespaces = semantics.map(semanticNamespace);
2205
2233
  return new Set(namespaces).size === namespaces.length;
2206
2234
  }
2207
2235
 
2236
+ function semanticsFieldSchema(semantic) {
2237
+ return v__namespace.optional(v__namespace.pipe(v__namespace.array(semantic), v__namespace.minLength(1, "declare at least one semantic, or omit `semantics`"), v__namespace.check(hasUniqueSemanticNamespaces, "declare at most one semantic from each namespace")));
2238
+ }
2239
+
2240
+ const SemanticsFieldSchema = semanticsFieldSchema(SemanticSchema), ActionSemanticsFieldSchema = semanticsFieldSchema(ActionSemanticSchema);
2241
+
2208
2242
  function actionFields(op, group) {
2209
2243
  return {
2210
2244
  name: NonEmpty,
2211
- semantics: v__namespace.optional(v__namespace.pipe(v__namespace.array(picklist(ACTION_SEMANTICS)), v__namespace.minLength(1, "declare at least one semantic, or omit `semantics`"), v__namespace.check(hasUniqueSemanticNamespaces, "declare at most one semantic from each namespace"))),
2245
+ semantics: ActionSemanticsFieldSchema,
2212
2246
  title: v__namespace.optional(v__namespace.string()),
2213
2247
  description: v__namespace.optional(v__namespace.string()),
2214
2248
  group: v__namespace.optional(group),
@@ -2255,6 +2289,7 @@ const StoredActionSchema = pinned()(v__namespace.strictObject({
2255
2289
  function activityFields({field: field, action: action, target: target, group: group}) {
2256
2290
  return {
2257
2291
  name: NonEmpty,
2292
+ semantics: SemanticsFieldSchema,
2258
2293
  title: v__namespace.optional(v__namespace.string()),
2259
2294
  description: v__namespace.optional(v__namespace.string()),
2260
2295
  groups: v__namespace.optional(v__namespace.array(GroupSchema)),
@@ -2328,6 +2363,7 @@ const GuardSchema = v__namespace.strictObject(guardFields(NonEmpty)), AuthoringG
2328
2363
  function stageFields({field: field, activity: activity, transition: transition, guard: guard, editable: editable}) {
2329
2364
  return {
2330
2365
  name: NonEmpty,
2366
+ semantics: SemanticsFieldSchema,
2331
2367
  title: v__namespace.optional(v__namespace.string()),
2332
2368
  description: v__namespace.optional(v__namespace.string()),
2333
2369
  groups: v__namespace.optional(v__namespace.array(GroupSchema)),
@@ -2366,6 +2402,7 @@ const StoredStartSchema = pinned()(v__namespace.strictObject(startFields(picklis
2366
2402
  function workflowFields({field: field, stage: stage, start: start}) {
2367
2403
  return {
2368
2404
  name: NonEmpty,
2405
+ semantics: SemanticsFieldSchema,
2369
2406
  title: NonEmpty,
2370
2407
  description: v__namespace.optional(v__namespace.string()),
2371
2408
  groups: v__namespace.optional(v__namespace.array(GroupSchema)),
@@ -2562,11 +2599,20 @@ function checkStageReachability({def: def, stageNames: stageNames, issues: issue
2562
2599
  });
2563
2600
  }
2564
2601
 
2602
+ function actionSites(def) {
2603
+ return def.stages.flatMap((stage, i) => (stage.activities ?? []).flatMap((activity, j) => (activity.actions ?? []).map((action, a) => ({
2604
+ action: action,
2605
+ activity: activity,
2606
+ stage: stage,
2607
+ path: [ "stages", i, "activities", j, "actions", a ]
2608
+ }))));
2609
+ }
2610
+
2565
2611
  function effectNameSites(def) {
2566
2612
  const sites = [];
2567
- 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({
2613
+ for (const {action: action, path: path} of actionSites(def)) collectEffects({
2568
2614
  effects: action.effects,
2569
- path: [ "stages", i, "activities", j, "actions", a, "effects" ],
2615
+ path: [ ...path, "effects" ],
2570
2616
  sites: sites
2571
2617
  });
2572
2618
  return sites;
@@ -2697,13 +2743,22 @@ function checkUnboundConditionVars(def, issues) {
2697
2743
  }
2698
2744
  }
2699
2745
 
2746
+ const SOFT_GATE_VARS = {
2747
+ can: {
2748
+ noun: "the caller's grants"
2749
+ },
2750
+ attributes: {
2751
+ noun: "the caller's org-level user attributes"
2752
+ }
2753
+ }, SOFT_GATE_VAR_NAMES = Object.keys(SOFT_GATE_VARS);
2754
+
2700
2755
  function unboundVarsAt(site) {
2701
2756
  const callerVars = unboundCallerVars(site.policy);
2702
2757
  return site.bindsRow === !0 ? callerVars : [ ...callerVars, "row" ];
2703
2758
  }
2704
2759
 
2705
2760
  function unboundCallerVars(policy) {
2706
- return policy === "cascade" ? CALLER_BOUND_VARS : policy === "caller-bound" ? [ "params" ] : policy === "triggered-payload" ? [ "can", "params" ] : [ "can" ];
2761
+ return policy === "cascade" ? CALLER_BOUND_VARS : policy === "caller-bound" ? [ "params" ] : policy === "triggered-payload" ? [ ...SOFT_GATE_VAR_NAMES, "params" ] : SOFT_GATE_VAR_NAMES;
2707
2762
  }
2708
2763
 
2709
2764
  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";
@@ -2713,7 +2768,12 @@ function rowVarMessage(site) {
2713
2768
  }
2714
2769
 
2715
2770
  function callerVarMessage(site, name) {
2716
- 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`;
2771
+ 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\``;
2772
+ 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`;
2773
+ 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`;
2774
+ const softGate = SOFT_GATE_VARS[name];
2775
+ 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}`;
2776
+ throw new Error(`callerVarMessage: unreachable for $${name} at ${site.label} (policy ${site.policy})`);
2717
2777
  }
2718
2778
 
2719
2779
  function conditionSites(def) {
@@ -2968,9 +3028,9 @@ function belowScopeNestedSites(args) {
2968
3028
  }
2969
3029
 
2970
3030
  function checkLevelKindEffectOutputs(def, issues) {
2971
- 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({
3031
+ for (const {action: action, path: path} of actionSites(def)) pushOutputIssues({
2972
3032
  action: action,
2973
- path: [ "stages", i, "activities", j, "actions", a ],
3033
+ path: path,
2974
3034
  issues: issues
2975
3035
  });
2976
3036
  }
@@ -3052,18 +3112,18 @@ function storedRolesIssue(action) {
3052
3112
  }
3053
3113
 
3054
3114
  function checkStoredRolesPlacement(def, issues) {
3055
- for (const [i, stage] of def.stages.entries()) for (const [j, activity] of (stage.activities ?? []).entries()) for (const [a, action] of (activity.actions ?? []).entries()) {
3115
+ for (const {action: action, path: path} of actionSites(def)) {
3056
3116
  const message = storedRolesIssue(action);
3057
3117
  message !== void 0 && issues.push({
3058
- path: [ "stages", i, "activities", j, "actions", a, "roles" ],
3118
+ path: [ ...path, "roles" ],
3059
3119
  message: message
3060
3120
  });
3061
3121
  }
3062
3122
  }
3063
3123
 
3064
3124
  function checkTriggeredActionParams(def, issues) {
3065
- 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({
3066
- path: [ "stages", i, "activities", j, "actions", a, "params" ],
3125
+ for (const {action: action, path: path} of actionSites(def)) action.when === void 0 || (action.params ?? []).length === 0 || issues.push({
3126
+ path: [ ...path, "params" ],
3067
3127
  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`
3068
3128
  });
3069
3129
  }
@@ -3108,13 +3168,20 @@ function checkSingleSubjectRequirements(def, issues) {
3108
3168
 
3109
3169
  function checkStartFilterReads(def, issues) {
3110
3170
  const filter = def.start?.filter;
3111
- filter !== void 0 && (conditionParameterNames(filter).has("fields") && issues.push({
3171
+ if (filter === void 0) return;
3172
+ const params = conditionParameterNames(filter);
3173
+ params.has("fields") && issues.push({
3112
3174
  path: [ "start", "filter" ],
3113
3175
  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"
3114
- }), readsRootDocument(filter) && !(def.fields ?? []).some(isSubjectEntry) && issues.push({
3176
+ });
3177
+ for (const name of CALLER_BOUND_VARS) params.has(name) && issues.push({
3178
+ path: [ "start", "filter" ],
3179
+ 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`
3180
+ });
3181
+ readsRootDocument(filter) && !(def.fields ?? []).some(isSubjectEntry) && issues.push({
3115
3182
  path: [ "start", "filter" ],
3116
3183
  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"
3117
- }));
3184
+ });
3118
3185
  }
3119
3186
 
3120
3187
  function checkStartRequirementReads(def, issues) {
@@ -3410,15 +3477,13 @@ function seedEarlierSiblingTarget(args) {
3410
3477
  }
3411
3478
 
3412
3479
  function opSites(def) {
3413
- const sites = [];
3414
- 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({
3480
+ return actionSites(def).map(({action: action, activity: activity, stage: stage, path: path}) => ({
3415
3481
  ops: action.ops,
3416
- path: [ "stages", i, "activities", j, "actions", a, "ops" ],
3482
+ path: [ ...path, "ops" ],
3417
3483
  label: `action "${action.name}"`,
3418
3484
  stage: stage,
3419
3485
  activity: activity
3420
- });
3421
- return sites;
3486
+ }));
3422
3487
  }
3423
3488
 
3424
3489
  function checkFieldReadOpValues(def, issues) {
@@ -3435,7 +3500,7 @@ function checkFieldReadOpValues(def, issues) {
3435
3500
  }
3436
3501
 
3437
3502
  function checkOpsFieldReads({ops: ops, path: path, label: label, ...ctx}) {
3438
- 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({
3503
+ 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({
3439
3504
  ...ctx,
3440
3505
  read: read,
3441
3506
  path: readPath,
@@ -3486,35 +3551,55 @@ function opFieldReadMissMessage({read: read, where: where, hosts: hosts}) {
3486
3551
  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)"}`;
3487
3552
  }
3488
3553
 
3489
- function checkUpdateWhereOps(def, issues) {
3554
+ function checkFieldTargetOps(def, issues) {
3490
3555
  const workflow = def.fields ?? [];
3491
3556
  for (const site of opSites(def)) for (const [o, op] of (site.ops ?? []).entries()) {
3492
- if (op.type !== "field.updateWhere") continue;
3493
- const scopes = {
3494
- workflow: workflow,
3495
- stage: site.stage.fields ?? [],
3496
- activity: site.activity.fields ?? []
3497
- };
3498
- checkUpdateWhereTargetKind({
3557
+ const path = [ ...site.path, o ], rule = TARGET_KIND_RULES[op.type];
3558
+ rule !== void 0 && "target" in op && checkTargetKind({
3499
3559
  op: op,
3500
- path: [ ...site.path, o ],
3560
+ path: [ ...path, "target" ],
3501
3561
  label: site.label,
3502
- scopes: scopes,
3562
+ scopes: opTargetScopes(workflow, site),
3563
+ rule: rule,
3503
3564
  issues: issues
3504
- }), checkUpdateWhereMergeKeys({
3565
+ }), op.type === "field.updateWhere" && checkUpdateWhereMergeKeys({
3505
3566
  op: op,
3506
- path: [ ...site.path, o ],
3567
+ path: path,
3507
3568
  label: site.label,
3508
3569
  issues: issues
3509
3570
  });
3510
3571
  }
3511
3572
  }
3512
3573
 
3513
- function checkUpdateWhereTargetKind({op: op, path: path, label: label, scopes: scopes, issues: issues}) {
3574
+ const arithmeticTargetRule = {
3575
+ accepts: target => target.type === "number",
3576
+ issue: () => "arithmetic ops target `number` entries only"
3577
+ }, TARGET_KIND_RULES = {
3578
+ "field.inc": arithmeticTargetRule,
3579
+ "field.dec": arithmeticTargetRule,
3580
+ "field.setIfMissing": {
3581
+ accepts: target => !isAlwaysArrayFieldKind(target.type),
3582
+ issue: () => "setIfMissing applies to nullable entries only; an empty array entry already holds []"
3583
+ },
3584
+ "field.updateWhere": {
3585
+ accepts: target => target.type === "array",
3586
+ issue: target => "updateWhere merges declared row sub-fields, so its target must be an `array` entry" + rowOpsHint(target.type)
3587
+ }
3588
+ };
3589
+
3590
+ function opTargetScopes(workflow, site) {
3591
+ return {
3592
+ workflow: workflow,
3593
+ stage: site.stage.fields ?? [],
3594
+ activity: site.activity.fields ?? []
3595
+ };
3596
+ }
3597
+
3598
+ function checkTargetKind({op: op, path: path, label: label, scopes: scopes, rule: rule, issues: issues}) {
3514
3599
  const target = scopes[op.target.scope]?.find(entry => entry.name === op.target.field);
3515
- target === void 0 || target.type === "array" || issues.push({
3516
- path: [ ...path, "target" ],
3517
- 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)}`
3600
+ target === void 0 || rule.accepts(target) || issues.push({
3601
+ path: path,
3602
+ message: `${label} ${op.type} targets ${op.target.scope}-scope "${op.target.field}" (${target.type}) — ${rule.issue(target)}`
3518
3603
  });
3519
3604
  }
3520
3605
 
@@ -3746,7 +3831,7 @@ function checkWorkflowInvariants(def) {
3746
3831
  }), checkEffectNames(def, issues), checkGuardNames(def, issues), checkFieldEntryNames(def, issues),
3747
3832
  checkRequiredField(def, issues), checkStart(def, issues), checkPredicates(def, issues),
3748
3833
  checkUnboundConditionVars(def, issues), checkConditionFieldReads(def, issues), checkFieldReadSeeds(def, issues),
3749
- checkFieldReadOpValues(def, issues), checkUpdateWhereOps(def, issues), checkGuardFieldReads(def, issues),
3834
+ checkFieldReadOpValues(def, issues), checkFieldTargetOps(def, issues), checkGuardFieldReads(def, issues),
3750
3835
  checkAssigneesEntries(def, issues), checkDueDateEntries(def, issues), checkSubjectEntries(def, issues),
3751
3836
  checkLevelKindEffectOutputs(def, issues), checkActivityTerminalPaths(def, issues),
3752
3837
  checkTerminalStageActivities(def, issues), checkTriggeredActionParams(def, issues),
@@ -3787,6 +3872,8 @@ exports.CONDITION_VARS = CONDITION_VARS;
3787
3872
 
3788
3873
  exports.ContractViolationError = ContractViolationError;
3789
3874
 
3875
+ exports.DECISION_SEMANTICS = DECISION_SEMANTICS;
3876
+
3790
3877
  exports.DEFAULT_TRANSITION_WHEN = DEFAULT_TRANSITION_WHEN;
3791
3878
 
3792
3879
  exports.DOCUMENT_VALUE_PERMISSIONS = DOCUMENT_VALUE_PERMISSIONS;
@@ -3837,6 +3924,8 @@ exports.RESERVED_CONDITION_VARS = RESERVED_CONDITION_VARS;
3837
3924
 
3838
3925
  exports.RESOURCE_ALIAS_NAME_SOURCE = RESOURCE_ALIAS_NAME_SOURCE;
3839
3926
 
3927
+ exports.SIGNAL_SEMANTICS = SIGNAL_SEMANTICS;
3928
+
3840
3929
  exports.START_FILTER_VARS = START_FILTER_VARS;
3841
3930
 
3842
3931
  exports.START_REQUIREMENT_VARS = START_REQUIREMENT_VARS;
@@ -3921,6 +4010,8 @@ exports.groq = groq;
3921
4010
 
3922
4011
  exports.groupMembershipNames = groupMembershipNames;
3923
4012
 
4013
+ exports.isAlwaysArrayFieldKind = isAlwaysArrayFieldKind;
4014
+
3924
4015
  exports.isBareSeedId = isBareSeedId;
3925
4016
 
3926
4017
  exports.isCascadeFired = isCascadeFired;