@beyondwork/docx-react-component 1.0.82 → 1.0.84

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.
@@ -204,55 +204,64 @@ function collectGuardVerdict(
204
204
  }
205
205
 
206
206
  const guard = runtime.getInteractionGuardSnapshot();
207
- if (
208
- guard.effectiveMode === "view" &&
209
- !blockedReasons.includes("guard:view-mode-active")
210
- ) {
211
- blockedReasons.push("guard:view-mode-active");
207
+ // Scope-targeted writes target `scope.handle.scopeId`, not the current
208
+ // editor selection. The target scope's own overlay posture was handled
209
+ // above through `scope.workflow.effectiveMode`; this second guard read is
210
+ // only allowed to contribute global/session-wide blockers.
211
+ const isSelectionScopeMembershipReason = (reason: { readonly code: string; readonly scopeId?: string }): boolean => {
212
+ if (reason.code === "outside_workflow_scope") return true;
213
+ if (
214
+ (reason.code === "workflow_view_only" ||
215
+ reason.code === "workflow_comment_only") &&
216
+ typeof reason.scopeId === "string" &&
217
+ reason.scopeId.length > 0
218
+ ) {
219
+ return true;
220
+ }
221
+ return false;
222
+ };
223
+ const rawReasons = guard.blockedReasons ?? [];
224
+ const nonSelectionScoped = rawReasons.filter(
225
+ (r) => !isSelectionScopeMembershipReason(r),
226
+ );
227
+ const pushTypedGuardBlocker = (code: string | undefined): void => {
228
+ const suffix = typeof code === "string" && code.length > 0
229
+ ? code
230
+ : "unspecified";
231
+ const typedBlocker = `guard:block-${suffix}`;
232
+ if (!blockedReasons.some((existing) => existing === typedBlocker)) {
233
+ blockedReasons.push(typedBlocker);
234
+ }
235
+ };
236
+ if (guard.effectiveMode === "view") {
237
+ const globalViewReason = nonSelectionScoped.find(
238
+ (reason) => reason.code === "workflow_view_only",
239
+ );
240
+ if (globalViewReason) {
241
+ pushTypedGuardBlocker(globalViewReason.code);
242
+ } else if (
243
+ rawReasons.length === 0 &&
244
+ !blockedReasons.includes("guard:view-mode-active")
245
+ ) {
246
+ blockedReasons.push("guard:view-mode-active");
247
+ }
248
+ }
249
+ if (guard.effectiveMode === "comment") {
250
+ const globalCommentReason = nonSelectionScoped.find(
251
+ (reason) => reason.code === "workflow_comment_only",
252
+ );
253
+ if (globalCommentReason) {
254
+ pushTypedGuardBlocker(globalCommentReason.code);
255
+ }
212
256
  }
213
257
  if (guard.effectiveMode === "blocked") {
214
258
  // Coord-06 §13e — promote the bare `guard:blocked` blocker to a typed
215
- // `guard:block-<reason>` suffix so agents can route intelligently on
216
- // boundary-paragraph / system-paragraph / read-only / protected-range
217
- // situations. The specific sub-reason is the first NON-selection-
218
- // scope-membership reason.
219
- //
220
- // Scope-targeted-write carve-out (coord-09, TemplateViewer repro
221
- // 2026-04-24): `applyReplacementScope`, `attachExplanation`, and
222
- // `createIssue` target a scopeId, not the current editor selection.
223
- // The scope's own `workflow.effectiveMode` already drove the
224
- // scope-level arm of `collectGuardVerdict` above (lines 159–197).
225
- // The selection-scoped coordinator guard, in contrast, evaluates
226
- // against the live `state.selection` — which, for scope-targeted
227
- // writes, may sit anywhere in the document. Reasons that depend on
228
- // selection-scope membership (`outside_workflow_scope`,
229
- // `workflow_view_only`, `workflow_comment_only`) are therefore
230
- // double-counting and must not block. Globally-scoped reasons
231
- // (`document_read_only`, `document_viewing_mode`) still apply — a
232
- // read-only doc rejects every write, scope-targeted or not.
233
- const SELECTION_SCOPE_MEMBERSHIP_CODES = new Set([
234
- "outside_workflow_scope",
235
- "workflow_view_only",
236
- "workflow_comment_only",
237
- ]);
238
- const rawReasons = guard.blockedReasons ?? [];
239
- const nonSelectionScoped = rawReasons.filter(
240
- (r) => !SELECTION_SCOPE_MEMBERSHIP_CODES.has(r.code),
241
- );
242
- // If every reason was selection-scope-membership for a scope-
243
- // targeted write, emit no blocker — the scope-level arm above is
244
- // authoritative. The defensive empty-array fallback
245
- // (guard:block-unspecified) still fires when the coordinator
246
- // produced effectiveMode:"blocked" without any reasons at all.
259
+ // `guard:block-<reason>` suffix. Selection-membership reasons are
260
+ // intentionally ignored here; global/session reasons such as read-only,
261
+ // protected ranges, shared workflow locks, and unsupported suggesting
262
+ // commands remain blockers.
247
263
  if (nonSelectionScoped.length > 0 || rawReasons.length === 0) {
248
- const primaryCode = nonSelectionScoped[0]?.code;
249
- const suffix = typeof primaryCode === "string" && primaryCode.length > 0
250
- ? primaryCode
251
- : "unspecified";
252
- const typedBlocker = `guard:block-${suffix}`;
253
- if (!blockedReasons.some((existing) => existing === typedBlocker)) {
254
- blockedReasons.push(typedBlocker);
255
- }
264
+ pushTypedGuardBlocker(nonSelectionScoped[0]?.code);
256
265
  }
257
266
  }
258
267
  for (const reason of guard.blockedReasons ?? []) {
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Given a scope's canonical range and the workflow overlay, returns the
5
5
  * `SemanticScopeWorkflow` projection: the ids of overlay scopes overlapping
6
- * the range + the most-restrictive `effectiveMode` across them +
7
- * `blockedReasons` when a `view`-mode overlap blocks the scope.
6
+ * the range + the most-restrictive enforced `effectiveMode` across scopes
7
+ * whose `guardPolicy` participates in editing + `blockedReasons` when a
8
+ * read-only overlap blocks the scope.
8
9
  *
9
10
  * The most-restrictive rule matches layer 06's `InteractionGuardSnapshot`
10
- * composition (see `docs/architecture/06-workflow-review.md` §W3):
11
+ * composition for guard-participating scopes (see
12
+ * `docs/architecture/06-workflow-review.md` §W3):
11
13
  * view > comment > suggest > edit
12
14
  *
13
15
  * When no overlay is threaded, or no overlap exists, the returned shape
@@ -19,7 +21,11 @@
19
21
  * overlap on top.
20
22
  */
21
23
 
22
- import type { WorkflowOverlay, WorkflowScope } from "./_scope-dependencies.ts";
24
+ import type {
25
+ WorkflowOverlay,
26
+ WorkflowScope,
27
+ WorkflowScopeGuardPolicy,
28
+ } from "./_scope-dependencies.ts";
23
29
 
24
30
  import type { ScopePositionMap, ScopePositionRange } from "./position-map.ts";
25
31
  import { rangesOverlap } from "./scope-range.ts";
@@ -27,6 +33,14 @@ import type { SemanticScopeWorkflow } from "./semantic-scope-types.ts";
27
33
 
28
34
  type WorkflowMode = "edit" | "suggest" | "comment" | "view";
29
35
 
36
+ function getScopeGuardPolicy(scope: WorkflowScope): WorkflowScopeGuardPolicy {
37
+ return scope.guardPolicy ?? (scope.mode === "view" ? "read-only" : "none");
38
+ }
39
+
40
+ function getScopeGuardMode(scope: WorkflowScope): WorkflowMode {
41
+ return getScopeGuardPolicy(scope) === "read-only" ? "view" : scope.mode;
42
+ }
43
+
30
44
  function modeRank(mode: WorkflowMode): number {
31
45
  // Higher = more restrictive (wins the merge).
32
46
  switch (mode) {
@@ -45,6 +59,21 @@ function mergeModes(a: WorkflowMode, b: WorkflowMode): WorkflowMode {
45
59
  return modeRank(a) >= modeRank(b) ? a : b;
46
60
  }
47
61
 
62
+ function rangeForWorkflowScope(
63
+ scope: WorkflowScope,
64
+ positionMap: ScopePositionMap,
65
+ ): ScopePositionRange | null {
66
+ const markerRange = positionMap.markerScopes.get(scope.scopeId);
67
+ if (markerRange) return markerRange;
68
+ if (scope.anchor.kind === "range") {
69
+ return { from: scope.anchor.from, to: scope.anchor.to };
70
+ }
71
+ if (scope.anchor.kind === "node") {
72
+ return { from: scope.anchor.at, to: scope.anchor.at };
73
+ }
74
+ return null;
75
+ }
76
+
48
77
  export interface WorkflowOverlapInputs {
49
78
  readonly overlay: WorkflowOverlay | null | undefined;
50
79
  readonly positionMap: ScopePositionMap;
@@ -73,12 +102,15 @@ export function resolveWorkflowOverlap(
73
102
  let mode: WorkflowMode = "edit";
74
103
  for (const scope of overlay.scopes as readonly WorkflowScope[]) {
75
104
  if (selfScopeIds && selfScopeIds.has(scope.scopeId)) continue;
76
- const markerRange = positionMap.markerScopes.get(scope.scopeId);
77
- if (!markerRange) continue;
78
- if (!rangesOverlap(range, markerRange)) continue;
105
+ const scopeRange = rangeForWorkflowScope(scope, positionMap);
106
+ if (!scopeRange) continue;
107
+ if (!rangesOverlap(range, scopeRange)) continue;
79
108
  overlappingIds.push(scope.scopeId);
80
- mode = mergeModes(mode, scope.mode);
81
- if (scope.mode === "view") {
109
+ const guardPolicy = getScopeGuardPolicy(scope);
110
+ if (guardPolicy === "none") continue;
111
+ const guardMode = getScopeGuardMode(scope);
112
+ mode = mergeModes(mode, guardMode);
113
+ if (guardMode === "view") {
82
114
  blockedReasons.push(`workflow-scope-view:${scope.scopeId}`);
83
115
  }
84
116
  }
@@ -64,6 +64,7 @@ import type {
64
64
  WorkflowMetadataSnapshot,
65
65
  WorkflowOverlay,
66
66
  WorkflowScope,
67
+ WorkflowScopeGuardPolicy,
67
68
  WorkflowScopeMode,
68
69
  WorkflowScopeSnapshot,
69
70
  } from "../../api/public-types.ts";
@@ -227,6 +228,11 @@ export interface WorkflowCoordinator {
227
228
  addInvisibleScope(params: AddScopeParams): AddScopeResult;
228
229
  setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
229
230
  getScopeVisibility(scopeId: string): ScopeVisibility;
231
+ setScopeGuardPolicy(
232
+ scopeId: string,
233
+ guardPolicy: WorkflowScopeGuardPolicy,
234
+ ): void;
235
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy;
230
236
  getScope(scopeId: string): WorkflowScope | null;
231
237
  getMarkerBackedScopeIds(): ReadonlySet<string>;
232
238
  /* --- scope chrome visibility (local view state) --- */
@@ -316,6 +322,18 @@ const MODE_RESTRICTIVENESS: Record<WorkflowScopeMode, number> = {
316
322
  view: 3,
317
323
  };
318
324
 
325
+ function resolveScopeGuardPolicy(scope: WorkflowScope): WorkflowScopeGuardPolicy {
326
+ return scope.guardPolicy ?? (scope.mode === "view" ? "read-only" : "none");
327
+ }
328
+
329
+ function getScopeGuardMode(scope: WorkflowScope): WorkflowScopeMode {
330
+ return resolveScopeGuardPolicy(scope) === "read-only" ? "view" : scope.mode;
331
+ }
332
+
333
+ function participatesInInteractionGuard(scope: WorkflowScope): boolean {
334
+ return resolveScopeGuardPolicy(scope) !== "none";
335
+ }
336
+
319
337
  export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordinator {
320
338
  const { overlayStore, clock } = deps;
321
339
 
@@ -413,8 +431,7 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
413
431
  };
414
432
  const activeScopes = getEffectiveWorkflowScopes(overlay);
415
433
  const matching = activeScopes.filter((scope) => {
416
- // §C8: invisible non-view scopes are transparent to the guard.
417
- if (scope.visibility === "invisible" && scope.mode !== "view") return false;
434
+ if (!participatesInInteractionGuard(scope)) return false;
418
435
  if (scope.anchor.kind === "detached") return false;
419
436
  const scopeFrom =
420
437
  scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
@@ -450,8 +467,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
450
467
  if (stack.length === 0) return null;
451
468
  // §C6 — most-restrictive-wins across overlapping scopes.
452
469
  return stack.reduce((best, scope) =>
453
- (MODE_RESTRICTIVENESS[scope.mode] ?? 0) >
454
- (MODE_RESTRICTIVENESS[best.mode] ?? 0)
470
+ (MODE_RESTRICTIVENESS[getScopeGuardMode(scope)] ?? 0) >
471
+ (MODE_RESTRICTIVENESS[getScopeGuardMode(best)] ?? 0)
455
472
  ? scope
456
473
  : best,
457
474
  );
@@ -463,7 +480,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
463
480
  const mode = deps.getDocumentMode();
464
481
  if (mode === "viewing" || mode === "commenting") return mode;
465
482
  const matchingScope = getMatchingWorkflowScope(selection);
466
- if (matchingScope?.mode === "suggest") return "suggesting";
483
+ if (matchingScope && getScopeGuardMode(matchingScope) === "suggest") {
484
+ return "suggesting";
485
+ }
467
486
  return mode;
468
487
  }
469
488
 
@@ -536,17 +555,18 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
536
555
  if (normalized) {
537
556
  const matchingScope = getMatchingWorkflowScope(selection);
538
557
  const activeScopes = getEffectiveWorkflowScopes(normalized);
539
- const guardingScopes = activeScopes.filter(
540
- (s) => !(s.visibility === "invisible" && s.mode !== "view"),
558
+ const insertOnlyScopes = activeScopes.filter(
559
+ (s) => resolveScopeGuardPolicy(s) === "insert-only",
541
560
  );
542
561
 
543
- if (!matchingScope && guardingScopes.length > 0) {
562
+ if (!matchingScope && insertOnlyScopes.length > 0) {
544
563
  reasons.push({
545
564
  code: "outside_workflow_scope",
546
- message: "Selection is outside any active workflow scope.",
565
+ message: "Selection is outside any active insert-only workflow scope.",
547
566
  });
548
567
  } else if (matchingScope) {
549
- if (matchingScope.mode === "comment") {
568
+ const guardMode = getScopeGuardMode(matchingScope);
569
+ if (guardMode === "comment") {
550
570
  const isCommentCommand = commandType?.startsWith("comment.") ?? false;
551
571
  if (!isCommentCommand) {
552
572
  reasons.push({
@@ -556,7 +576,7 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
556
576
  workItemId: matchingScope.workItemId,
557
577
  });
558
578
  }
559
- } else if (matchingScope.mode === "view") {
579
+ } else if (guardMode === "view") {
560
580
  reasons.push({
561
581
  code: "workflow_view_only",
562
582
  message: `Scope "${matchingScope.label ?? matchingScope.scopeId}" is view-only.`,
@@ -618,6 +638,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
618
638
  const matchingScope = getMatchingWorkflowScope(state.selection);
619
639
  const scopeStack = buildMatchingScopeStack(state.selection);
620
640
  const primaryBlockedReason = blockedReasons[0];
641
+ const matchingGuardMode = matchingScope
642
+ ? getScopeGuardMode(matchingScope)
643
+ : undefined;
621
644
  const effectiveMode = primaryBlockedReason
622
645
  ? primaryBlockedReason.code === "workflow_comment_only"
623
646
  ? "comment"
@@ -626,19 +649,19 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
626
649
  : "blocked"
627
650
  : getEffectiveDocumentMode(state.selection) === "suggesting"
628
651
  ? "suggest"
629
- : matchingScope?.mode ?? "edit";
652
+ : matchingGuardMode ?? "edit";
630
653
  const matchedScopeStack: InteractionGuardSnapshot["matchedScopeStack"] =
631
654
  scopeStack.length > 0
632
655
  ? scopeStack.map((s) => ({
633
656
  scopeId: s.scopeId,
634
- mode: s.mode,
657
+ mode: getScopeGuardMode(s),
635
658
  visibility: s.visibility ?? "visible",
636
659
  }))
637
660
  : undefined;
638
661
  const snapshot: InteractionGuardSnapshot = {
639
662
  effectiveMode,
640
663
  ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
641
- ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
664
+ ...(matchingGuardMode ? { matchedScopeMode: matchingGuardMode } : {}),
642
665
  ...(matchedScopeStack ? { matchedScopeStack } : {}),
643
666
  targetAccess:
644
667
  effectiveMode === "edit"
@@ -888,6 +911,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
888
911
  anchor: publicAnchor,
889
912
  ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
890
913
  ...(params.label ? { label: params.label } : {}),
914
+ ...(params.visibility ? { visibility: params.visibility } : {}),
915
+ ...(params.guardPolicy ? { guardPolicy: params.guardPolicy } : {}),
891
916
  ...(params.scopeMetadataFields && params.scopeMetadataFields.length > 0
892
917
  ? { metadata: [...params.scopeMetadataFields] }
893
918
  : {}),
@@ -1004,6 +1029,31 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
1004
1029
  return scope?.visibility ?? "visible";
1005
1030
  }
1006
1031
 
1032
+ function setScopeGuardPolicy(
1033
+ scopeId: string,
1034
+ guardPolicy: WorkflowScopeGuardPolicy,
1035
+ ): void {
1036
+ const overlay = overlayStore.getOverlay();
1037
+ if (!overlay) return;
1038
+ const idx = overlay.scopes.findIndex((s) => s.scopeId === scopeId);
1039
+ if (idx === -1) return;
1040
+ const nextScopes = overlay.scopes.map((s) =>
1041
+ s.scopeId === scopeId ? { ...s, guardPolicy } : s,
1042
+ );
1043
+ deps.dispatch({
1044
+ type: "workflow.set-overlay",
1045
+ overlay: { ...overlay, scopes: nextScopes },
1046
+ origin: { source: "api", at: clock() },
1047
+ });
1048
+ }
1049
+
1050
+ function getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy {
1051
+ const overlay = overlayStore.getOverlay();
1052
+ if (!overlay) return "none";
1053
+ const scope = overlay.scopes.find((s) => s.scopeId === scopeId);
1054
+ return scope ? resolveScopeGuardPolicy(scope) : "none";
1055
+ }
1056
+
1007
1057
  function getScope(scopeId: string): WorkflowScope | null {
1008
1058
  const normalized = getNormalizedOverlay();
1009
1059
  const fromOverlay = normalized?.scopes.find((s) => s.scopeId === scopeId);
@@ -1360,6 +1410,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
1360
1410
  addInvisibleScope,
1361
1411
  setScopeVisibility,
1362
1412
  getScopeVisibility,
1413
+ setScopeGuardPolicy,
1414
+ getScopeGuardPolicy,
1363
1415
  getScope,
1364
1416
  getMarkerBackedScopeIds: () => overlayStore.getMarkerBackedScopeIds(),
1365
1417
  setScopeChromeVisibility,
@@ -37,6 +37,8 @@ import type {
37
37
  EditorAnchorProjection,
38
38
  EditorStoryTarget,
39
39
  RuntimeRenderSnapshot,
40
+ ScopeVisibility,
41
+ WorkflowScopeGuardPolicy,
40
42
  WorkflowMetadataEntry,
41
43
  WorkflowMetadataPersistence,
42
44
  WorkflowScopeMetadataField,
@@ -62,6 +64,8 @@ export interface CreateScopeFromBlockIdInput {
62
64
  readonly persistence?: WorkflowMetadataPersistence;
63
65
  readonly metadata?: Partial<WorkflowMetadataEntry>;
64
66
  readonly storyTarget?: EditorStoryTarget;
67
+ readonly visibility?: ScopeVisibility;
68
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
65
69
  /**
66
70
  * Coord-06 §13d — per-scope edge stickiness for the range anchor.
67
71
  * Defaults to `{ start: 1, end: -1 }` (greedy — absorbs boundary
@@ -98,7 +102,23 @@ export interface CreateScopeFromBlockIdInput {
98
102
 
99
103
  export type CreateScopeFromBlockIdResult =
100
104
  | { readonly status: "created"; readonly scopeId: string; readonly anchor: EditorAnchorProjection }
101
- | { readonly status: "block-not-found"; readonly blockId: string };
105
+ | { readonly status: "block-not-found"; readonly blockId: string }
106
+ | {
107
+ readonly status: "range-invalid";
108
+ readonly scopeId: "";
109
+ readonly reason:
110
+ | "range-exceeds-story-length"
111
+ | "non-paragraph-target"
112
+ | "empty-document";
113
+ readonly from: number;
114
+ readonly to: number;
115
+ readonly storyLength: number;
116
+ /** Non-paragraph target only — the offending block's index and kind. */
117
+ readonly blockIndex?: number;
118
+ readonly blockKind?: string;
119
+ readonly message: string;
120
+ readonly nextStep: string;
121
+ };
102
122
 
103
123
  function inlineLength(node: InlineNode): number {
104
124
  switch (node.type) {
@@ -190,8 +210,9 @@ export function createScopeFromBlockId(
190
210
  runtime: ScopeWriterRuntime,
191
211
  input: CreateScopeFromBlockIdInput,
192
212
  ): CreateScopeFromBlockIdResult {
213
+ const document = runtime.getCanonicalDocument();
193
214
  const anchor = resolveBlockAnchorFromCanonical(
194
- runtime.getCanonicalDocument(),
215
+ document,
195
216
  input.blockId,
196
217
  input.assoc,
197
218
  );
@@ -216,10 +237,63 @@ export function createScopeFromBlockId(
216
237
  metadata: input.metadata,
217
238
  storyTarget: input.storyTarget,
218
239
  label: input.label,
240
+ ...(input.visibility ? { visibility: input.visibility } : {}),
241
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
219
242
  ...(scopeMetadataFields.length > 0
220
243
  ? { scopeMetadataFields }
221
244
  : {}),
222
245
  });
246
+ if (result.plantStatus && result.plantStatus.planted === false) {
247
+ const ps = result.plantStatus;
248
+ const from = anchor.kind === "range" ? anchor.from : 0;
249
+ const to = anchor.kind === "range" ? anchor.to : from;
250
+ const storyLength = ps.storyLength ?? computeMainStoryLength(document);
251
+ if (ps.reason === "non-paragraph-target") {
252
+ return {
253
+ status: "range-invalid",
254
+ scopeId: "",
255
+ reason: "non-paragraph-target",
256
+ from,
257
+ to,
258
+ storyLength,
259
+ blockIndex: ps.blockIndex ?? -1,
260
+ blockKind: ps.blockKind ?? "unknown",
261
+ message:
262
+ `createScope refused blockId "${input.blockId}": it resolves to a ` +
263
+ `${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
264
+ `Marker-backed scopes only plant inside paragraphs today; choose a ` +
265
+ `paragraph block or create an overlay-only scope for table/SDT metadata.`,
266
+ nextStep: "pick-a-paragraph-block-or-use-overlay-only-scope",
267
+ };
268
+ }
269
+ if (ps.reason === "range-out-of-bounds") {
270
+ return {
271
+ status: "range-invalid",
272
+ scopeId: "",
273
+ reason: "range-exceeds-story-length",
274
+ from,
275
+ to,
276
+ storyLength,
277
+ message:
278
+ `createScope refused blockId "${input.blockId}": resolved range ` +
279
+ `[${from}, ${to}] exceeds the current story length (${storyLength}). ` +
280
+ `Re-query block ids from the current render snapshot before retrying.`,
281
+ nextStep: "re-query-current-block-id",
282
+ };
283
+ }
284
+ return {
285
+ status: "range-invalid",
286
+ scopeId: "",
287
+ reason: "empty-document",
288
+ from,
289
+ to,
290
+ storyLength,
291
+ message:
292
+ `createScope refused blockId "${input.blockId}": the target document ` +
293
+ `has no blocks, so scope markers cannot be planted.`,
294
+ nextStep: "initialize-document-before-creating-scopes",
295
+ };
296
+ }
223
297
  return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
224
298
  }
225
299
 
@@ -251,6 +325,8 @@ export interface CreateScopeFromAnchorInput {
251
325
  readonly scopeId?: string;
252
326
  readonly persistence?: WorkflowMetadataPersistence;
253
327
  readonly metadata?: Partial<WorkflowMetadataEntry>;
328
+ readonly visibility?: ScopeVisibility;
329
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
254
330
  /**
255
331
  * Per-scope edge stickiness for the range anchor. Defaults to
256
332
  * `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts). See
@@ -423,6 +499,8 @@ export function createScopeFromAnchor(
423
499
  metadata: input.metadata,
424
500
  storyTarget,
425
501
  label: input.label,
502
+ ...(input.visibility ? { visibility: input.visibility } : {}),
503
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
426
504
  ...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
427
505
  });
428
506
 
@@ -448,9 +526,9 @@ export function createScopeFromAnchor(
448
526
  message:
449
527
  `createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
450
528
  `${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
451
- `Marker scopes only plant inside paragraphs today. Pick a paragraph ` +
452
- `target, or use runtime.workflow.createScope({blockId}) for ` +
453
- `whole-block scopes on the containing structure.`,
529
+ `Marker-backed scopes only plant inside paragraphs today. Pick a ` +
530
+ `paragraph target, or create an overlay-only scope for table/SDT ` +
531
+ `metadata until structural marker support lands.`,
454
532
  nextStep: "pick-a-paragraph-target",
455
533
  };
456
534
  }
@@ -1289,6 +1289,8 @@ function createLoadingRuntimeBridge(input: {
1289
1289
  addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
1290
1290
  setScopeVisibility: () => undefined,
1291
1291
  getScopeVisibility: () => "visible" as const,
1292
+ setScopeGuardPolicy: () => undefined,
1293
+ getScopeGuardPolicy: () => "none" as const,
1292
1294
  setScopeChromeVisibility: () => undefined,
1293
1295
  getScopeChromeVisibility: () => ({ mode: "all" as const }),
1294
1296
  subscribeToScopeQuery: (_filter, _callback) => () => undefined,