@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.
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.82",
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