@beyondwork/docx-react-component 1.0.41 → 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,157 @@
1
+ import {
2
+ verifyWorkflowPayloadXml,
3
+ type PayloadSignature,
4
+ type PayloadVerifier,
5
+ } from "../io/ooxml/payload-signature.ts";
6
+
7
+ /**
8
+ * Runtime metadata-integrity states.
9
+ *
10
+ * - `unsigned` — payload has no `bw:signature`, treated as trust-on-
11
+ * first-use. Callers may mutate freely; the first
12
+ * mutation will re-sign and transition to `verified`.
13
+ * - `verified` — signature present and valid against the registered
14
+ * verifier. Mutations are allowed through the gate.
15
+ * - `tampered` — signature present but verification failed. All
16
+ * mutations are blocked with `metadata_tampered` until
17
+ * the user calls `acknowledge()`.
18
+ */
19
+ export type MetadataIntegrity = "unsigned" | "verified" | "tampered";
20
+
21
+ export type TamperGateEvent =
22
+ | { type: "integrity_changed"; state: MetadataIntegrity }
23
+ | { type: "metadata_integrity_violation" };
24
+
25
+ export interface TamperGateArgs {
26
+ /**
27
+ * Verifier used on attach. Matches the signer the host writes the
28
+ * payload with. Omit to skip verification (payload is treated as
29
+ * unsigned).
30
+ */
31
+ verifier?: PayloadVerifier;
32
+ }
33
+
34
+ export interface AttachArgs {
35
+ payloadXml: string;
36
+ signature: PayloadSignature | undefined;
37
+ }
38
+
39
+ export type GuardResult =
40
+ | { ok: true }
41
+ | { ok: false; reason: "metadata_tampered" };
42
+
43
+ export interface TamperGate {
44
+ readonly state: MetadataIntegrity;
45
+ /**
46
+ * Verifies the signature against the supplied payload XML and
47
+ * transitions the gate state. Idempotent — safe to call every
48
+ * time a new payload is attached.
49
+ */
50
+ attach(args: AttachArgs): Promise<MetadataIntegrity>;
51
+ /**
52
+ * Resets the gate to `unsigned`. Useful when the underlying
53
+ * payload is detached (host switched documents).
54
+ */
55
+ detach(): void;
56
+ /**
57
+ * Single chokepoint every mutation path consults before writing
58
+ * back to the payload. Returns `{ ok: false, reason: "metadata_tampered" }`
59
+ * when the gate is in `tampered` state and the user has not
60
+ * acknowledged. Returns `{ ok: true }` otherwise.
61
+ */
62
+ guard(): GuardResult;
63
+ /**
64
+ * Flips `tampered` → `verified`. Only effective when the current
65
+ * state is `tampered`; other states are left untouched.
66
+ */
67
+ acknowledge(): void;
68
+ /**
69
+ * Re-enters `tampered` — e.g. when a host writes a payload and the
70
+ * re-sign check disagrees with the stored signature. Primarily for
71
+ * test fixtures and future composition slices.
72
+ */
73
+ flagTampered(): void;
74
+ subscribe(listener: (event: TamperGateEvent) => void): () => void;
75
+ destroy(): void;
76
+ }
77
+
78
+ export function createTamperGate(args: TamperGateArgs = {}): TamperGate {
79
+ let state: MetadataIntegrity = "unsigned";
80
+ let hasEmittedViolation = false;
81
+ const listeners = new Set<(event: TamperGateEvent) => void>();
82
+
83
+ const emit = (event: TamperGateEvent): void => {
84
+ for (const fn of [...listeners]) fn(event);
85
+ };
86
+
87
+ const setState = (next: MetadataIntegrity): void => {
88
+ if (next === state) return;
89
+ state = next;
90
+ emit({ type: "integrity_changed", state: next });
91
+ if (next === "tampered" && !hasEmittedViolation) {
92
+ hasEmittedViolation = true;
93
+ emit({ type: "metadata_integrity_violation" });
94
+ }
95
+ if (next !== "tampered") {
96
+ // Re-arm the violation emitter so a subsequent tamper transition
97
+ // re-notifies the host. Matches the "once per open on tamper"
98
+ // requirement, where "open" is bracketed by acknowledge().
99
+ hasEmittedViolation = false;
100
+ }
101
+ };
102
+
103
+ return {
104
+ get state() {
105
+ return state;
106
+ },
107
+
108
+ async attach({ payloadXml, signature }) {
109
+ if (!signature) {
110
+ setState("unsigned");
111
+ return state;
112
+ }
113
+ if (!args.verifier) {
114
+ setState("unsigned");
115
+ return state;
116
+ }
117
+ const ok = await verifyWorkflowPayloadXml(
118
+ payloadXml,
119
+ signature,
120
+ args.verifier,
121
+ );
122
+ setState(ok ? "verified" : "tampered");
123
+ return state;
124
+ },
125
+
126
+ detach() {
127
+ setState("unsigned");
128
+ },
129
+
130
+ guard() {
131
+ if (state === "tampered") {
132
+ return { ok: false, reason: "metadata_tampered" };
133
+ }
134
+ return { ok: true };
135
+ },
136
+
137
+ acknowledge() {
138
+ if (state !== "tampered") return;
139
+ setState("verified");
140
+ },
141
+
142
+ flagTampered() {
143
+ setState("tampered");
144
+ },
145
+
146
+ subscribe(listener) {
147
+ listeners.add(listener);
148
+ return () => {
149
+ listeners.delete(listener);
150
+ };
151
+ },
152
+
153
+ destroy() {
154
+ listeners.clear();
155
+ },
156
+ };
157
+ }
@@ -168,6 +168,15 @@ function collectWorkflowMetadataMarkup(
168
168
  value: entry.value,
169
169
  scopeId: entry.scopeId,
170
170
  workItemId: entry.workItemId,
171
+ // Schema 1.1 — copy external-mode fields through so card-layer code
172
+ // can detect external entries without re-consulting the entry snapshot.
173
+ ...(entry.metadataPersistence !== undefined
174
+ ? { metadataPersistence: entry.metadataPersistence }
175
+ : {}),
176
+ ...(entry.storageRef !== undefined ? { storageRef: entry.storageRef } : {}),
177
+ ...(entry.metadataVersion !== undefined
178
+ ? { metadataVersion: entry.metadataVersion }
179
+ : {}),
171
180
  } satisfies WorkflowMetadataMarkup];
172
181
  });
173
182
  }
@@ -16,14 +16,18 @@ import type {
16
16
  EditorAnchorProjection,
17
17
  EditorStoryTarget,
18
18
  IssueMetadataValue,
19
+ ReviewActionMetadataValue,
19
20
  ScopeCardModel,
21
+ ScopeMetadataResolver,
22
+ SuggestionGroup,
23
+ SuggestionsSnapshot,
20
24
  WorkflowBlockedCommandReason,
21
25
  WorkflowCandidateRange,
22
26
  WorkflowLockedZone,
23
27
  WorkflowMetadataMarkup,
24
28
  WorkflowScope,
25
29
  } from "../api/public-types";
26
- import { ISSUE_METADATA_ID } from "../api/public-types";
30
+ import { ISSUE_METADATA_ID, REVIEW_ACTION_METADATA_ID } from "../api/public-types";
27
31
  import { MAIN_STORY_TARGET, storyTargetsEqual } from "../core/selection/mapping.ts";
28
32
  import type { RuntimePageGraph } from "./layout/page-graph.ts";
29
33
  import type {
@@ -316,6 +320,41 @@ export interface AttachScopeCardModelInput {
316
320
  * rects — chrome consumers fall back to on-render positioning.
317
321
  */
318
322
  anchorIndex?: RenderAnchorIndex | null;
323
+ /**
324
+ * R3 — suggestion snapshot. Groups whose `issueId` matches the
325
+ * scope's issue attach as `ScopeCardModel.suggestionGroups`; their
326
+ * ids land in `suggestionGroupIds` as a convenience for consumers
327
+ * that want a flat lookup.
328
+ */
329
+ suggestions?: SuggestionsSnapshot | null;
330
+ /**
331
+ * K1-light — review-action markup. Entries with
332
+ * `metadataId === REVIEW_ACTION_METADATA_ID` whose coerced value's
333
+ * `issueId` matches the scope's issue attach newest-first as
334
+ * `ScopeCardModel.reviewActions`.
335
+ */
336
+ reviewActionMetadata?: readonly WorkflowMetadataMarkup[];
337
+ /**
338
+ * K2 — candidate ranges. Any entry with `source: "ai"` whose
339
+ * offset range overlaps the segment flips `agentPending` to true.
340
+ */
341
+ candidates?: readonly WorkflowCandidateRange[];
342
+ /**
343
+ * Pre-resolved external metadata values, keyed by entryId. When an
344
+ * entry in `metadata` or `reviewActionMetadata` has
345
+ * `metadataPersistence === "external"` and its inline `value` is
346
+ * undefined, this map supplies the resolved value (fetched by the caller
347
+ * via `ScopeMetadataResolver.resolve`).
348
+ *
349
+ * Entries absent from the map render as if the metadata had no value —
350
+ * the card still shows the scope but drops the issue row, consistent with
351
+ * how malformed metadata is handled today.
352
+ *
353
+ * NOTE: The facet's `getAllScopeCardModels()` does NOT populate this.
354
+ * Callers that need resolved values build the map via
355
+ * `buildExternalResolutions()` and call `attachScopeCardModel()` directly.
356
+ */
357
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>;
319
358
  }
320
359
 
321
360
  /**
@@ -361,11 +400,23 @@ export function attachScopeCardModel(
361
400
  segment.scopeId,
362
401
  workItemId,
363
402
  input.metadata,
403
+ input.externalResolutions,
364
404
  );
365
405
  const primaryAnchorRect = input.anchorIndex
366
406
  ? input.anchorIndex.bySelection(segment.fromOffset, segment.toOffset)
367
407
  : null;
368
408
 
409
+ const suggestionGroups = resolveSuggestionGroupsForIssue(
410
+ issue?.issueId,
411
+ input.suggestions,
412
+ );
413
+ const reviewActions = resolveReviewActionsForIssue(
414
+ issue?.issueId,
415
+ input.reviewActionMetadata,
416
+ input.externalResolutions,
417
+ );
418
+ const agentPending = resolveAgentPending(segment, input.candidates);
419
+
369
420
  models.push({
370
421
  scopeId: segment.scopeId,
371
422
  ...(workItemId ? { workItemId } : {}),
@@ -373,19 +424,103 @@ export function attachScopeCardModel(
373
424
  posture: segment.posture,
374
425
  primaryAnchorRect,
375
426
  ...(issue ? { issue } : {}),
376
- suggestionGroupIds: [],
377
- reviewActionCount: 0,
378
- agentPending: false,
427
+ suggestionGroupIds: suggestionGroups.map((group) => group.groupId),
428
+ suggestionGroups,
429
+ reviewActionCount: reviewActions.length,
430
+ reviewActions,
431
+ agentPending,
379
432
  });
380
433
  }
381
434
 
382
435
  return models;
383
436
  }
384
437
 
438
+ function resolveSuggestionGroupsForIssue(
439
+ issueId: string | undefined,
440
+ suggestions: SuggestionsSnapshot | null | undefined,
441
+ ): SuggestionGroup[] {
442
+ if (!issueId || !suggestions?.groups) return [];
443
+ return suggestions.groups.filter((group) => group.issueId === issueId);
444
+ }
445
+
446
+ function resolveReviewActionsForIssue(
447
+ issueId: string | undefined,
448
+ metadata: readonly WorkflowMetadataMarkup[] | undefined,
449
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>,
450
+ ): ReviewActionMetadataValue[] {
451
+ if (!issueId || !metadata || metadata.length === 0) return [];
452
+ const result: ReviewActionMetadataValue[] = [];
453
+ for (const entry of metadata) {
454
+ if (entry.metadataId !== REVIEW_ACTION_METADATA_ID) continue;
455
+ const coerced = coerceReviewActionValue(effectiveMetadataValue(entry, externalResolutions));
456
+ if (!coerced) continue;
457
+ if (coerced.issueId !== issueId) continue;
458
+ result.push(coerced);
459
+ }
460
+ // Newest-first by createdAt ISO string (lexicographic for 8601 UTC).
461
+ result.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
462
+ return result;
463
+ }
464
+
465
+ function coerceReviewActionValue(
466
+ value: unknown,
467
+ ): ReviewActionMetadataValue | undefined {
468
+ if (!value || typeof value !== "object") return undefined;
469
+ const candidate = value as Partial<ReviewActionMetadataValue>;
470
+ if (
471
+ typeof candidate.reviewActionId !== "string" ||
472
+ typeof candidate.action !== "string" ||
473
+ typeof candidate.actor !== "string" ||
474
+ typeof candidate.actorRole !== "string" ||
475
+ typeof candidate.createdAt !== "string"
476
+ ) {
477
+ return undefined;
478
+ }
479
+ return candidate as ReviewActionMetadataValue;
480
+ }
481
+
482
+ function resolveAgentPending(
483
+ segment: ScopeRailSegment,
484
+ candidates: readonly WorkflowCandidateRange[] | undefined,
485
+ ): boolean {
486
+ if (!candidates || candidates.length === 0) return false;
487
+ for (const candidate of candidates) {
488
+ if (candidate.source !== "ai") continue;
489
+ const range = anchorToRuntimeRange(candidate.anchor);
490
+ if (!range) continue;
491
+ // Strict-half-open overlap: [from, to).
492
+ if (range.to > segment.fromOffset && range.from < segment.toOffset) {
493
+ return true;
494
+ }
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Return the effective value for a metadata markup entry. For
501
+ * internal-mode entries (or entries with an inline value), returns
502
+ * `entry.value` directly. For external-mode entries whose inline
503
+ * `value` is undefined, looks up the resolved value from the
504
+ * caller-supplied `externalResolutions` map keyed by `entryId`.
505
+ *
506
+ * Returns `undefined` when no value is available — callers treat this the
507
+ * same as malformed metadata and drop the card row silently.
508
+ */
509
+ function effectiveMetadataValue(
510
+ entry: WorkflowMetadataMarkup,
511
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>,
512
+ ): Record<string, unknown> | undefined {
513
+ if (entry.value !== undefined) return entry.value;
514
+ // External-mode entry — inline body is empty. Look up the resolved value.
515
+ const resolved = externalResolutions?.get(entry.entryId);
516
+ return resolved?.value;
517
+ }
518
+
385
519
  function resolveIssueForScope(
386
520
  scopeId: string,
387
521
  workItemId: string | undefined,
388
522
  metadata: readonly WorkflowMetadataMarkup[] | undefined,
523
+ externalResolutions?: ReadonlyMap<string, { value: Record<string, unknown>; version?: number }>,
389
524
  ): IssueMetadataValue | undefined {
390
525
  if (!metadata || metadata.length === 0) return undefined;
391
526
  for (const entry of metadata) {
@@ -397,7 +532,7 @@ function resolveIssueForScope(
397
532
  const matchesScope =
398
533
  entry.scopeId !== undefined && entry.scopeId === scopeId;
399
534
  if (!matchesWorkItem && !matchesScope) continue;
400
- const coerced = coerceIssueValue(entry.value);
535
+ const coerced = coerceIssueValue(effectiveMetadataValue(entry, externalResolutions));
401
536
  if (coerced) return coerced;
402
537
  }
403
538
  return undefined;
@@ -426,3 +561,107 @@ function coerceIssueValue(
426
561
  }
427
562
  return candidate as IssueMetadataValue;
428
563
  }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // buildExternalResolutions — caller utility for pre-resolving external refs
567
+ // ---------------------------------------------------------------------------
568
+
569
+ /**
570
+ * Structured result from `buildExternalResolutions`. Callers that previously
571
+ * consumed the return value as a `Map` directly must now destructure `.resolutions`.
572
+ */
573
+ export interface BuildExternalResolutionsResult {
574
+ /**
575
+ * Resolved values keyed by `entryId`. Suitable for passing into
576
+ * `AttachScopeCardModelInput.externalResolutions`.
577
+ */
578
+ resolutions: Map<string, { value: Record<string, unknown>; version?: number }>;
579
+ /**
580
+ * Detected version mismatches. Non-empty only when the caller passes
581
+ * `detectConflicts: true`. Each record includes both sides' value +
582
+ * version so the caller can emit a `metadata_conflict_detected` event.
583
+ */
584
+ conflicts: Array<{
585
+ scopeId?: string;
586
+ entryId?: string;
587
+ embedded: { value?: Record<string, unknown>; version?: number } | null;
588
+ external: { value?: Record<string, unknown>; version?: number } | null;
589
+ }>;
590
+ }
591
+
592
+ /**
593
+ * Pre-resolve every external-mode metadata entry's value via the supplied
594
+ * resolver. The returned `resolutions` map is suitable for passing into
595
+ * `AttachScopeCardModelInput.externalResolutions`.
596
+ *
597
+ * Internal-mode entries (where `entry.value` is already inline) are skipped.
598
+ * When the resolver returns `undefined` for a ref (unknown or revoked), the
599
+ * `entryId` is omitted from the map; the scope card then renders without that
600
+ * value.
601
+ *
602
+ * Both `metadata` and `reviewActionMetadata` arrays are walked so review-action
603
+ * rows stored externally are resolved alongside issue entries.
604
+ *
605
+ * When `detectConflicts` is `true`, the helper compares each entry's embedded
606
+ * `metadataVersion` against the resolver-returned version. Any mismatch is
607
+ * recorded in the returned `conflicts` array.
608
+ *
609
+ * NOTE: This helper is intentionally async so callers can `await` it in the
610
+ * React layer before passing the map to the synchronous `attachScopeCardModel`.
611
+ * The layout facet's `getAllScopeCardModels()` does NOT call this — it is a
612
+ * React-layer concern where async is normal.
613
+ */
614
+ export async function buildExternalResolutions(input: {
615
+ metadata: readonly WorkflowMetadataMarkup[];
616
+ reviewActionMetadata?: readonly WorkflowMetadataMarkup[];
617
+ resolver: ScopeMetadataResolver;
618
+ /**
619
+ * When `true`, the helper compares each external entry's embedded
620
+ * `metadataVersion` against the resolver-returned version and records a
621
+ * conflict for every mismatch. Absent / `false` → `conflicts` is always
622
+ * empty (backwards-compatible default).
623
+ */
624
+ detectConflicts?: boolean;
625
+ }): Promise<BuildExternalResolutionsResult> {
626
+ const externalEntries = [
627
+ ...input.metadata,
628
+ ...(input.reviewActionMetadata ?? []),
629
+ ].filter(
630
+ (entry) =>
631
+ entry.metadataPersistence === "external" &&
632
+ entry.value === undefined &&
633
+ entry.storageRef !== undefined,
634
+ );
635
+
636
+ const results = await Promise.all(
637
+ externalEntries.map(async (entry) => {
638
+ const resolved = await input.resolver.resolve(entry.storageRef!);
639
+ return resolved ? { entry, resolved } : null;
640
+ }),
641
+ );
642
+
643
+ const resolutions = new Map<string, { value: Record<string, unknown>; version?: number }>();
644
+ const conflicts: BuildExternalResolutionsResult["conflicts"] = [];
645
+
646
+ for (const row of results) {
647
+ if (!row) continue;
648
+ resolutions.set(row.entry.entryId, row.resolved);
649
+
650
+ if (
651
+ input.detectConflicts &&
652
+ row.entry.metadataVersion !== undefined &&
653
+ row.resolved.version !== undefined &&
654
+ row.entry.metadataVersion !== row.resolved.version
655
+ ) {
656
+ conflicts.push({
657
+ scopeId: row.entry.scopeId,
658
+ entryId: row.entry.entryId,
659
+ // External entries have no inline value — embedded side carries only version
660
+ embedded: { version: row.entry.metadataVersion },
661
+ external: { value: row.resolved.value, version: row.resolved.version },
662
+ });
663
+ }
664
+ }
665
+
666
+ return { resolutions, conflicts };
667
+ }