@beyondwork/docx-react-component 1.0.83 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.83",
4
+ "version": "1.0.84",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -2512,6 +2512,16 @@ export interface AddScopeParams {
2512
2512
  storyTarget?: EditorStoryTarget;
2513
2513
  /** Optional display label for the scope card / rail. */
2514
2514
  label?: string;
2515
+ /**
2516
+ * Optional per-scope visibility. Controls chrome/query presentation only;
2517
+ * edit enforcement is controlled by `guardPolicy`.
2518
+ */
2519
+ visibility?: ScopeVisibility;
2520
+ /**
2521
+ * Optional edit-enforcement posture. Absent means advisory for
2522
+ * edit/suggest/comment scopes and read-only for `mode: "view"`.
2523
+ */
2524
+ guardPolicy?: WorkflowScopeGuardPolicy;
2515
2525
  }
2516
2526
 
2517
2527
  export interface AddScopeResult {
@@ -2591,6 +2601,19 @@ export interface ExportResult {
2591
2601
 
2592
2602
  export type WorkflowScopeMode = "edit" | "suggest" | "comment" | "view";
2593
2603
 
2604
+ /**
2605
+ * Explicit scope enforcement axis. Visibility is presentation-only.
2606
+ *
2607
+ * - `"none"`: advisory/metadata/chrome scope; direct editing is unaffected.
2608
+ * - `"insert-only"`: allowlist scope; when any active insert-only scope
2609
+ * exists, selection-driven edits outside insert-only scopes are blocked.
2610
+ * - `"read-only"`: deny edits inside this scope; outside content is unaffected.
2611
+ *
2612
+ * When absent, `mode: "view"` scopes default to `"read-only"` for
2613
+ * compatibility; all other modes default to `"none"`.
2614
+ */
2615
+ export type WorkflowScopeGuardPolicy = "none" | "insert-only" | "read-only";
2616
+
2594
2617
  /**
2595
2618
  * §C7 — Local chrome visibility mode. Never collab-replicated.
2596
2619
  * - `"all"`: show all scope rail entries + decorations (default).
@@ -2615,8 +2638,8 @@ export interface ScopeChromeVisibilityState {
2615
2638
  *
2616
2639
  * - `"visible"`: rail entry + card + inline decoration (current behavior).
2617
2640
  * - `"hidden"`: in the rail but muted; card opens on explicit click; no decoration.
2618
- * - `"invisible"`: never rendered; only queryable via API. Does NOT contribute
2619
- * to InteractionGuard unless `mode === "view"` (explicit host opt-in).
2641
+ * - `"invisible"`: never rendered; only queryable via API. Visibility never
2642
+ * contributes to InteractionGuard.
2620
2643
  */
2621
2644
  export type ScopeVisibility = "visible" | "hidden" | "invisible";
2622
2645
 
@@ -2639,6 +2662,12 @@ export interface WorkflowScope {
2639
2662
  domain?: "legal" | "commercial" | "finance" | "other";
2640
2663
  metadataRefs?: string[];
2641
2664
  metadata?: WorkflowScopeMetadataField[];
2665
+ /**
2666
+ * Explicit edit-enforcement posture. Absent means:
2667
+ * - `mode: "view"` -> `"read-only"` (legacy read-only behavior).
2668
+ * - all other modes -> `"none"` (advisory/chrome/metadata only).
2669
+ */
2670
+ guardPolicy?: WorkflowScopeGuardPolicy;
2642
2671
  /**
2643
2672
  * Schema 1.1 — override the overlay default for this scope.
2644
2673
  * `"inherit"` defers to the overlay; absent is equivalent to
@@ -4571,8 +4600,9 @@ export interface WordReviewEditorRef {
4571
4600
  removeScope(scopeId: string): void;
4572
4601
  /**
4573
4602
  * §C8 — Convenience: adds a scope with `visibility: "invisible"` atomically.
4574
- * Mode defaults to `"comment"` invisible scopes carry no InteractionGuard
4575
- * constraint unless `mode: "view"` is passed explicitly.
4603
+ * Mode defaults to `"comment"`. Visibility is presentation-only; edit
4604
+ * gating is controlled by `guardPolicy` (`mode: "view"` still defaults to
4605
+ * read-only for compatibility unless `guardPolicy: "none"` is set).
4576
4606
  */
4577
4607
  addInvisibleScope(
4578
4608
  params: Omit<AddScopeParams, "mode"> & { mode?: WorkflowScopeMode },
@@ -4587,6 +4617,20 @@ export interface WordReviewEditorRef {
4587
4617
  * scopes or when the `visibility` field has never been set.
4588
4618
  */
4589
4619
  getScopeVisibility(scopeId: string): ScopeVisibility;
4620
+ /**
4621
+ * Set a scope's edit-enforcement posture. Collab-replicated through the
4622
+ * workflow overlay; visibility remains purely presentational.
4623
+ */
4624
+ setScopeGuardPolicy(
4625
+ scopeId: string,
4626
+ guardPolicy: WorkflowScopeGuardPolicy,
4627
+ ): void;
4628
+ /**
4629
+ * Get a scope's effective guard policy. Returns `"read-only"` for legacy
4630
+ * `mode: "view"` scopes with no explicit policy and `"none"` for unknown
4631
+ * scopes.
4632
+ */
4633
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy;
4590
4634
  /**
4591
4635
  * §C7 — Set the local chrome-visibility state. Local view-state only —
4592
4636
  * never collab-replicated. Controls whether the scope rail / decorations
@@ -70,6 +70,8 @@ export type RuntimeApiHandle = Pick<
70
70
  // live seam. `getWorkflowMetadataSnapshot` is the read side the
71
71
  // metadata writer inspects before merging its entry.
72
72
  | "addScope"
73
+ | "setScopeGuardPolicy"
74
+ | "getScopeGuardPolicy"
73
75
  | "setWorkflowMetadataEntries"
74
76
  | "getWorkflowMetadataSnapshot"
75
77
  // W10 overlay-visibility policy (state-classes X1). Class-A canonical
@@ -155,6 +157,8 @@ export const RUNTIME_API_HANDLE_SHAPE_CHECK: Record<keyof RuntimeApiHandle, true
155
157
  getInteractionGuardSnapshot: true,
156
158
  getWorkflowOverlay: true,
157
159
  addScope: true,
160
+ setScopeGuardPolicy: true,
161
+ getScopeGuardPolicy: true,
158
162
  setWorkflowMetadataEntries: true,
159
163
  getWorkflowMetadataSnapshot: true,
160
164
  getVisibilityPolicy: true,
@@ -2,7 +2,8 @@
2
2
  * @endStateApi v3 — `runtime.workflow` family.
3
3
  *
4
4
  * queryScopes (live) / getMarkup (live) / getGuard (live) /
5
- * createScope (live-with-adapter) / attachMetadata (live-with-adapter) /
5
+ * createScope (live-with-adapter) / setScopeGuardPolicy (live) /
6
+ * attachMetadata (live-with-adapter) /
6
7
  * getVisibilityPolicy · getVisibilityPolicies · setVisibilityPolicy ·
7
8
  * clearVisibilityPolicy (live — W10 state-classes X1) /
8
9
  * scopeTags (live — coord-10 L11-4 tag-catalog read).
@@ -14,6 +15,8 @@ import type {
14
15
  EditorStoryTarget,
15
16
  OverlayKind,
16
17
  OverlayVisibilityPolicy,
18
+ ScopeVisibility,
19
+ WorkflowScopeGuardPolicy,
17
20
  WorkflowMarkupModePolicy,
18
21
  } from "../../public-types.ts";
19
22
  import { emitUxResponse } from "../_ux-response.ts";
@@ -82,6 +85,13 @@ export interface CreateScopeInput {
82
85
  readonly blockId: string;
83
86
  readonly mode?: "edit" | "suggest" | "comment" | "view";
84
87
  readonly label?: string;
88
+ /** Per-scope visibility. Controls chrome/query presentation only. */
89
+ readonly visibility?: ScopeVisibility;
90
+ /**
91
+ * Explicit edit-enforcement posture. Use `"insert-only"` for
92
+ * template-slot allowlists; use `"read-only"` to lock the scope range.
93
+ */
94
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
85
95
  /**
86
96
  * Coord-06 §13d — per-scope edge stickiness. Defaults to
87
97
  * `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts, the
@@ -112,10 +122,24 @@ export interface CreateScopeInput {
112
122
  | "runtime-handle";
113
123
  }
114
124
 
115
- export interface CreateScopeResult {
116
- readonly scopeId: string;
117
- readonly status: "created" | "block-not-found";
118
- }
125
+ export type CreateScopeResult =
126
+ | { readonly scopeId: string; readonly status: "created" }
127
+ | { readonly scopeId: ""; readonly status: "block-not-found" }
128
+ | {
129
+ readonly scopeId: "";
130
+ readonly status: "range-invalid";
131
+ readonly reason:
132
+ | "range-exceeds-story-length"
133
+ | "non-paragraph-target"
134
+ | "empty-document";
135
+ readonly from: number;
136
+ readonly to: number;
137
+ readonly storyLength: number;
138
+ readonly blockIndex?: number;
139
+ readonly blockKind?: string;
140
+ readonly message: string;
141
+ readonly nextStep: string;
142
+ };
119
143
 
120
144
  /**
121
145
  * Input for `createScopeFromAnchor` — sub-block marker-backed scope.
@@ -144,6 +168,8 @@ export interface CreateScopeFromAnchorInput {
144
168
  };
145
169
  readonly mode?: "edit" | "suggest" | "comment" | "view";
146
170
  readonly label?: string;
171
+ readonly visibility?: ScopeVisibility;
172
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
147
173
  readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
148
174
  readonly stableRefHint?:
149
175
  | "scope-id"
@@ -174,6 +200,22 @@ export type CreateScopeFromAnchorResult =
174
200
  readonly nextStep: string;
175
201
  };
176
202
 
203
+ export interface SetScopeGuardPolicyInput {
204
+ readonly scopeId: string;
205
+ readonly guardPolicy: WorkflowScopeGuardPolicy;
206
+ }
207
+
208
+ export type SetScopeGuardPolicyResult =
209
+ | {
210
+ readonly status: "updated";
211
+ readonly scopeId: string;
212
+ readonly guardPolicy: WorkflowScopeGuardPolicy;
213
+ }
214
+ | {
215
+ readonly status: "scope-not-found";
216
+ readonly scopeId: string;
217
+ };
218
+
177
219
  export const createScopeMetadata: ApiV3FnMetadata = {
178
220
  name: "runtime.workflow.createScope",
179
221
  status: "live-with-adapter",
@@ -216,6 +258,51 @@ export const createScopeFromAnchorMetadata: ApiV3FnMetadata = {
216
258
  "§Runtime API § runtime.workflow.createScopeFromAnchor. Companion to createScope({blockId}) for sub-block ranges. Consumes from/to once to plant inline scope_marker_start/end; the returned scopeId is the durable reference (KI-P9).",
217
259
  };
218
260
 
261
+ export const getScopeGuardPolicyMetadata: ApiV3FnMetadata = {
262
+ name: "runtime.workflow.getScopeGuardPolicy",
263
+ status: "live",
264
+ sourceLayer: "workflow-review",
265
+ liveEvidence: {
266
+ runnerTest: "test/api/v3/workflow-create-scope-live.test.ts",
267
+ commit: "guard-policy-setter",
268
+ },
269
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
270
+ agentMetadata: {
271
+ readOrMutate: "read",
272
+ boundedScope: "scope",
273
+ auditCategory: "scope-policy",
274
+ },
275
+ stateClass: "A-canonical",
276
+ persistsTo: "customXml",
277
+ broadcastsVia: "crdt",
278
+ rwdReference: "§Runtime API § runtime.workflow.getScopeGuardPolicy",
279
+ };
280
+
281
+ export const setScopeGuardPolicyMetadata: ApiV3FnMetadata = {
282
+ name: "runtime.workflow.setScopeGuardPolicy",
283
+ status: "live",
284
+ sourceLayer: "workflow-review",
285
+ liveEvidence: {
286
+ runnerTest: "test/api/v3/workflow-create-scope-live.test.ts",
287
+ commit: "guard-policy-setter",
288
+ },
289
+ uxIntent: {
290
+ uiVisible: true,
291
+ expectsUxResponse: "inline-change",
292
+ expectedDelta:
293
+ "scope edit-enforcement policy updates and interaction guard recalculates",
294
+ },
295
+ agentMetadata: {
296
+ readOrMutate: "mutate",
297
+ boundedScope: "scope",
298
+ auditCategory: "scope-policy",
299
+ },
300
+ stateClass: "A-canonical",
301
+ persistsTo: "customXml",
302
+ broadcastsVia: "crdt",
303
+ rwdReference: "§Runtime API § runtime.workflow.setScopeGuardPolicy",
304
+ };
305
+
219
306
  export interface AttachMetadataInput {
220
307
  readonly scopeId: string;
221
308
  readonly metadataId: string;
@@ -476,6 +563,8 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
476
563
  blockId: input.blockId,
477
564
  mode: input.mode,
478
565
  label: input.label,
566
+ ...(input.visibility ? { visibility: input.visibility } : {}),
567
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
479
568
  ...(input.assoc ? { assoc: input.assoc } : {}),
480
569
  ...(input.stableRefHint
481
570
  ? { stableRefHint: input.stableRefHint }
@@ -493,11 +582,68 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
493
582
  : undefined,
494
583
  });
495
584
  if (adapterResult.status !== "created") {
496
- return { scopeId: "", status: adapterResult.status };
585
+ if (adapterResult.status === "block-not-found") {
586
+ return { scopeId: "", status: "block-not-found" };
587
+ }
588
+ return {
589
+ scopeId: "",
590
+ status: "range-invalid",
591
+ reason: adapterResult.reason,
592
+ from: adapterResult.from,
593
+ to: adapterResult.to,
594
+ storyLength: adapterResult.storyLength,
595
+ ...(adapterResult.blockIndex !== undefined
596
+ ? { blockIndex: adapterResult.blockIndex }
597
+ : {}),
598
+ ...(adapterResult.blockKind !== undefined
599
+ ? { blockKind: adapterResult.blockKind }
600
+ : {}),
601
+ message: adapterResult.message,
602
+ nextStep: adapterResult.nextStep,
603
+ };
497
604
  }
498
605
  return { scopeId: adapterResult.scopeId, status: "created" };
499
606
  },
500
607
 
608
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy {
609
+ // @endStateApi — live.
610
+ return runtime.getScopeGuardPolicy(scopeId);
611
+ },
612
+
613
+ setScopeGuardPolicy(
614
+ input: SetScopeGuardPolicyInput,
615
+ ): SetScopeGuardPolicyResult {
616
+ // @endStateApi — live. Mutates the scope's explicit guard axis in the
617
+ // workflow overlay. Visibility stays presentation-only.
618
+ const overlay = runtime.getWorkflowOverlay();
619
+ const existingScope = overlay?.scopes.find(
620
+ (scope) => scope.scopeId === input.scopeId,
621
+ );
622
+ if (!existingScope) {
623
+ return { status: "scope-not-found", scopeId: input.scopeId };
624
+ }
625
+ runtime.setScopeGuardPolicy(input.scopeId, input.guardPolicy);
626
+ emitUxResponse(runtime, {
627
+ apiFn: setScopeGuardPolicyMetadata.name,
628
+ intent: setScopeGuardPolicyMetadata.uxIntent.expectedDelta ?? "",
629
+ mockOrLive: "live",
630
+ uiVisible: true,
631
+ expectedDelta: setScopeGuardPolicyMetadata.uxIntent.expectedDelta,
632
+ actualDelta: {
633
+ kind: "inline-change",
634
+ payload: {
635
+ scopeId: input.scopeId,
636
+ guardPolicy: input.guardPolicy,
637
+ },
638
+ },
639
+ });
640
+ return {
641
+ status: "updated",
642
+ scopeId: input.scopeId,
643
+ guardPolicy: input.guardPolicy,
644
+ };
645
+ },
646
+
501
647
  createScopeFromAnchor(
502
648
  input: CreateScopeFromAnchorInput,
503
649
  ): CreateScopeFromAnchorResult {
@@ -510,6 +656,8 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
510
656
  anchor: input.anchor,
511
657
  mode: input.mode,
512
658
  label: input.label,
659
+ ...(input.visibility ? { visibility: input.visibility } : {}),
660
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
513
661
  ...(input.assoc ? { assoc: input.assoc } : {}),
514
662
  ...(input.stableRefHint
515
663
  ? { stableRefHint: input.stableRefHint }
@@ -89,6 +89,14 @@ interface SerializationState {
89
89
  usedTextIds: Set<string>;
90
90
  /** Deterministic PRNG-less counter used when we mint fresh ids. */
91
91
  mintedParaIdCounter: number;
92
+ /**
93
+ * Scope markers export as OOXML bookmarks. `w:bookmarkStart/@w:id` is a
94
+ * decimal number in WordprocessingML, so scope ids cannot be written into
95
+ * it directly.
96
+ */
97
+ usedBookmarkIds: Set<string>;
98
+ scopeBookmarkIds: Map<string, string>;
99
+ nextScopeBookmarkId: number;
92
100
  }
93
101
 
94
102
  interface InlineSerializationResult {
@@ -117,6 +125,61 @@ function serializePerformanceNow(): number {
117
125
  return Date.now();
118
126
  }
119
127
 
128
+ function collectNumericBookmarkIds(content: DocumentRootNode): Set<string> {
129
+ const used = new Set<string>();
130
+ const visit = (node: unknown): void => {
131
+ if (!node || typeof node !== "object") return;
132
+ const typed = node as {
133
+ type?: string;
134
+ bookmarkId?: unknown;
135
+ children?: unknown;
136
+ rows?: unknown;
137
+ cells?: unknown;
138
+ };
139
+ if (
140
+ (typed.type === "bookmark_start" || typed.type === "bookmark_end") &&
141
+ typeof typed.bookmarkId === "string"
142
+ ) {
143
+ const normalized = normalizeDecimalBookmarkId(typed.bookmarkId);
144
+ if (normalized !== null) used.add(normalized);
145
+ }
146
+
147
+ if (Array.isArray(typed.children)) {
148
+ for (const child of typed.children) visit(child);
149
+ }
150
+ if (Array.isArray(typed.rows)) {
151
+ for (const row of typed.rows) visit(row);
152
+ }
153
+ if (Array.isArray(typed.cells)) {
154
+ for (const cell of typed.cells) visit(cell);
155
+ }
156
+ };
157
+
158
+ visit(content);
159
+ return used;
160
+ }
161
+
162
+ function normalizeDecimalBookmarkId(id: string): string | null {
163
+ if (!/^\d+$/u.test(id)) return null;
164
+ const stripped = id.replace(/^0+/u, "");
165
+ return stripped.length > 0 ? stripped : "0";
166
+ }
167
+
168
+ function scopeBookmarkIdFor(state: SerializationState, scopeId: string): string {
169
+ const existing = state.scopeBookmarkIds.get(scopeId);
170
+ if (existing !== undefined) return existing;
171
+
172
+ let next = state.nextScopeBookmarkId;
173
+ while (state.usedBookmarkIds.has(String(next))) {
174
+ next += 1;
175
+ }
176
+ const bookmarkId = String(next);
177
+ state.nextScopeBookmarkId = next + 1;
178
+ state.usedBookmarkIds.add(bookmarkId);
179
+ state.scopeBookmarkIds.set(scopeId, bookmarkId);
180
+ return bookmarkId;
181
+ }
182
+
120
183
  export function serializeMainDocument(
121
184
  content: DocumentRootNode,
122
185
  preservation: PreservationStore = { opaqueFragments: {}, packageParts: {} },
@@ -150,6 +213,9 @@ export function serializeMainDocument(
150
213
  usedParaIds: new Set<string>(),
151
214
  usedTextIds: new Set<string>(),
152
215
  mintedParaIdCounter: 0,
216
+ usedBookmarkIds: collectNumericBookmarkIds(content),
217
+ scopeBookmarkIds: new Map<string, string>(),
218
+ nextScopeBookmarkId: 100000,
153
219
  };
154
220
  const suffix = `</w:body>\n</w:document>`;
155
221
  const bodyPieces: string[] = [];
@@ -679,9 +745,9 @@ function serializeTableInlineNode(
679
745
  return `<w:bookmarkEnd w:id="${escapeXmlAttribute(node.bookmarkId)}"/>`;
680
746
  case "scope_marker_start": {
681
747
  // S1 — scope markers export as w:bookmarkStart with the reserved
682
- // `bw:scope:` name prefix. The synthetic w:id is keyed on scopeId so
683
- // the matching end element references the same id.
684
- const bkId = `scope-${node.scopeId}`;
748
+ // `bw:scope:` name prefix. The synthetic w:id is numeric because
749
+ // WordprocessingML bookmark ids are ST_DecimalNumber values.
750
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
685
751
  const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
686
752
  return (
687
753
  `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
@@ -689,7 +755,7 @@ function serializeTableInlineNode(
689
755
  );
690
756
  }
691
757
  case "scope_marker_end": {
692
- const bkId = `scope-${node.scopeId}`;
758
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
693
759
  return `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
694
760
  }
695
761
  case "footnote_ref": {
@@ -1258,7 +1324,7 @@ function serializeInlineNode(
1258
1324
  case "scope_marker_start": {
1259
1325
  // S1 — mirror the bookmark_start shape with the reserved `bw:scope:`
1260
1326
  // name prefix. See serializeInline() above for the same convention.
1261
- const bkId = `scope-${node.scopeId}`;
1327
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
1262
1328
  const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
1263
1329
  const xml =
1264
1330
  `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
@@ -1269,7 +1335,7 @@ function serializeInlineNode(
1269
1335
  return { xml, cursor, boundaries };
1270
1336
  }
1271
1337
  case "scope_marker_end": {
1272
- const bkId = `scope-${node.scopeId}`;
1338
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
1273
1339
  const xml = `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
1274
1340
  const boundaries = new Map<number, number>();
1275
1341
  boundaries.set(cursor, xmlOffset);
@@ -40,6 +40,8 @@ export interface ValidatorIssue {
40
40
 
41
41
  const OVERLAY_PERSISTENCE_VALUES = ["internal", "external"] as const;
42
42
  const SCOPE_PERSISTENCE_VALUES = ["internal", "external", "inherit"] as const;
43
+ const SCOPE_VISIBILITY_VALUES = ["visible", "hidden", "invisible"] as const;
44
+ const SCOPE_GUARD_POLICY_VALUES = ["none", "insert-only", "read-only"] as const;
43
45
 
44
46
  export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
45
47
  const issues: ValidatorIssue[] = [];
@@ -82,6 +84,28 @@ export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
82
84
  for (const scope of findAll(xml, /<bw:scope\b([^>]*?)>([\s\S]*?)<\/bw:scope>/gu)) {
83
85
  const scopeAttrs = parseAttrs(scope.attrs);
84
86
  const scopeId = scopeAttrs.id ?? "?";
87
+ if (
88
+ scopeAttrs.visibility !== undefined &&
89
+ !(SCOPE_VISIBILITY_VALUES as readonly string[]).includes(scopeAttrs.visibility)
90
+ ) {
91
+ issues.push({
92
+ code: "unknown_enum_value",
93
+ path: `bw:scope[@id='${scopeId}']/@visibility`,
94
+ value: scopeAttrs.visibility,
95
+ severity: "warning",
96
+ });
97
+ }
98
+ if (
99
+ scopeAttrs.guardPolicy !== undefined &&
100
+ !(SCOPE_GUARD_POLICY_VALUES as readonly string[]).includes(scopeAttrs.guardPolicy)
101
+ ) {
102
+ issues.push({
103
+ code: "unknown_enum_value",
104
+ path: `bw:scope[@id='${scopeId}']/@guardPolicy`,
105
+ value: scopeAttrs.guardPolicy,
106
+ severity: "warning",
107
+ });
108
+ }
85
109
  let scopePersistence: string | undefined = scopeAttrs.metadataPersistence;
86
110
  if (scopePersistence !== undefined) {
87
111
  if (
@@ -927,6 +927,8 @@ function buildWorkflowScopeXml(scope: WorkflowScope): string {
927
927
  scope.workItemId ? ` workItemRef="${escapeXml(scope.workItemId)}"` : "",
928
928
  scope.label ? ` label="${escapeXml(scope.label)}"` : "",
929
929
  scope.domain ? ` domain="${escapeXml(scope.domain)}"` : "",
930
+ scope.visibility ? ` visibility="${escapeXml(scope.visibility)}"` : "",
931
+ scope.guardPolicy ? ` guardPolicy="${escapeXml(scope.guardPolicy)}"` : "",
930
932
  scope.metadataPersistence && scope.metadataPersistence !== "inherit"
931
933
  ? ` metadataPersistence="${escapeXml(scope.metadataPersistence)}"`
932
934
  : "",
@@ -1166,6 +1168,14 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
1166
1168
  attributes.metadataPersistence,
1167
1169
  ["internal", "external", "inherit"] as const,
1168
1170
  );
1171
+ const visibility = parseClosedEnum(
1172
+ attributes.visibility,
1173
+ ["visible", "hidden", "invisible"] as const,
1174
+ );
1175
+ const guardPolicy = parseClosedEnum(
1176
+ attributes.guardPolicy,
1177
+ ["none", "insert-only", "read-only"] as const,
1178
+ );
1169
1179
 
1170
1180
  return {
1171
1181
  scopeId: attributes.id,
@@ -1177,6 +1187,8 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
1177
1187
  label: attributes.label,
1178
1188
  domain: attributes.domain as WorkflowScope["domain"],
1179
1189
  metadata: parseWorkflowScopeMetadata(body),
1190
+ ...(visibility !== undefined ? { visibility } : {}),
1191
+ ...(guardPolicy !== undefined ? { guardPolicy } : {}),
1180
1192
  ...(scopeMetadataPersistence !== undefined ? { metadataPersistence: scopeMetadataPersistence } : {}),
1181
1193
  };
1182
1194
  }
@@ -94,6 +94,7 @@ import type {
94
94
  ScopeQueryFilter,
95
95
  ScopeQueryResult,
96
96
  ScopeVisibility,
97
+ WorkflowScopeGuardPolicy,
97
98
  ScopeChromeVisibilityState,
98
99
  SearchOptions,
99
100
  TextStyleFilter,
@@ -588,6 +589,13 @@ export interface DocumentRuntime {
588
589
  setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
589
590
  /** §C8 — Get a scope's current visibility (absent = "visible"). */
590
591
  getScopeVisibility(scopeId: string): ScopeVisibility;
592
+ /** Scope edit-enforcement posture (collab-replicated). */
593
+ setScopeGuardPolicy(
594
+ scopeId: string,
595
+ guardPolicy: WorkflowScopeGuardPolicy,
596
+ ): void;
597
+ /** Effective scope edit-enforcement posture (unknown = "none"). */
598
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy;
591
599
  /** §C7 — Set local chrome visibility state (never collab-replicated). */
592
600
  setScopeChromeVisibility(state: ScopeChromeVisibilityState): void;
593
601
  /** §C7 — Get local chrome visibility state (default: { mode: "all" }). */
@@ -3095,6 +3103,7 @@ export function createDocumentRuntime(
3095
3103
  {
3096
3104
  selection: createSelectionFromPublicAnchor(anchor),
3097
3105
  blockedCommandName: "applyScopeReplacement",
3106
+ skipWorkflowGuard: true,
3098
3107
  },
3099
3108
  );
3100
3109
  } catch (error) {
@@ -3129,6 +3138,7 @@ export function createDocumentRuntime(
3129
3138
  {
3130
3139
  selection: createSelectionFromPublicAnchor(anchor),
3131
3140
  blockedCommandName: "applyScopeReplacement",
3141
+ skipWorkflowGuard: true,
3132
3142
  },
3133
3143
  );
3134
3144
  } catch (error) {
@@ -3168,6 +3178,7 @@ export function createDocumentRuntime(
3168
3178
  selection: createSelectionFromPublicAnchor(anchor),
3169
3179
  blockedCommandName: "applyScopeReplacement",
3170
3180
  documentModeOverride: "suggesting",
3181
+ skipWorkflowGuard: true,
3171
3182
  },
3172
3183
  );
3173
3184
  } catch (error) {
@@ -3200,6 +3211,7 @@ export function createDocumentRuntime(
3200
3211
  selection: createSelectionFromPublicAnchor(anchor),
3201
3212
  blockedCommandName: "applyScopeReplacement",
3202
3213
  documentModeOverride: "suggesting",
3214
+ skipWorkflowGuard: true,
3203
3215
  },
3204
3216
  );
3205
3217
  } catch (error) {
@@ -3580,6 +3592,12 @@ export function createDocumentRuntime(
3580
3592
  getScopeVisibility(scopeId) {
3581
3593
  return workflowCoordinator.getScopeVisibility(scopeId);
3582
3594
  },
3595
+ setScopeGuardPolicy(scopeId, guardPolicy) {
3596
+ workflowCoordinator.setScopeGuardPolicy(scopeId, guardPolicy);
3597
+ },
3598
+ getScopeGuardPolicy(scopeId) {
3599
+ return workflowCoordinator.getScopeGuardPolicy(scopeId);
3600
+ },
3583
3601
  setScopeChromeVisibility(chromeVisibility) {
3584
3602
  workflowCoordinator.setScopeChromeVisibility(chromeVisibility);
3585
3603
  },
@@ -4939,6 +4957,13 @@ export function createDocumentRuntime(
4939
4957
  * override to the context that `executeEditorCommand` reads.
4940
4958
  */
4941
4959
  documentModeOverride?: DocumentMode;
4960
+ /**
4961
+ * Scope replacements are validated against their target scope by the
4962
+ * Layer-08 compiler before reaching this mechanical dispatch step.
4963
+ * Re-running the selection-scoped workflow guard here would make the
4964
+ * live cursor, not the target scope, decide whether the edit lands.
4965
+ */
4966
+ skipWorkflowGuard?: boolean;
4942
4967
  } = {},
4943
4968
  ): TextCommandAck {
4944
4969
  emitStageToken(telemetryBus, "command", "command.dispatch.start", {
@@ -4980,20 +5005,22 @@ export function createDocumentRuntime(
4980
5005
  blockedReasons: [{ code: "suggesting_unsupported", message }],
4981
5006
  });
4982
5007
  }
4983
- const blockedReasons = workflowCoordinator.evaluateBlockedReasons(selection, command.type);
4984
- if (blockedReasons.length > 0) {
4985
- emit({
4986
- type: "command_blocked",
4987
- documentId: state.documentId,
4988
- command: textOptions.blockedCommandName ?? command.type,
4989
- reasons: blockedReasons,
4990
- });
4991
- return completeDispatch({
4992
- kind: "rejected",
4993
- opId,
4994
- newRevisionToken: "",
4995
- blockedReasons: blockedReasons.map((r) => ({ code: r.code, message: r.message })),
4996
- });
5008
+ if (!textOptions.skipWorkflowGuard) {
5009
+ const blockedReasons = workflowCoordinator.evaluateBlockedReasons(selection, command.type);
5010
+ if (blockedReasons.length > 0) {
5011
+ emit({
5012
+ type: "command_blocked",
5013
+ documentId: state.documentId,
5014
+ command: textOptions.blockedCommandName ?? command.type,
5015
+ reasons: blockedReasons,
5016
+ });
5017
+ return completeDispatch({
5018
+ kind: "rejected",
5019
+ opId,
5020
+ newRevisionToken: "",
5021
+ blockedReasons: blockedReasons.map((r) => ({ code: r.code, message: r.message })),
5022
+ });
5023
+ }
4997
5024
  }
4998
5025
 
4999
5026
  const timestamp = normalizeCommandTimestamp(command.origin?.timestamp) ?? clock();
@@ -37,6 +37,7 @@ export type {
37
37
  WorkflowMetadataSnapshot,
38
38
  WorkflowOverlay,
39
39
  WorkflowScope,
40
+ WorkflowScopeGuardPolicy,
40
41
  WorkflowScopeMetadataField,
41
42
  } from "../../api/public-types.ts";
42
43
 
@@ -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,
@@ -393,6 +393,8 @@ export function __createWordReviewEditorRefBridge(
393
393
  addInvisibleScope: (params) => runtime.addInvisibleScope(params),
394
394
  setScopeVisibility: (scopeId, visibility) => runtime.setScopeVisibility(scopeId, visibility),
395
395
  getScopeVisibility: (scopeId) => runtime.getScopeVisibility(scopeId),
396
+ setScopeGuardPolicy: (scopeId, guardPolicy) => runtime.setScopeGuardPolicy(scopeId, guardPolicy),
397
+ getScopeGuardPolicy: (scopeId) => runtime.getScopeGuardPolicy(scopeId),
396
398
  setScopeChromeVisibility: (state) => runtime.setScopeChromeVisibility(state),
397
399
  getScopeChromeVisibility: () => runtime.getScopeChromeVisibility(),
398
400
  subscribeToScopeQuery: (filter, callback) => runtime.subscribeToScopeQuery(filter, callback),
@@ -1654,6 +1656,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1654
1656
  addInvisibleScope: (params) => activeRuntime.addInvisibleScope(params),
1655
1657
  setScopeVisibility: (scopeId, visibility) => activeRuntime.setScopeVisibility(scopeId, visibility),
1656
1658
  getScopeVisibility: (scopeId) => activeRuntime.getScopeVisibility(scopeId),
1659
+ setScopeGuardPolicy: (scopeId, guardPolicy) => activeRuntime.setScopeGuardPolicy(scopeId, guardPolicy),
1660
+ getScopeGuardPolicy: (scopeId) => activeRuntime.getScopeGuardPolicy(scopeId),
1657
1661
  setScopeChromeVisibility: (state) => activeRuntime.setScopeChromeVisibility(state),
1658
1662
  getScopeChromeVisibility: () => activeRuntime.getScopeChromeVisibility(),
1659
1663
  subscribeToScopeQuery: (filter, callback) => activeRuntime.subscribeToScopeQuery(filter, callback),