@beyondwork/docx-react-component 1.0.40 → 1.0.42

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.
Files changed (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Local-path validator for bw:workflowPayload 1.0 / 1.1.
3
+ *
4
+ * This is a structural sanity check that runs inline on every import.
5
+ * It does NOT replace the Railway OOXML validator — that one checks
6
+ * package-level concerns (part types, relationship sanity, Word-spec
7
+ * compliance). This validator catches malformed bw:workflowPayload
8
+ * content that the parser would silently strip. Results surface to
9
+ * callers so tooling can log, display, or assert on them.
10
+ *
11
+ * Spec: docs/reference/bw-schema-spec.md §8.2.
12
+ */
13
+
14
+ export type ValidatorIssueCode =
15
+ | "unknown_enum_value"
16
+ | "external_entry_missing_ref"
17
+ | "external_entry_body_ignored"
18
+ | "external_field_missing_ref"
19
+ | "external_field_body_ignored"
20
+ | "internal_entry_unexpected_storage_ref"
21
+ | "internal_field_unexpected_storage_ref"
22
+ | "unsupported_version";
23
+
24
+ export type ValidatorIssueSeverity = "error" | "warning";
25
+
26
+ export interface ValidatorIssue {
27
+ code: ValidatorIssueCode;
28
+ path: string; // e.g. "bw:entry[@key='e1']"
29
+ severity: ValidatorIssueSeverity;
30
+ value?: string; // the invalid value when relevant
31
+ detail?: string; // optional extra context
32
+ }
33
+
34
+ const OVERLAY_PERSISTENCE_VALUES = ["internal", "external"] as const;
35
+ const SCOPE_PERSISTENCE_VALUES = ["internal", "external", "inherit"] as const;
36
+
37
+ export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
38
+ const issues: ValidatorIssue[] = [];
39
+
40
+ // Root version check.
41
+ const versionMatch = xml.match(/<bw:workflowPayload\b[^>]*\sversion="([^"]+)"/u);
42
+ if (versionMatch && !/^1\.[0-9]+$/.test(versionMatch[1])) {
43
+ issues.push({
44
+ code: "unsupported_version",
45
+ path: "bw:workflowPayload/@version",
46
+ value: versionMatch[1],
47
+ severity: "warning",
48
+ });
49
+ }
50
+
51
+ // Overlay-level metadataPersistence.
52
+ const overlayMatch = xml.match(/<bw:workflowOverlay\b([^>]*)>/u);
53
+ let overlayEnum: "internal" | "external" | undefined;
54
+ if (overlayMatch) {
55
+ const attrs = parseAttrs(overlayMatch[1]);
56
+ if (attrs.metadataPersistence !== undefined) {
57
+ if (
58
+ !(OVERLAY_PERSISTENCE_VALUES as readonly string[]).includes(
59
+ attrs.metadataPersistence,
60
+ )
61
+ ) {
62
+ issues.push({
63
+ code: "unknown_enum_value",
64
+ path: "bw:workflowOverlay/@metadataPersistence",
65
+ value: attrs.metadataPersistence,
66
+ severity: "warning",
67
+ });
68
+ } else {
69
+ overlayEnum = attrs.metadataPersistence as "internal" | "external";
70
+ }
71
+ }
72
+ }
73
+
74
+ // Each scope + its fields.
75
+ for (const scope of findAll(xml, /<bw:scope\b([^>]*?)>([\s\S]*?)<\/bw:scope>/gu)) {
76
+ const scopeAttrs = parseAttrs(scope.attrs);
77
+ const scopeId = scopeAttrs.id ?? "?";
78
+ let scopePersistence: string | undefined = scopeAttrs.metadataPersistence;
79
+ if (scopePersistence !== undefined) {
80
+ if (
81
+ !(SCOPE_PERSISTENCE_VALUES as readonly string[]).includes(scopePersistence)
82
+ ) {
83
+ issues.push({
84
+ code: "unknown_enum_value",
85
+ path: `bw:scope[@id='${scopeId}']/@metadataPersistence`,
86
+ value: scopePersistence,
87
+ severity: "warning",
88
+ });
89
+ scopePersistence = undefined;
90
+ }
91
+ }
92
+
93
+ // <bw:field>, two forms: self-closing and with body.
94
+ for (const field of findFields(scope.body)) {
95
+ const fieldAttrs = parseAttrs(field.attrs);
96
+ const fieldKey = fieldAttrs.key ?? "?";
97
+
98
+ let fieldPersistence: string | undefined = fieldAttrs.metadataPersistence;
99
+ if (
100
+ fieldPersistence !== undefined &&
101
+ !(SCOPE_PERSISTENCE_VALUES as readonly string[]).includes(fieldPersistence)
102
+ ) {
103
+ issues.push({
104
+ code: "unknown_enum_value",
105
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']/@metadataPersistence`,
106
+ value: fieldPersistence,
107
+ severity: "warning",
108
+ });
109
+ fieldPersistence = undefined;
110
+ }
111
+
112
+ const effective = resolveEffective({
113
+ overlay: overlayEnum,
114
+ scope: scopePersistence,
115
+ field: fieldPersistence,
116
+ });
117
+ const hasRef = Boolean(fieldAttrs.storageRef);
118
+ const hasBody = field.body.trim().length > 0;
119
+
120
+ if (effective === "external") {
121
+ if (!hasRef) {
122
+ issues.push({
123
+ code: "external_field_missing_ref",
124
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']`,
125
+ severity: "error",
126
+ });
127
+ }
128
+ if (hasBody) {
129
+ issues.push({
130
+ code: "external_field_body_ignored",
131
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']`,
132
+ severity: "warning",
133
+ });
134
+ }
135
+ } else if (effective === "internal" && hasRef) {
136
+ // Internal-mode fields carry their value inline. A `storageRef` on
137
+ // an internal field is a stale pointer from a persistence-mode
138
+ // downgrade and will confuse hosts that branch on its presence.
139
+ issues.push({
140
+ code: "internal_field_unexpected_storage_ref",
141
+ path: `bw:scope[@id='${scopeId}']/bw:field[@key='${fieldKey}']`,
142
+ severity: "warning",
143
+ });
144
+ }
145
+ }
146
+ }
147
+
148
+ // Top-level entries (bw:metadata body, outside scopes).
149
+ // These resolve against overlay-only (no scope context available unless
150
+ // the entry carries its own `scope="scope:{id}"` attribute, in which case
151
+ // a matching scope's override would apply — we keep this simple and just
152
+ // use the overlay default for entries).
153
+ for (const entry of findEntries(xml)) {
154
+ const entryAttrs = parseAttrs(entry.attrs);
155
+ const entryKey = entryAttrs.key ?? "?";
156
+
157
+ // Definition rows (key="definition.*") are schema descriptors, not data
158
+ // entries. Their body is always the human-readable label (per §4.1) and
159
+ // they are never subject to external-persistence rules (§4.3 applies only
160
+ // to data rows, §4.2). Skip persistence checks for them.
161
+ if (entryKey.startsWith("definition.")) {
162
+ continue;
163
+ }
164
+
165
+ let entryPersistence: string | undefined = entryAttrs.metadataPersistence;
166
+ if (
167
+ entryPersistence !== undefined &&
168
+ !(SCOPE_PERSISTENCE_VALUES as readonly string[]).includes(entryPersistence)
169
+ ) {
170
+ issues.push({
171
+ code: "unknown_enum_value",
172
+ path: `bw:entry[@key='${entryKey}']/@metadataPersistence`,
173
+ value: entryPersistence,
174
+ severity: "warning",
175
+ });
176
+ entryPersistence = undefined;
177
+ }
178
+
179
+ const effective = resolveEffective({
180
+ overlay: overlayEnum,
181
+ scope: undefined,
182
+ field: entryPersistence,
183
+ });
184
+ const hasRef = Boolean(entryAttrs.storageRef);
185
+ const hasBody = entry.body.trim().length > 0;
186
+
187
+ if (effective === "external") {
188
+ if (!hasRef) {
189
+ issues.push({
190
+ code: "external_entry_missing_ref",
191
+ path: `bw:entry[@key='${entryKey}']`,
192
+ severity: "error",
193
+ });
194
+ }
195
+ if (hasBody) {
196
+ issues.push({
197
+ code: "external_entry_body_ignored",
198
+ path: `bw:entry[@key='${entryKey}']`,
199
+ severity: "warning",
200
+ });
201
+ }
202
+ } else if (effective === "internal" && hasRef) {
203
+ // Internal-mode entries carry their value inline. A `storageRef` on
204
+ // an internal entry is a stale pointer from a persistence-mode
205
+ // downgrade and will confuse hosts that branch on its presence.
206
+ issues.push({
207
+ code: "internal_entry_unexpected_storage_ref",
208
+ path: `bw:entry[@key='${entryKey}']`,
209
+ severity: "warning",
210
+ });
211
+ }
212
+ }
213
+
214
+ return issues;
215
+ }
216
+
217
+ function parseAttrs(source: string): Record<string, string> {
218
+ const out: Record<string, string> = {};
219
+ for (const m of source.matchAll(/(\w+)="([^"]*)"/gu)) {
220
+ out[m[1]] = m[2];
221
+ }
222
+ return out;
223
+ }
224
+
225
+ function* findAll(
226
+ xml: string,
227
+ re: RegExp,
228
+ ): Generator<{ attrs: string; body: string }> {
229
+ for (const m of xml.matchAll(re)) {
230
+ yield { attrs: m[1] ?? "", body: m[2] ?? "" };
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Emits each `<bw:field>` in the scope body, handling both self-closing
236
+ * and body-bearing forms.
237
+ */
238
+ function* findFields(
239
+ scopeBody: string,
240
+ ): Generator<{ attrs: string; body: string }> {
241
+ // Match both <bw:field .../> and <bw:field ...>body</bw:field>
242
+ const re = /<bw:field\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:field>)/gu;
243
+ for (const m of scopeBody.matchAll(re)) {
244
+ yield { attrs: m[1] ?? "", body: m[2] ?? "" };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Emits each top-level `<bw:entry>` (inside `<bw:metadata>`), handling
250
+ * both self-closing and body-bearing forms. Scope-internal entries
251
+ * are handled by findFields — this only looks at the flat metadata list.
252
+ */
253
+ function* findEntries(
254
+ xml: string,
255
+ ): Generator<{ attrs: string; body: string }> {
256
+ const re = /<bw:entry\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:entry>)/gu;
257
+ for (const m of xml.matchAll(re)) {
258
+ yield { attrs: m[1] ?? "", body: m[2] ?? "" };
259
+ }
260
+ }
261
+
262
+ function resolveEffective(input: {
263
+ overlay?: "internal" | "external";
264
+ scope?: string;
265
+ field?: string;
266
+ }): "internal" | "external" {
267
+ if (input.field === "internal" || input.field === "external") return input.field;
268
+ if (input.scope === "internal" || input.scope === "external") return input.scope;
269
+ if (input.overlay === "external") return "external";
270
+ return "internal";
271
+ }
@@ -12,6 +12,29 @@ import type {
12
12
  WorkflowScope,
13
13
  WorkflowWorkItem,
14
14
  } from "../../api/public-types.ts";
15
+ import {
16
+ validateWorkflowPayloadEnvelope,
17
+ type ValidatorIssue,
18
+ } from "./workflow-payload-validator.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Schema 1.1 parser helpers (fail-closed per spec §8.2)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function parseClosedEnum<T extends string>(
25
+ raw: string | undefined,
26
+ allowed: readonly T[],
27
+ ): T | undefined {
28
+ if (raw === undefined) return undefined;
29
+ return (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined;
30
+ }
31
+
32
+ function parseNonNegativeInt(raw: string | undefined): number | undefined {
33
+ if (raw === undefined) return undefined;
34
+ const n = Number(raw);
35
+ if (!Number.isInteger(n) || n < 0) return undefined;
36
+ return n;
37
+ }
15
38
 
16
39
  export const WORKFLOW_PAYLOAD_PART_PATH = "/customXml/item1.xml";
17
40
  export const WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH = "/customXml/itemProps1.xml";
@@ -39,6 +62,7 @@ type EmbeddedWorkflowPayloadDescriptor = {
39
62
  export interface WorkflowPayloadEnvelope {
40
63
  workflowMetadata: WorkflowMetadataSnapshot;
41
64
  workflowOverlay?: WorkflowOverlay;
65
+ validatorIssues?: readonly ValidatorIssue[];
42
66
  }
43
67
 
44
68
  export function getDocumentBackedWorkflowMetadata(
@@ -144,7 +168,8 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
144
168
  }
145
169
 
146
170
  const xml = decodeUtf8(payloadPart.bytes);
147
- const entryMatches = [...xml.matchAll(/<bw:entry\b([^>]*)>([\s\S]*?)<\/bw:entry>/g)];
171
+ // Match both self-closing (<bw:entry .../>) and body-bearing (<bw:entry ...>body</bw:entry>)
172
+ const entryMatches = [...xml.matchAll(/<bw:entry\b([^>]*)(?:\/>|>([\s\S]*?)<\/bw:entry>)/g)];
148
173
  const definitions: WorkflowMetadataDefinition[] = [];
149
174
  const entries: WorkflowMetadataEntry[] = [];
150
175
 
@@ -183,6 +208,14 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
183
208
  if (!entryId || !metadataId) {
184
209
  continue;
185
210
  }
211
+
212
+ const entryMetadataPersistence = parseClosedEnum(
213
+ attributes.metadataPersistence,
214
+ ["internal", "external", "inherit"] as const,
215
+ );
216
+ const entryStorageRef = attributes.storageRef;
217
+ const entryMetadataVersion = parseNonNegativeInt(attributes.metadataVersion);
218
+
186
219
  entries.push({
187
220
  entryId,
188
221
  metadataId,
@@ -198,18 +231,62 @@ export function parseWorkflowPayloadEnvelopeFromPackage(
198
231
  ...(attributes.scope?.startsWith("workItem:")
199
232
  ? { workItemId: attributes.scope.slice("workItem:".length) }
200
233
  : {}),
234
+ ...(entryMetadataPersistence !== undefined ? { metadataPersistence: entryMetadataPersistence } : {}),
235
+ ...(entryStorageRef !== undefined ? { storageRef: entryStorageRef } : {}),
236
+ ...(entryMetadataVersion !== undefined ? { metadataVersion: entryMetadataVersion } : {}),
201
237
  });
202
238
  }
203
239
 
240
+ const validatorIssues = validateWorkflowPayloadEnvelope(xml);
204
241
  return {
205
242
  workflowMetadata: {
206
243
  definitions,
207
244
  entries,
208
245
  },
209
246
  workflowOverlay: parseWorkflowOverlay(xml),
247
+ ...(validatorIssues.length > 0 ? { validatorIssues } : {}),
210
248
  };
211
249
  }
212
250
 
251
+ function needsSchemaV11(input: {
252
+ workflowMetadata: WorkflowMetadataSnapshot;
253
+ workflowOverlay?: WorkflowOverlay;
254
+ }): boolean {
255
+ if (input.workflowOverlay?.metadataPersistence !== undefined) {
256
+ return true;
257
+ }
258
+ if (
259
+ input.workflowOverlay?.scopes?.some(
260
+ (s) => s.metadataPersistence !== undefined,
261
+ )
262
+ ) {
263
+ return true;
264
+ }
265
+ if (
266
+ input.workflowOverlay?.scopes?.some((s) =>
267
+ s.metadata?.some(
268
+ (f) =>
269
+ f.metadataPersistence !== undefined ||
270
+ f.storageRef !== undefined ||
271
+ f.metadataVersion !== undefined,
272
+ ),
273
+ )
274
+ ) {
275
+ return true;
276
+ }
277
+ if (
278
+ input.workflowMetadata.entries.some(
279
+ (e) =>
280
+ e.metadataPersistence !== undefined ||
281
+ e.storageRef !== undefined ||
282
+ e.metadataVersion !== undefined,
283
+ )
284
+ ) {
285
+ return true;
286
+ }
287
+ return false;
288
+ }
289
+
213
290
  function buildPayloadXml(input: {
214
291
  descriptor: EmbeddedWorkflowPayloadDescriptor;
215
292
  createdAt: string;
@@ -219,6 +296,8 @@ function buildPayloadXml(input: {
219
296
  workflowOverlay?: WorkflowOverlay;
220
297
  preservedExtensionsXml: string;
221
298
  }): string {
299
+ const schemaVersion = needsSchemaV11(input) ? "1.1" : "1.0";
300
+
222
301
  const definitionEntriesXml = input.workflowMetadata.definitions
223
302
  .map((definition) => [
224
303
  `<bw:entry`,
@@ -237,7 +316,7 @@ function buildPayloadXml(input: {
237
316
  const serializedValue = serializeWorkflowMetadataValue(entry.value);
238
317
  const storyTargetAttributes = serializeWorkflowStoryTarget(entry.storyTarget);
239
318
 
240
- return [
319
+ const baseAttrs = [
241
320
  `<bw:entry`,
242
321
  ` key="${escapeXml(entry.entryId)}"`,
243
322
  ` metadataId="${escapeXml(entry.metadataId)}"`,
@@ -245,8 +324,24 @@ function buildPayloadXml(input: {
245
324
  ` valueType="${escapeXml(serializedValue.type)}"`,
246
325
  ` scope="${escapeXml(entry.scopeId ? `scope:${entry.scopeId}` : entry.workItemId ? `workItem:${entry.workItemId}` : "document")}"`,
247
326
  storyTargetAttributes,
248
- `>${escapeXml(serializedValue.text)}</bw:entry>`,
327
+ entry.metadataPersistence && entry.metadataPersistence !== "inherit"
328
+ ? ` metadataPersistence="${escapeXml(entry.metadataPersistence)}"`
329
+ : "",
330
+ entry.metadataPersistence === "external" && entry.storageRef
331
+ ? ` storageRef="${escapeXml(entry.storageRef)}"`
332
+ : "",
333
+ typeof entry.metadataVersion === "number" &&
334
+ Number.isInteger(entry.metadataVersion) &&
335
+ entry.metadataVersion >= 0
336
+ ? ` metadataVersion="${entry.metadataVersion.toString()}"`
337
+ : "",
249
338
  ].join("");
339
+
340
+ if (entry.metadataPersistence === "external") {
341
+ return `${baseAttrs}/>`;
342
+ }
343
+
344
+ return `${baseAttrs}>${escapeXml(serializedValue.text)}</bw:entry>`;
250
345
  })
251
346
  .filter((value) => value.length > 0)
252
347
  .join("\n");
@@ -258,7 +353,7 @@ function buildPayloadXml(input: {
258
353
 
259
354
  return [
260
355
  `<?xml version="1.0" encoding="UTF-8"?>`,
261
- `<bw:workflowPayload xmlns:bw="urn:beyondwork:workflow-payload:1" version="1.0" payloadId="${escapeXml(input.descriptor.payloadId)}" itemId="${escapeXml(input.descriptor.itemId)}" documentId="${escapeXml(input.descriptor.documentId)}" createdAt="${escapeXml(input.createdAt)}" updatedAt="${escapeXml(input.updatedAt)}">`,
356
+ `<bw:workflowPayload xmlns:bw="urn:beyondwork:workflow-payload:1" version="${schemaVersion}" payloadId="${escapeXml(input.descriptor.payloadId)}" itemId="${escapeXml(input.descriptor.itemId)}" documentId="${escapeXml(input.descriptor.documentId)}" createdAt="${escapeXml(input.createdAt)}" updatedAt="${escapeXml(input.updatedAt)}">`,
262
357
  ` <bw:manifest>`,
263
358
  ` <bw:producer name="@beyondwork/docx-react-component" version="${escapeXml(input.producerVersion)}" />`,
264
359
  ` <bw:compatibility rebindMode="best-effort" preserveUnknownExtensions="true" />`,
@@ -357,6 +452,9 @@ function buildWorkblockExtensionXml(workflowOverlay: WorkflowOverlay | undefined
357
452
  workflowOverlay.activeWorkItemId
358
453
  ? ` activeWorkItemId="${escapeXml(workflowOverlay.activeWorkItemId)}"`
359
454
  : "",
455
+ workflowOverlay.metadataPersistence
456
+ ? ` metadataPersistence="${escapeXml(workflowOverlay.metadataPersistence)}"`
457
+ : "",
360
458
  `>`,
361
459
  ].join(""),
362
460
  ` <bw:workItems>`,
@@ -380,6 +478,9 @@ function buildWorkflowScopeXml(scope: WorkflowScope): string {
380
478
  scope.workItemId ? ` workItemRef="${escapeXml(scope.workItemId)}"` : "",
381
479
  scope.label ? ` label="${escapeXml(scope.label)}"` : "",
382
480
  scope.domain ? ` domain="${escapeXml(scope.domain)}"` : "",
481
+ scope.metadataPersistence && scope.metadataPersistence !== "inherit"
482
+ ? ` metadataPersistence="${escapeXml(scope.metadataPersistence)}"`
483
+ : "",
383
484
  `>`,
384
485
  indentLines(buildWorkflowAnchorXml(scope.anchor, scope.storyTarget), 2),
385
486
  scopeMetadataXml ? indentLines(scopeMetadataXml, 2) : "",
@@ -397,12 +498,28 @@ function buildWorkflowScopeMetadataXml(scopeMetadata: WorkflowScope["metadata"])
397
498
  scopeMetadata
398
499
  .map((field) => {
399
500
  const serialized = serializeWorkflowScopeMetadataValue(field.valueType, field.value);
400
- return [
501
+ const baseAttrs = [
401
502
  `<bw:field`,
402
503
  ` key="${escapeXml(field.key)}"`,
403
504
  serialized.valueType ? ` type="${escapeXml(serialized.valueType)}"` : "",
404
- `>${escapeXml(serialized.text)}</bw:field>`,
505
+ field.metadataPersistence && field.metadataPersistence !== "inherit"
506
+ ? ` metadataPersistence="${escapeXml(field.metadataPersistence)}"`
507
+ : "",
508
+ field.metadataPersistence === "external" && field.storageRef
509
+ ? ` storageRef="${escapeXml(field.storageRef)}"`
510
+ : "",
511
+ typeof field.metadataVersion === "number" &&
512
+ Number.isInteger(field.metadataVersion) &&
513
+ field.metadataVersion >= 0
514
+ ? ` metadataVersion="${field.metadataVersion.toString()}"`
515
+ : "",
405
516
  ].join("");
517
+
518
+ if (field.metadataPersistence === "external") {
519
+ return `${baseAttrs}/>`;
520
+ }
521
+
522
+ return `${baseAttrs}>${escapeXml(serialized.text)}</bw:field>`;
406
523
  })
407
524
  .join("\n"),
408
525
  2,
@@ -571,11 +688,17 @@ function parseWorkflowOverlay(xml: string): WorkflowOverlay | undefined {
571
688
  .map((match) => parseWorkflowWorkItem(match[1] ?? "", match[2] ?? ""))
572
689
  .filter((item): item is WorkflowWorkItem => item !== null);
573
690
 
691
+ const overlayMetadataPersistence = parseClosedEnum(
692
+ overlayAttributes.metadataPersistence,
693
+ ["internal", "external"] as const,
694
+ );
695
+
574
696
  return {
575
697
  overlayVersion: (overlayAttributes.overlayVersion as WorkflowOverlay["overlayVersion"]) ?? "workflow-overlay/1",
576
698
  activeWorkItemId: overlayAttributes.activeWorkItemId ?? null,
577
699
  scopes,
578
700
  ...(workItems.length > 0 ? { workItems } : {}),
701
+ ...(overlayMetadataPersistence !== undefined ? { metadataPersistence: overlayMetadataPersistence } : {}),
579
702
  };
580
703
  }
581
704
 
@@ -588,6 +711,11 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
588
711
  const anchor = anchorMatch
589
712
  ? parseWorkflowOverlayAnchor(anchorMatch[1] ?? "", anchorMatch[2] ?? "")
590
713
  : createDefaultRangeAnchor(0, 0);
714
+ const scopeMetadataPersistence = parseClosedEnum(
715
+ attributes.metadataPersistence,
716
+ ["internal", "external", "inherit"] as const,
717
+ );
718
+
591
719
  return {
592
720
  scopeId: attributes.id,
593
721
  version: attributes.version !== undefined ? Number(attributes.version) : undefined,
@@ -598,6 +726,7 @@ function parseWorkflowScope(attributesSource: string, body: string): WorkflowSco
598
726
  label: attributes.label,
599
727
  domain: attributes.domain as WorkflowScope["domain"],
600
728
  metadata: parseWorkflowScopeMetadata(body),
729
+ ...(scopeMetadataPersistence !== undefined ? { metadataPersistence: scopeMetadataPersistence } : {}),
601
730
  };
602
731
  }
603
732
 
@@ -606,7 +735,8 @@ function parseWorkflowScopeMetadata(body: string): WorkflowScope["metadata"] | u
606
735
  if (!metadataMatch) {
607
736
  return undefined;
608
737
  }
609
- const fields = [...(metadataMatch[1] ?? "").matchAll(/<bw:field\b([^>]*)>([\s\S]*?)<\/bw:field>/gu)]
738
+ // Match both self-closing (<bw:field .../>) and body-bearing (<bw:field ...>body</bw:field>)
739
+ const fields = [...(metadataMatch[1] ?? "").matchAll(/<bw:field\b([^>]*)(?:\/>|>([\s\S]*?)<\/bw:field>)/gu)]
610
740
  .map((match) => parseWorkflowScopeMetadataField(match[1] ?? "", match[2] ?? ""))
611
741
  .filter((field): field is NonNullable<WorkflowScope["metadata"]>[number] => field !== null);
612
742
  return fields.length > 0 ? fields : undefined;
@@ -621,10 +751,19 @@ function parseWorkflowScopeMetadataField(
621
751
  return null;
622
752
  }
623
753
  const valueType = attributes.type as NonNullable<WorkflowScope["metadata"]>[number]["valueType"] | undefined;
754
+ const fieldMetadataPersistence = parseClosedEnum(
755
+ attributes.metadataPersistence,
756
+ ["internal", "external", "inherit"] as const,
757
+ );
758
+ const fieldStorageRef = attributes.storageRef;
759
+ const fieldMetadataVersion = parseNonNegativeInt(attributes.metadataVersion);
624
760
  return {
625
761
  key: attributes.key,
626
762
  ...(valueType ? { valueType } : {}),
627
763
  value: parseWorkflowScopeMetadataValue(valueType, body),
764
+ ...(fieldMetadataPersistence !== undefined ? { metadataPersistence: fieldMetadataPersistence } : {}),
765
+ ...(fieldStorageRef !== undefined ? { storageRef: fieldStorageRef } : {}),
766
+ ...(fieldMetadataVersion !== undefined ? { metadataVersion: fieldMetadataVersion } : {}),
628
767
  };
629
768
  }
630
769