@sanity/workflow-engine 0.8.0 → 0.9.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/dist/index.cjs CHANGED
@@ -155,11 +155,11 @@ function scopedStateOverlay(instance, snapshot, taskName) {
155
155
  const stageState = renderedState(stageEntry.state ?? [], snapshot), task = taskName ? stageEntry.tasks.find((t) => t.name === taskName) : void 0;
156
156
  return { ...stageState, ...renderedState(task?.state ?? [], snapshot) };
157
157
  }
158
- function assignedFor(instance, taskName, actor) {
158
+ function assignedFor(instance, taskName, actor, roleAliases) {
159
159
  if (actor === void 0) return !1;
160
160
  const entry = findOpenStageEntry(instance)?.tasks.find((t) => t.name === taskName)?.state?.find((s) => s._type === "assignees");
161
161
  return entry === void 0 ? !1 : entry.value.some(
162
- (a) => a.type === "user" ? a.id === actor.id : (actor.roles ?? []).includes(a.role)
162
+ (a) => a.type === "user" ? a.id === actor.id : schema.actorFulfillsRole(actor.roles, a.role, roleAliases)
163
163
  );
164
164
  }
165
165
  function effectsContextMap(instance) {
@@ -678,6 +678,17 @@ function isRevisionConflict(error) {
678
678
  const { statusCode, message } = error;
679
679
  return statusCode === 409 ? !0 : typeof message == "string" && message.includes("ifRevisionId check failed");
680
680
  }
681
+ class ConcurrentEditStateError extends Error {
682
+ instanceId;
683
+ target;
684
+ attempts;
685
+ constructor(args) {
686
+ const where = args.target.task !== void 0 ? `${args.target.task}.${args.target.state}` : args.target.state;
687
+ super(
688
+ `Edit of "${args.target.scope}:${where}" (instance ${args.instanceId}) lost the optimistic-locking race ${args.attempts} attempts running \u2014 a concurrent writer kept committing first. Re-read and retry, or investigate a write storm on this instance.`
689
+ ), this.name = "ConcurrentEditStateError", this.instanceId = args.instanceId, this.target = args.target, this.attempts = args.attempts;
690
+ }
691
+ }
681
692
  class CascadeLimitError extends Error {
682
693
  instanceId;
683
694
  limit;
@@ -1297,6 +1308,12 @@ function coerceToGdr(raw, workflowResource) {
1297
1308
  function gdrToBareId(uri) {
1298
1309
  return uri.includes(":") ? extractDocumentId(uri) : uri;
1299
1310
  }
1311
+ function loadCallContext(client, instanceId, options) {
1312
+ return loadContext(client, instanceId, {
1313
+ ...options?.clock ? { clock: options.clock } : {},
1314
+ ...options?.clientForGdr ? { clientForGdr: options.clientForGdr } : {}
1315
+ });
1316
+ }
1300
1317
  async function loadContext(client, instanceId, options) {
1301
1318
  const instance = await client.getDocument(instanceId);
1302
1319
  if (!instance)
@@ -1317,7 +1334,7 @@ async function ctxConditionParams(ctx, opts) {
1317
1334
  ...base,
1318
1335
  state,
1319
1336
  actor: opts?.actor,
1320
- assigned: opts?.taskName !== void 0 ? assignedFor(ctx.instance, opts.taskName, opts?.actor) : !1,
1337
+ assigned: opts?.taskName !== void 0 ? assignedFor(ctx.instance, opts.taskName, opts?.actor, ctx.definition.roleAliases) : !1,
1321
1338
  ...opts?.vars
1322
1339
  };
1323
1340
  return { ...await evaluatePredicates({
@@ -2048,22 +2065,27 @@ function runOps(args) {
2048
2065
  const summaries = [];
2049
2066
  for (const op of ops) {
2050
2067
  const summary = applyOp(op, { mutation, stage, taskName: origin.task, params, actor, self, now });
2051
- summaries.push(summary), mutation.history.push({
2052
- _key: randomKey(),
2053
- _type: "opApplied",
2054
- at: now,
2055
- stage,
2056
- ...origin.task !== void 0 ? { task: origin.task } : {},
2057
- ...origin.action !== void 0 ? { action: origin.action } : {},
2058
- ...origin.transition !== void 0 ? { transition: origin.transition } : {},
2059
- opType: summary.opType,
2060
- ...summary.target !== void 0 ? { target: summary.target } : {},
2061
- ...summary.resolved !== void 0 ? { resolved: summary.resolved } : {},
2062
- ...actor !== void 0 ? { actor } : {}
2063
- });
2068
+ summaries.push(summary), mutation.history.push(opAppliedEntry({ origin, summary, stage, actor, now }));
2064
2069
  }
2065
2070
  return summaries;
2066
2071
  }
2072
+ function opAppliedEntry(args) {
2073
+ const { origin, summary, stage, actor, now } = args;
2074
+ return {
2075
+ _key: randomKey(),
2076
+ _type: "opApplied",
2077
+ at: now,
2078
+ stage,
2079
+ ...origin.task !== void 0 ? { task: origin.task } : {},
2080
+ ...origin.action !== void 0 ? { action: origin.action } : {},
2081
+ ...origin.transition !== void 0 ? { transition: origin.transition } : {},
2082
+ ...origin.edit === !0 ? { edit: !0 } : {},
2083
+ opType: summary.opType,
2084
+ ...summary.target !== void 0 ? { target: summary.target } : {},
2085
+ ...summary.resolved !== void 0 ? { resolved: summary.resolved } : {},
2086
+ ...actor !== void 0 ? { actor } : {}
2087
+ };
2088
+ }
2067
2089
  function applyOp(op, ctx) {
2068
2090
  switch (op.type) {
2069
2091
  case "state.set":
@@ -2697,6 +2719,85 @@ async function fetchGrantsCached(requestFn, resourcePath) {
2697
2719
  return;
2698
2720
  }
2699
2721
  }
2722
+ function effectiveEditable(baseline, override) {
2723
+ if (baseline === void 0) return;
2724
+ if (override === void 0) return baseline;
2725
+ if (baseline === !0 && override === !0) return !0;
2726
+ const parts = [baseline, override].map((e) => e === !0 ? void 0 : e);
2727
+ return schema.andConditions(parts) ?? !0;
2728
+ }
2729
+ function slotSites(definition, stage) {
2730
+ const sites = [];
2731
+ for (const entry of definition.state ?? []) sites.push({ scope: "workflow", entry });
2732
+ for (const entry of stage.state ?? []) sites.push({ scope: "stage", entry });
2733
+ for (const task of stage.tasks ?? [])
2734
+ for (const entry of task.state ?? []) sites.push({ scope: "task", task: task.name, entry });
2735
+ return sites;
2736
+ }
2737
+ function toResolvedSlot(site, stage) {
2738
+ return {
2739
+ scope: site.scope,
2740
+ ...site.task !== void 0 ? { task: site.task } : {},
2741
+ name: site.entry.name,
2742
+ type: site.entry.type,
2743
+ ...site.entry.title !== void 0 ? { title: site.entry.title } : {},
2744
+ ref: { scope: site.scope, state: site.entry.name },
2745
+ effective: effectiveEditable(site.entry.editable, stage.editable?.[site.entry.name])
2746
+ };
2747
+ }
2748
+ function editableSlotsInStage(definition, stage) {
2749
+ return slotSites(definition, stage).filter((site) => site.entry.editable !== void 0).map((site) => toResolvedSlot(site, stage));
2750
+ }
2751
+ function editTargetMatches(slot, target) {
2752
+ return slot.name !== target.state ? !1 : target.task !== void 0 ? slot.scope === "task" && slot.task === target.task : target.scope !== void 0 ? slot.scope === target.scope : !0;
2753
+ }
2754
+ const SCOPE_PRECEDENCE = { task: 0, stage: 1, workflow: 2 };
2755
+ function resolveEditTarget(args) {
2756
+ const { definition, stage, target } = args, found = slotSites(definition, stage).filter(
2757
+ (site) => editTargetMatches(
2758
+ {
2759
+ scope: site.scope,
2760
+ name: site.entry.name,
2761
+ ...site.task !== void 0 ? { task: site.task } : {}
2762
+ },
2763
+ target
2764
+ )
2765
+ ).sort((a, b) => SCOPE_PRECEDENCE[a.scope] - SCOPE_PRECEDENCE[b.scope])[0];
2766
+ return found ? toResolvedSlot(found, stage) : void 0;
2767
+ }
2768
+ function slotWindowOpen(instance, slot) {
2769
+ if (instance.completedAt !== void 0) return { open: !1, detail: "instance completed" };
2770
+ if (instance.abortedAt !== void 0) return { open: !1, detail: "instance aborted" };
2771
+ if (slot.scope !== "task") return { open: !0 };
2772
+ const status = findOpenStageEntry(instance)?.tasks.find((t) => t.name === slot.task)?.status;
2773
+ return status === "active" ? { open: !0 } : { open: !1, detail: `task "${slot.task}" is ${status ?? "not active"}` };
2774
+ }
2775
+ function readSlotValue(instance, slot) {
2776
+ return slotStateHost(instance, slot)?.find((e) => e.name === slot.name)?.value;
2777
+ }
2778
+ function slotStateHost(instance, slot) {
2779
+ if (slot.scope === "workflow") return instance.state;
2780
+ const stageEntry = findOpenStageEntry(instance);
2781
+ return slot.scope === "stage" ? stageEntry?.state : stageEntry?.tasks.find((t) => t.name === slot.task)?.state;
2782
+ }
2783
+ function slotProvenance(instance, ref) {
2784
+ let latest;
2785
+ for (const entry of instance.history)
2786
+ entry._type === "opApplied" && (entry.target?.scope !== ref.scope || entry.target.state !== ref.state || (latest = { at: entry.at, ...entry.actor !== void 0 ? { actor: entry.actor } : {} }));
2787
+ return latest === void 0 ? {} : {
2788
+ ...latest.actor !== void 0 ? { setBy: latest.actor } : {},
2789
+ setAt: latest.at
2790
+ };
2791
+ }
2792
+ function editDisabledReason(args) {
2793
+ const { effective, instance, window, guardDenial, predicateSatisfied } = args;
2794
+ if (effective === void 0) return { kind: "not-editable" };
2795
+ if (!window.open)
2796
+ return instance.completedAt !== void 0 ? { kind: "instance-completed", completedAt: instance.completedAt } : { kind: "edit-window-closed", detail: window.detail ?? "closed" };
2797
+ if (guardDenial !== void 0) return guardDenial;
2798
+ if (!predicateSatisfied)
2799
+ return { kind: "editor-not-permitted", predicate: effective === !0 ? "true" : effective };
2800
+ }
2700
2801
  async function evaluateInstance(args) {
2701
2802
  const { client, tag, instanceId, resourceClients } = args, now = (args.clock ?? wallClock)();
2702
2803
  validateTag(tag);
@@ -2730,6 +2831,7 @@ async function evaluateFromSnapshot(args) {
2730
2831
  actor,
2731
2832
  snapshot,
2732
2833
  scope,
2834
+ roleAliases: definition.roleAliases,
2733
2835
  stageHasExits: !isTerminalStage(stage),
2734
2836
  guardDenial
2735
2837
  })
@@ -2751,16 +2853,65 @@ async function evaluateFromSnapshot(args) {
2751
2853
  stage,
2752
2854
  tasks: taskEvaluations,
2753
2855
  transitions: transitionEvaluations
2754
- }, pendingOnYou = taskEvaluations.filter((t) => t.pendingOnActor), canInteract = taskEvaluations.some((t) => t.actions.some((a) => a.allowed));
2856
+ }, pendingOnYou = taskEvaluations.filter((t) => t.pendingOnActor), canInteract = taskEvaluations.some((t) => t.actions.some((a) => a.allowed)), editGuardDenial = guardDenial?.kind === "mutation-guard-denied" ? guardDenial : void 0, editableSlots = await evaluateEditableSlots({
2857
+ instance,
2858
+ definition,
2859
+ stage,
2860
+ actor,
2861
+ snapshot,
2862
+ scope,
2863
+ guardDenial: editGuardDenial
2864
+ });
2755
2865
  return {
2756
2866
  instance,
2757
2867
  definition,
2758
2868
  actor,
2759
2869
  currentStage,
2760
2870
  pendingOnYou,
2761
- canInteract
2871
+ canInteract,
2872
+ editableSlots
2762
2873
  };
2763
2874
  }
2875
+ async function evaluateEditableSlots(args) {
2876
+ const { instance, definition, stage, actor, snapshot, scope, guardDenial } = args, slots = [];
2877
+ for (const slot of editableSlotsInStage(definition, stage)) {
2878
+ const window = slotWindowOpen(instance, slot), predicateSatisfied = await editPredicateSatisfied({
2879
+ slot,
2880
+ window,
2881
+ guardDenial,
2882
+ instance,
2883
+ snapshot,
2884
+ scope,
2885
+ actor,
2886
+ roleAliases: definition.roleAliases
2887
+ }), reason = editDisabledReason({
2888
+ effective: slot.effective,
2889
+ instance,
2890
+ window,
2891
+ guardDenial,
2892
+ predicateSatisfied
2893
+ }), value = readSlotValue(instance, slot);
2894
+ slots.push({
2895
+ scope: slot.scope,
2896
+ ...slot.task !== void 0 ? { task: slot.task } : {},
2897
+ name: slot.name,
2898
+ type: slot.type,
2899
+ ...slot.title !== void 0 ? { title: slot.title } : {},
2900
+ value,
2901
+ editable: reason === void 0,
2902
+ ...reason !== void 0 ? { disabledReason: reason } : {},
2903
+ ...slotProvenance(instance, slot.ref)
2904
+ });
2905
+ }
2906
+ return slots;
2907
+ }
2908
+ async function editPredicateSatisfied(args) {
2909
+ const { slot, window, guardDenial, instance, snapshot, scope, actor, roleAliases } = args;
2910
+ if (!window.open || guardDenial !== void 0) return !1;
2911
+ if (slot.effective === !0) return !0;
2912
+ const params = slot.scope === "task" && slot.task !== void 0 ? taskScopeFor({ scope, instance, snapshot, actor, taskName: slot.task, roleAliases }) : scope;
2913
+ return evaluateCondition({ condition: slot.effective, snapshot, params });
2914
+ }
2764
2915
  async function renderScope(args) {
2765
2916
  const { instance, definition, actor, grants, snapshot, now } = args, params = {
2766
2917
  ...buildParams({ instance, now, snapshot }),
@@ -2786,15 +2937,36 @@ async function advisoryCan(instance, actor, grants) {
2786
2937
  });
2787
2938
  return can;
2788
2939
  }
2789
- async function evaluateTask(args) {
2790
- const { task, statusEntry, instance, actor, snapshot, scope, stageHasExits, guardDenial } = args, status = statusEntry?.status ?? "pending", assigned = assignedFor(instance, task.name, actor), taskScope = {
2940
+ function taskScopeFor(args) {
2941
+ const { scope, instance, snapshot, actor, taskName, roleAliases } = args;
2942
+ return {
2791
2943
  ...scope,
2792
2944
  state: {
2793
2945
  ...scope.state,
2794
- ...scopedStateOverlay(instance, snapshot, task.name)
2946
+ ...scopedStateOverlay(instance, snapshot, taskName)
2795
2947
  },
2796
- assigned
2797
- }, unmetRequirements = await evaluateRequirements({
2948
+ assigned: assignedFor(instance, taskName, actor, roleAliases)
2949
+ };
2950
+ }
2951
+ async function evaluateTask(args) {
2952
+ const {
2953
+ task,
2954
+ statusEntry,
2955
+ instance,
2956
+ actor,
2957
+ snapshot,
2958
+ scope,
2959
+ roleAliases,
2960
+ stageHasExits,
2961
+ guardDenial
2962
+ } = args, status = statusEntry?.status ?? "pending", taskScope = taskScopeFor({
2963
+ scope,
2964
+ instance,
2965
+ snapshot,
2966
+ actor,
2967
+ taskName: task.name,
2968
+ roleAliases
2969
+ }), assigned = taskScope.assigned === !0, unmetRequirements = await evaluateRequirements({
2798
2970
  requirements: task.requirements,
2799
2971
  snapshot,
2800
2972
  params: taskScope
@@ -3021,10 +3193,7 @@ async function commitAbort(ctx, reason, actor) {
3021
3193
  async function fireAction(args) {
3022
3194
  const { client, instanceId, task, action, params, options } = args;
3023
3195
  for (let attempt = 1; attempt <= CONCURRENT_FIRE_ACTION_MAX_ATTEMPTS; attempt++) {
3024
- const ctx = await loadContext(client, instanceId, {
3025
- ...options?.clock ? { clock: options.clock } : {},
3026
- ...options?.clientForGdr ? { clientForGdr: options.clientForGdr } : {}
3027
- });
3196
+ const ctx = await loadCallContext(client, instanceId, options);
3028
3197
  try {
3029
3198
  return await commitAction(ctx, task, action, params, options);
3030
3199
  } catch (error) {
@@ -3119,6 +3288,114 @@ function formatDisabledReason(task, action, reason) {
3119
3288
  const detail = disabledReasonDetail[reason.kind](reason);
3120
3289
  return `Action "${task}:${action}" is not allowed: ${detail}`;
3121
3290
  }
3291
+ class EditStateDeniedError extends Error {
3292
+ reason;
3293
+ target;
3294
+ constructor(args) {
3295
+ super(formatEditDisabledReason(args.target, args.reason)), this.name = "EditStateDeniedError", this.reason = args.reason, this.target = args.target;
3296
+ }
3297
+ }
3298
+ const editDisabledReasonDetail = {
3299
+ "not-editable": () => "slot is not declared editable",
3300
+ "instance-completed": (r) => `instance completed at ${r.completedAt}`,
3301
+ "edit-window-closed": (r) => `edit window closed (${r.detail})`,
3302
+ "editor-not-permitted": (r) => `editor not permitted (${r.predicate})`,
3303
+ "mutation-guard-denied": (r) => `blocked by mutation guard(s) [${r.guardIds.join(", ")}]${r.detail ? ` (${r.detail})` : ""}`
3304
+ };
3305
+ function formatEditDisabledReason(target, reason) {
3306
+ const detail = editDisabledReasonDetail[reason.kind](
3307
+ reason
3308
+ ), where = target.task !== void 0 ? `${target.task}.${target.state}` : target.state;
3309
+ return `State "${target.scope}:${where}" is not editable: ${detail}`;
3310
+ }
3311
+ async function editState(args) {
3312
+ const { client, instanceId, target, mode = "set", value, options } = args;
3313
+ for (let attempt = 1; attempt <= CONCURRENT_FIRE_ACTION_MAX_ATTEMPTS; attempt++) {
3314
+ const ctx = await loadCallContext(client, instanceId, options);
3315
+ try {
3316
+ return await commitEdit(ctx, target, mode, value, options);
3317
+ } catch (error) {
3318
+ if (!isRevisionConflict(error)) throw error;
3319
+ }
3320
+ }
3321
+ throw new ConcurrentEditStateError({
3322
+ instanceId,
3323
+ target: labelTarget(target),
3324
+ attempts: CONCURRENT_FIRE_ACTION_MAX_ATTEMPTS
3325
+ });
3326
+ }
3327
+ async function commitEdit(ctx, target, mode, value, options) {
3328
+ const actor = options?.actor, stage = findStage(ctx.definition, ctx.instance.currentStage), slot = resolveEditTarget({ definition: ctx.definition, stage, target });
3329
+ if (slot === void 0)
3330
+ throw new Error(
3331
+ `No state slot "${describeTarget(target)}" resolves in current stage "${stage.name}" of ${ctx.definition.name}`
3332
+ );
3333
+ await assertSlotEditable(ctx, slot, options);
3334
+ const op = buildEditOp(slot, mode, value), mutation = startMutation(ctx.instance), ranOps = runOps({
3335
+ ops: [op],
3336
+ mutation,
3337
+ stage: stage.name,
3338
+ origin: {
3339
+ edit: !0,
3340
+ ...slot.scope === "task" && slot.task !== void 0 ? { task: slot.task } : {}
3341
+ },
3342
+ params: {},
3343
+ actor,
3344
+ self: selfGdr(ctx.instance),
3345
+ now: ctx.now
3346
+ });
3347
+ return await persistThenDeploy(
3348
+ ctx,
3349
+ mutation,
3350
+ () => ranOps.some(isStateOp) ? refreshStageGuards(ctx, mutation, stage.name) : Promise.resolve()
3351
+ ), {
3352
+ edited: !0,
3353
+ target: slotTarget(slot),
3354
+ ...ranOps.length > 0 ? { ranOps } : {}
3355
+ };
3356
+ }
3357
+ async function assertSlotEditable(ctx, slot, options) {
3358
+ const actor = options?.actor, window = slotWindowOpen(ctx.instance, slot), can = options?.grants !== void 0 && actor !== void 0 ? await advisoryCan(ctx.instance, actor, options.grants) : void 0, predicateSatisfied = window.open && slot.effective !== !0 && slot.effective !== void 0 ? await ctxEvaluateCondition(ctx, slot.effective, {
3359
+ ...slot.task !== void 0 ? { taskName: slot.task } : {},
3360
+ ...actor !== void 0 ? { actor } : {},
3361
+ ...can !== void 0 ? { vars: { can } } : {}
3362
+ }) : slot.effective === !0, reason = editDisabledReason({
3363
+ effective: slot.effective,
3364
+ instance: ctx.instance,
3365
+ window,
3366
+ guardDenial: void 0,
3367
+ predicateSatisfied
3368
+ });
3369
+ if (reason !== void 0) throw new EditStateDeniedError({ target: slotTarget(slot), reason });
3370
+ }
3371
+ function buildEditOp(slot, mode, value) {
3372
+ if (mode === "unset") return { type: "state.unset", target: slot.ref };
3373
+ if (value === void 0)
3374
+ throw new Error(`editState mode "${mode}" requires a value for slot "${slot.name}"`);
3375
+ return {
3376
+ type: mode === "append" ? "state.append" : "state.set",
3377
+ target: slot.ref,
3378
+ value: { type: "literal", value }
3379
+ };
3380
+ }
3381
+ function slotTarget(slot) {
3382
+ return {
3383
+ scope: slot.scope,
3384
+ state: slot.name,
3385
+ ...slot.task !== void 0 ? { task: slot.task } : {}
3386
+ };
3387
+ }
3388
+ function labelTarget(target) {
3389
+ return {
3390
+ scope: target.scope ?? (target.task !== void 0 ? "task" : "workflow"),
3391
+ state: target.state,
3392
+ ...target.task !== void 0 ? { task: target.task } : {}
3393
+ };
3394
+ }
3395
+ function describeTarget(target) {
3396
+ const where = target.task !== void 0 ? `${target.task}.${target.state}` : target.state;
3397
+ return target.scope !== void 0 ? `${target.scope}:${where}` : where;
3398
+ }
3122
3399
  function bareIdFromSpawnRef(uri) {
3123
3400
  if (!uri.includes(":")) return [uri];
3124
3401
  try {
@@ -3165,18 +3442,60 @@ function buildClientForGdr(defaultClient, resolver) {
3165
3442
  function toEffectsContextEntries(ctx) {
3166
3443
  return Object.entries(ctx).map(([id, value]) => effectsContextEntry(id, value));
3167
3444
  }
3445
+ async function dispatchGatedWrite(args) {
3446
+ const { client, tag, instanceId, resourceClients, access, apply } = args, evaluation = await evaluateInstance({
3447
+ client,
3448
+ tag,
3449
+ instanceId,
3450
+ access,
3451
+ clock: args.clock,
3452
+ ...resourceClients !== void 0 ? { resourceClients } : {}
3453
+ }), ranOps = await apply(args.before, evaluation), cascaded = await cascade(client, instanceId, args.actor, args.clientForGdr, args.clock);
3454
+ return {
3455
+ instance: await reload(client, instanceId, tag),
3456
+ cascaded,
3457
+ fired: !0,
3458
+ ...ranOps !== void 0 ? { ranOps } : {}
3459
+ };
3460
+ }
3168
3461
  function assertActionAllowed(evaluation, task, action) {
3169
3462
  const actionEval = evaluation.currentStage.tasks.find((t) => t.task.name === task)?.actions.find((a) => a.action.name === action);
3170
3463
  if (actionEval?.allowed === !1 && actionEval.disabledReason)
3171
3464
  throw new ActionDisabledError({ task, action, reason: actionEval.disabledReason });
3172
3465
  }
3173
- async function applyAction(args) {
3174
- const { client, instance, task, action, params, actor, grants, clock, clientForGdr } = args, options = {
3175
- actor,
3176
- ...grants !== void 0 ? { grants } : {},
3177
- ...clock !== void 0 ? { clock } : {},
3178
- ...clientForGdr !== void 0 ? { clientForGdr } : {}
3466
+ function assertEditAllowed(evaluation, target) {
3467
+ const slot = evaluation.editableSlots.filter((s) => editTargetMatches(s, target)).sort((a, b) => SCOPE_PRECEDENCE[a.scope] - SCOPE_PRECEDENCE[b.scope])[0];
3468
+ if (slot !== void 0 && !slot.editable && slot.disabledReason !== void 0)
3469
+ throw new EditStateDeniedError({
3470
+ target: {
3471
+ scope: slot.scope,
3472
+ state: slot.name,
3473
+ ...slot.task !== void 0 ? { task: slot.task } : {}
3474
+ },
3475
+ reason: slot.disabledReason
3476
+ });
3477
+ }
3478
+ async function applyEdit(args) {
3479
+ const { client, instance, target, mode, value, actor, grants, clock, clientForGdr } = args;
3480
+ return (await editState({
3481
+ client,
3482
+ instanceId: instance._id,
3483
+ target,
3484
+ ...mode !== void 0 ? { mode } : {},
3485
+ ...value !== void 0 ? { value } : {},
3486
+ options: buildEngineCallOptions({ actor, grants, clock, clientForGdr })
3487
+ })).ranOps;
3488
+ }
3489
+ function buildEngineCallOptions(args) {
3490
+ return {
3491
+ actor: args.actor,
3492
+ ...args.grants !== void 0 ? { grants: args.grants } : {},
3493
+ ...args.clock !== void 0 ? { clock: args.clock } : {},
3494
+ ...args.clientForGdr !== void 0 ? { clientForGdr: args.clientForGdr } : {}
3179
3495
  };
3496
+ }
3497
+ async function applyAction(args) {
3498
+ const { client, instance, task, action, params, actor, grants, clock, clientForGdr } = args, options = buildEngineCallOptions({ actor, grants, clock, clientForGdr });
3180
3499
  return findOpenStageEntry(instance)?.tasks.find((t) => t.name === task)?.status === "pending" && await invokeTask({ client, instanceId: instance._id, task, options }), (await fireAction({
3181
3500
  client,
3182
3501
  instanceId: instance._id,
@@ -3392,34 +3711,68 @@ const workflow = {
3392
3711
  idempotent,
3393
3712
  resourceClients
3394
3713
  } = args, { access, actor, clientForGdr } = await resolveOperationContext(args), clock = args.clock ?? wallClock, before = await reload(client, instanceId, tag);
3395
- if (findOpenStageEntry(before)?.tasks.find((t) => t.name === task) === void 0 && idempotent === !0)
3396
- return { instance: before, cascaded: 0, fired: !1 };
3397
- const evaluation = await evaluateInstance({
3714
+ return findOpenStageEntry(before)?.tasks.find((t) => t.name === task) === void 0 && idempotent === !0 ? { instance: before, cascaded: 0, fired: !1 } : dispatchGatedWrite({
3398
3715
  client,
3399
3716
  tag,
3400
3717
  instanceId,
3401
3718
  access,
3719
+ actor,
3720
+ clientForGdr,
3402
3721
  clock,
3403
- ...resourceClients !== void 0 ? { resourceClients } : {}
3722
+ before,
3723
+ ...resourceClients !== void 0 ? { resourceClients } : {},
3724
+ apply: (instance, evaluation) => (assertActionAllowed(evaluation, task, action), applyAction({
3725
+ client,
3726
+ instance,
3727
+ task,
3728
+ action,
3729
+ actor,
3730
+ ...access.grants !== void 0 ? { grants: access.grants } : {},
3731
+ clock,
3732
+ clientForGdr,
3733
+ ...params !== void 0 ? { params } : {}
3734
+ }))
3404
3735
  });
3405
- assertActionAllowed(evaluation, task, action);
3406
- const ranOps = await applyAction({
3736
+ },
3737
+ /**
3738
+ * Edit a declared-editable state slot directly — reassign, reschedule,
3739
+ * claim-by-hand, append to a running log — through the generic edit seam,
3740
+ * instead of a bespoke action per field. Soft-gates on the slot's declared
3741
+ * editability (the same projection a UI renders), applies the edit as a
3742
+ * `state.*` op (so provenance + history are stamped by the op path),
3743
+ * refreshes the stage's guards, then cascades — an edit to a value a
3744
+ * transition reads can and should move the instance. Advisory like every
3745
+ * engine gate; the lake ACL + guards are the only hard locks.
3746
+ *
3747
+ * Each call is a discrete COMMIT (a history entry, a guard refresh, a
3748
+ * cascade, an `ifRevisionId` write), not a draft patch — so an inline-field
3749
+ * UI must bind it to a deliberate boundary (blur / Enter / Save / debounce),
3750
+ * never an `onChange` per keystroke.
3751
+ */
3752
+ editState: async (args) => {
3753
+ const { client, tag, instanceId, target, mode, value, resourceClients } = args, { access, actor, clientForGdr } = await resolveOperationContext(args), clock = args.clock ?? wallClock, before = await reload(client, instanceId, tag);
3754
+ return dispatchGatedWrite({
3407
3755
  client,
3408
- instance: before,
3409
- task,
3410
- action,
3756
+ tag,
3757
+ instanceId,
3758
+ access,
3411
3759
  actor,
3412
- ...access.grants !== void 0 ? { grants: access.grants } : {},
3413
- clock,
3414
3760
  clientForGdr,
3415
- ...params !== void 0 ? { params } : {}
3416
- }), cascaded = await cascade(client, instanceId, actor, clientForGdr, clock);
3417
- return {
3418
- instance: await reload(client, instanceId, tag),
3419
- cascaded,
3420
- fired: !0,
3421
- ...ranOps !== void 0 ? { ranOps } : {}
3422
- };
3761
+ clock,
3762
+ before,
3763
+ ...resourceClients !== void 0 ? { resourceClients } : {},
3764
+ apply: (instance, evaluation) => (assertEditAllowed(evaluation, target), applyEdit({
3765
+ client,
3766
+ instance,
3767
+ target,
3768
+ ...mode !== void 0 ? { mode } : {},
3769
+ ...value !== void 0 ? { value } : {},
3770
+ actor,
3771
+ ...access.grants !== void 0 ? { grants: access.grants } : {},
3772
+ clock,
3773
+ clientForGdr
3774
+ }))
3775
+ });
3423
3776
  },
3424
3777
  /**
3425
3778
  * Report a queued effect's outcome. Drains it from `pendingEffects`,
@@ -3837,6 +4190,9 @@ function createInstanceSession(args) {
3837
4190
  ...clock !== void 0 ? { now: clock() } : {},
3838
4191
  ...grants !== void 0 ? { grants } : {}
3839
4192
  });
4193
+ }, settleAfterApply = async (actor, held, ranOps) => {
4194
+ const cascaded = await cascade(client, instance._id, actor, clientForGdr, clock, held);
4195
+ return instance = await reload(client, instance._id, tag), { instance, cascaded, fired: !0, ...ranOps !== void 0 ? { ranOps } : {} };
3840
4196
  }, commit = async (run) => {
3841
4197
  committing = !0;
3842
4198
  const held = new Map(overlay);
@@ -3887,8 +4243,25 @@ function createInstanceSession(args) {
3887
4243
  ...grants !== void 0 ? { grants } : {},
3888
4244
  ...clock !== void 0 ? { clock } : {},
3889
4245
  ...params !== void 0 ? { params } : {}
3890
- }), cascaded = await cascade(client, instance._id, actor, clientForGdr, clock, held);
3891
- return instance = await reload(client, instance._id, tag), { instance, cascaded, fired: !0, ...ranOps !== void 0 ? { ranOps } : {} };
4246
+ });
4247
+ return settleAfterApply(actor, held, ranOps);
4248
+ });
4249
+ },
4250
+ editState({ target, mode, value }) {
4251
+ return commit(async (actor, held) => {
4252
+ assertEditAllowed(await evaluateWith(held, heldGuards), target);
4253
+ const { grants } = await access(), ranOps = await applyEdit({
4254
+ client,
4255
+ instance,
4256
+ target,
4257
+ ...mode !== void 0 ? { mode } : {},
4258
+ ...value !== void 0 ? { value } : {},
4259
+ actor,
4260
+ clientForGdr,
4261
+ ...grants !== void 0 ? { grants } : {},
4262
+ ...clock !== void 0 ? { clock } : {}
4263
+ });
4264
+ return settleAfterApply(actor, held, ranOps);
3892
4265
  });
3893
4266
  }
3894
4267
  };
@@ -3936,6 +4309,7 @@ function createEngine(args) {
3936
4309
  deployDefinitions: (rest) => workflow.deployDefinitions(bind(rest)),
3937
4310
  startInstance: (rest) => workflow.startInstance(bind(rest)),
3938
4311
  fireAction: (rest) => workflow.fireAction(bind(rest)),
4312
+ editState: (rest) => workflow.editState(bind(rest)),
3939
4313
  completeEffect: (rest) => workflow.completeEffect(bind(rest)),
3940
4314
  tick: (rest) => workflow.tick(bind(rest)),
3941
4315
  evaluateInstance: (rest) => evaluateInstance(bind(rest)),
@@ -4232,10 +4606,12 @@ exports.AUTHORING_DISPLAY = AUTHORING_DISPLAY;
4232
4606
  exports.ActionDisabledError = ActionDisabledError;
4233
4607
  exports.ActionParamsInvalidError = ActionParamsInvalidError;
4234
4608
  exports.CascadeLimitError = CascadeLimitError;
4609
+ exports.ConcurrentEditStateError = ConcurrentEditStateError;
4235
4610
  exports.ConcurrentFireActionError = ConcurrentFireActionError;
4236
4611
  exports.DEFAULT_CONTENT_PERSPECTIVE = DEFAULT_CONTENT_PERSPECTIVE;
4237
4612
  exports.DISPLAY = DISPLAY;
4238
4613
  exports.EFFECTS_CONTEXT_DISPLAY = EFFECTS_CONTEXT_DISPLAY;
4614
+ exports.EditStateDeniedError = EditStateDeniedError;
4239
4615
  exports.GUARD_DOC_TYPE = GUARD_DOC_TYPE;
4240
4616
  exports.GUARD_LIFTED_PREDICATE = GUARD_LIFTED_PREDICATE;
4241
4617
  exports.GUARD_OWNER = GUARD_OWNER;