@beyondwork/docx-react-component 1.0.71 → 1.0.73

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 (87) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +280 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/policy.ts +31 -0
  9. package/src/api/v3/ai/replacement.ts +8 -0
  10. package/src/api/v3/ai/review.ts +342 -0
  11. package/src/api/v3/ai/stats.ts +62 -0
  12. package/src/api/v3/runtime/viewport.ts +181 -0
  13. package/src/api/v3/runtime/workflow.ts +114 -1
  14. package/src/api/v3/ui/_types.ts +35 -0
  15. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  16. package/src/api/v3/ui/index.ts +1 -0
  17. package/src/api/v3/ui/viewport.ts +112 -0
  18. package/src/compare/diff-engine.ts +2 -0
  19. package/src/core/commands/formatting-commands.ts +1 -0
  20. package/src/core/commands/table-structure-commands.ts +1 -0
  21. package/src/core/state/editor-state.ts +49 -6
  22. package/src/io/export/serialize-footnotes.ts +6 -0
  23. package/src/io/export/serialize-headers-footers.ts +7 -0
  24. package/src/io/export/serialize-main-document.ts +20 -0
  25. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  26. package/src/io/export/split-review-boundaries.ts +1 -0
  27. package/src/io/normalize/normalize-text.ts +49 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +148 -7
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  31. package/src/model/canonical-document.ts +401 -1
  32. package/src/runtime/formatting/formatting-context.ts +2 -1
  33. package/src/runtime/geometry/overlay-rects.ts +7 -10
  34. package/src/runtime/layout/layout-engine-version.ts +278 -1
  35. package/src/runtime/layout/paginated-layout-engine.ts +181 -8
  36. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  37. package/src/runtime/markdown-sanitizer.ts +21 -4
  38. package/src/runtime/render/render-kernel.ts +21 -1
  39. package/src/runtime/scopes/action-validation.ts +30 -4
  40. package/src/runtime/scopes/audit-bundle.ts +8 -0
  41. package/src/runtime/scopes/compiler-service.ts +1 -0
  42. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  43. package/src/runtime/scopes/replacement/apply.ts +50 -3
  44. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  45. package/src/runtime/scopes/semantic-scope-types.ts +27 -0
  46. package/src/runtime/surface-projection.ts +77 -0
  47. package/src/runtime/workflow/coordinator.ts +3 -0
  48. package/src/runtime/workflow/scope-writer.ts +34 -0
  49. package/src/session/export/embedded-reconstitute.ts +37 -3
  50. package/src/session/import/embedded-offload.ts +26 -1
  51. package/src/session/import/loader-types.ts +18 -0
  52. package/src/session/import/loader.ts +2 -0
  53. package/src/shell/media-previews.ts +8 -6
  54. package/src/ui/WordReviewEditor.tsx +1 -0
  55. package/src/ui/editor-surface-controller.tsx +11 -0
  56. package/src/ui/headless/selection-helpers.ts +2 -2
  57. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  58. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  62. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  63. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  64. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  65. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  66. package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
  67. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  68. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  69. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  70. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
  71. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  72. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  73. package/src/ui-tailwind/index.ts +4 -2
  74. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  75. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
  76. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  77. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  78. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  79. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
  80. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
  81. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  82. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
  83. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  84. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  85. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  86. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  87. package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
@@ -72,11 +72,11 @@ const FONT_AVG_CHAR_WIDTH: Record<string, number> = {
72
72
  "palatino linotype": 5.5,
73
73
  "cambria": 5.3,
74
74
  // Proportional sans-serif (xAvgCharWidth ≈ 0.50-0.60 em)
75
- "arial": 5.8,
75
+ "arial": 4.9,
76
76
  "calibri": 5.0,
77
77
  "helvetica": 5.8,
78
78
  "verdana": 6.7,
79
- "tahoma": 5.9,
79
+ "tahoma": 6.3,
80
80
  "segoe ui": 5.4,
81
81
  "trebuchet ms": 5.6,
82
82
  // Monospace (every glyph ≈ 0.60-0.67 em)
@@ -157,9 +157,21 @@ export interface ResolvedTableRowFormatting {
157
157
  // Paragraph formatting resolution
158
158
  // ---------------------------------------------------------------------------
159
159
 
160
+ /**
161
+ * Resolved theme-font lookup. When a segment's
162
+ * `resolvedRunFormatting.*Theme` carries a slot reference (`minorHAnsi`,
163
+ * `majorAscii`, …) but no literal `fontFamily*` name, the dominant-font
164
+ * resolver consults these fallbacks. Source: `document.subParts?.resolvedTheme`.
165
+ */
166
+ export interface LayoutThemeFonts {
167
+ minorFont?: string;
168
+ majorFont?: string;
169
+ }
170
+
160
171
  export function resolveBlockFormatting(
161
172
  block: SurfaceBlockSnapshot,
162
173
  defaultTabInterval = 720,
174
+ themeFonts?: LayoutThemeFonts,
163
175
  ): ResolvedParagraphFormatting | null {
164
176
  if (block.kind !== "paragraph") {
165
177
  return null;
@@ -167,7 +179,7 @@ export function resolveBlockFormatting(
167
179
 
168
180
  const spacing = resolveSpacing(block);
169
181
  const indent = resolveIndentation(block);
170
- const fontInfo = resolveDominantFont(block);
182
+ const fontInfo = resolveDominantFont(block, themeFonts);
171
183
  const lineHeight = resolveLineHeight(spacing, fontInfo.fontSizeHalfPoints);
172
184
  const markerLane = block.resolvedNumbering?.geometry?.markerLane;
173
185
 
@@ -293,6 +305,7 @@ function resolveIndentation(
293
305
 
294
306
  function resolveDominantFont(
295
307
  block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
308
+ themeFonts?: LayoutThemeFonts,
296
309
  ): { fontSizeHalfPoints: number; avgCharWidth: number; fontFamily: string | undefined } {
297
310
  // L03 → L04 contract: read formatting from the canonical L03 output
298
311
  // (`segment.resolvedRunFormatting`) first; fall back to the surface
@@ -304,10 +317,27 @@ function resolveDominantFont(
304
317
  // to combine layers for performance" permits layout to ride the
305
318
  // surface snapshot for locality, but the authoritative values must
306
319
  // come from L03.
320
+ //
321
+ // Theme-slot fallback (Task A4 follow-up, 2026-04-23): when the L03
322
+ // cascade leaves `fontFamilyAscii` absent but carries an `asciiTheme`
323
+ // slot reference (ECMA-376 §17.3.2.26 — `<w:rFonts w:asciiTheme="minorHAnsi"/>`),
324
+ // resolve through `themeFonts` — mirrors L03's own
325
+ // `resolveRunFontFamily` behavior so the empirical measurement
326
+ // backend doesn't fall to the DEFAULT factor for theme-referenced
327
+ // paragraphs. CCEP templates with Tahoma/Calibri theme-bound bodies
328
+ // (e.g. `EU & Global IT Services SOW`) land here.
307
329
  let fontFamily: string | undefined;
308
330
  let fontSizeHalfPoints: number | undefined;
331
+ let resolvedViaTheme = false;
309
332
  let maxTextLength = 0;
310
333
 
334
+ const resolveThemeSlot = (
335
+ slot: string | undefined,
336
+ ): string | undefined => {
337
+ if (!slot || !themeFonts) return undefined;
338
+ return slot.startsWith("major") ? themeFonts.majorFont : themeFonts.minorFont;
339
+ };
340
+
311
341
  for (const segment of block.segments) {
312
342
  if (segment.kind !== "text") continue;
313
343
  const resolved = segment.resolvedRunFormatting;
@@ -315,11 +345,23 @@ function resolveDominantFont(
315
345
  // already layered docDefaults → paragraph style → character style →
316
346
  // direct overrides; the markAttrs fallback only applies when the
317
347
  // block was viewport-culled (resolvedRunFormatting absent).
318
- const segFontFamily =
319
- resolved?.fontFamilyAscii ??
320
- resolved?.fontFamily ??
321
- resolved?.fontFamilyHAnsi ??
322
- segment.markAttrs?.fontFamily;
348
+ // Script precedence: ascii → hAnsi → eastAsia → cs. Within each
349
+ // script, concrete family wins over theme slot.
350
+ let segResolvedViaTheme = false;
351
+ let segFontFamily: string | undefined;
352
+ if (resolved?.fontFamilyAscii) {
353
+ segFontFamily = resolved.fontFamilyAscii;
354
+ } else if (resolved?.asciiTheme && themeFonts) {
355
+ segFontFamily = resolveThemeSlot(resolved.asciiTheme);
356
+ if (segFontFamily) segResolvedViaTheme = true;
357
+ } else if (resolved?.fontFamilyHAnsi) {
358
+ segFontFamily = resolved.fontFamilyHAnsi;
359
+ } else if (resolved?.hAnsiTheme && themeFonts) {
360
+ segFontFamily = resolveThemeSlot(resolved.hAnsiTheme);
361
+ if (segFontFamily) segResolvedViaTheme = true;
362
+ } else {
363
+ segFontFamily = resolved?.fontFamily ?? segment.markAttrs?.fontFamily;
364
+ }
323
365
  const segFontSize =
324
366
  resolved?.fontSizeHalfPoints ?? segment.markAttrs?.fontSize;
325
367
  const textLength = segment.text.length;
@@ -328,6 +370,7 @@ function resolveDominantFont(
328
370
  maxTextLength = textLength;
329
371
  if (typeof segFontFamily === "string") {
330
372
  fontFamily = segFontFamily;
373
+ resolvedViaTheme = segResolvedViaTheme;
331
374
  }
332
375
  if (typeof segFontSize === "number") {
333
376
  fontSizeHalfPoints = segFontSize;
@@ -341,8 +384,32 @@ function resolveDominantFont(
341
384
  ? (FONT_AVG_CHAR_WIDTH[normalizedFamily] ?? DEFAULT_FONT_AVG_CHAR_WIDTH)
342
385
  : DEFAULT_FONT_AVG_CHAR_WIDTH;
343
386
 
387
+ // Theme-slot safety margin: xAvgCharWidth is derived from a fixed
388
+ // character-frequency sample and undercounts character-class-skewed
389
+ // content (legal-contract prose — heavy on capitalized defined terms
390
+ // and digits). When L03 left the paragraph bound to a theme slot and
391
+ // we resolved through `themeFonts`, the actual rendered width on these
392
+ // paragraphs is systematically wider than `xAvgCharWidth × chars`.
393
+ // Apply a safety multiplier — calibrated against 6 CCEP docs
394
+ // (coord-04 §1.18.2 regression finding). 2.4× is the minimum that
395
+ // restores `eu-global-it-services-sow` to its pre-v47 exact-parity
396
+ // (17/17) without regressing any of the 4 docs that won at v47.
397
+ // Concrete-family paragraphs keep the `xAvgCharWidth` factor untouched.
398
+ // The v49 safety multiplier was calibrated against a corpus where
399
+ // `<w:br w:type="page"/>` was being silently dropped by
400
+ // `src/io/normalize/normalize-text.ts::normalizeInlineChildren` (no
401
+ // `case "page_break"` → fell through). With the normalize fix landed
402
+ // alongside this change, real page-breaks now force pagination and
403
+ // SOW lands at 17/17 exact parity at multiplier 1.0 — i.e. theme-slot
404
+ // resolution is still useful for identifying the font, but the
405
+ // character-width factor does not need additional inflation.
406
+ const themeSafetyMultiplier = 1.0;
407
+ const effectiveFactor = resolvedViaTheme
408
+ ? charWidthFactor * themeSafetyMultiplier
409
+ : charWidthFactor;
410
+
344
411
  // Average char width in twips = factor * (fontSize in half-points)
345
- const avgCharWidth = Math.max(96, Math.round(charWidthFactor * effectiveSize));
412
+ const avgCharWidth = Math.max(96, Math.round(effectiveFactor * effectiveSize));
346
413
 
347
414
  return { fontSizeHalfPoints: effectiveSize, avgCharWidth, fontFamily };
348
415
  }
@@ -403,11 +470,39 @@ function resolveTabStops(
403
470
  leader: tab.leader,
404
471
  });
405
472
 
406
- if (numSurfaceTabs && numSurfaceTabs.length > 0 && surfaceTabs && surfaceTabs.length > 0) {
473
+ // Style-cascaded tab stops (coord-04 §1.19.a). L03's
474
+ // `resolveEffectiveParagraphFormatting` merges `docDefaults.paragraph.tabs`
475
+ // → basedOn-chain style tabs → direct `w:tabs`, with direct winning on
476
+ // conflicting positions. The resolved merged set is deposited on
477
+ // `block.resolvedParagraphFormatting.tabStops` in canonical shape
478
+ // (`{ position, align, leader }`). Paragraphs styled TOC1/TOC2/etc.
479
+ // typically have no direct `w:tabs`; the style cascade is the only
480
+ // source. Without this fallback, TOC entries measured under L04 behave
481
+ // as if no tab-stops exist — right-aligned page-number columns get no
482
+ // dedicated tab-stop and the paragraph measurement collapses the tab
483
+ // advance to the default tab interval.
484
+ const cascadeTabs = block.resolvedParagraphFormatting?.tabStops;
485
+ const normalizeCanonical = (tab: {
486
+ position: number;
487
+ align: string;
488
+ leader?: string;
489
+ }): LayoutTabStop => ({
490
+ position: tab.position,
491
+ align: tab.align,
492
+ leader: tab.leader,
493
+ });
494
+
495
+ const paraTabs: LayoutTabStop[] = surfaceTabs && surfaceTabs.length > 0
496
+ ? surfaceTabs.map(normalize)
497
+ : cascadeTabs && cascadeTabs.length > 0
498
+ ? cascadeTabs.map(normalizeCanonical)
499
+ : [];
500
+
501
+ if (numSurfaceTabs && numSurfaceTabs.length > 0 && paraTabs.length > 0) {
407
502
  const numPositions = new Set(numSurfaceTabs.map((t) => t.pos));
408
503
  const merged = [
409
504
  ...numSurfaceTabs.map(normalize),
410
- ...surfaceTabs.filter((t) => !numPositions.has(t.pos)).map(normalize),
505
+ ...paraTabs.filter((t) => !numPositions.has(t.position)),
411
506
  ];
412
507
  return merged.sort((a, b) => a.position - b.position);
413
508
  }
@@ -416,8 +511,8 @@ function resolveTabStops(
416
511
  return numSurfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
417
512
  }
418
513
 
419
- if (surfaceTabs && surfaceTabs.length > 0) {
420
- return surfaceTabs.map(normalize).sort((a, b) => a.position - b.position);
514
+ if (paraTabs.length > 0) {
515
+ return [...paraTabs].sort((a, b) => a.position - b.position);
421
516
  }
422
517
 
423
518
  return [];
@@ -47,17 +47,34 @@ export function sanitizeMarkdown(raw: string): SanitizeResult {
47
47
  (_match, addr: string) => `<${addr}>`,
48
48
  );
49
49
 
50
+ // SEC-UI-01 (2026-04-23): strip HTML tags strictly.
51
+ //
52
+ // Prior implementation preserved any match containing `://`, `@`, or `:` as
53
+ // a heuristic for autolinks. That let `<img onerror="alert(1)" src="x:y">`
54
+ // and `<a href="javascript:alert(1)">` and SVG `<use xlink:href="..."/>`
55
+ // through — the colon in an attribute value triggered preservation.
56
+ //
57
+ // The autolink pre-passes above (scheme URI + email form) already leave
58
+ // proper autolinks as `<scheme:target>` or `<user@host>`. They are the only
59
+ // angle-bracketed forms that should survive this strip. Anything else —
60
+ // anything with attributes, anything with whitespace inside the brackets,
61
+ // anything with quoted attribute content — is HTML-shaped and must be
62
+ // removed regardless of whether its internals happen to contain `:` / `@`.
63
+ const AUTOLINK_SCHEME_RE =
64
+ /^<[a-zA-Z][a-zA-Z0-9+.-]*:[^\s<>"'`]+>$/;
65
+ const AUTOLINK_EMAIL_RE =
66
+ /^<[A-Za-z0-9][A-Za-z0-9._%+-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,}>$/;
50
67
  const strippedHtml = text.replace(
51
68
  // Require a recognisable HTML tag name so we do not strip safe
52
69
  // autolinks that survived the pass above (email autolinks, bare URIs).
53
70
  /<\/?([A-Za-z][A-Za-z0-9-]*)(\s[^>]*)?\/?>/g,
54
71
  (match, tagName: string) => {
55
- // Skip angle-bracket content that looks like an autolink we just
56
- // approved (email or scheme URI).
57
- if (match.includes("://") || match.includes("@") || match.includes(":")) {
72
+ // Preserve ONLY pure autolink shape. Any attributes / whitespace /
73
+ // quotes definitely HTML, strip.
74
+ if (AUTOLINK_SCHEME_RE.test(match) || AUTOLINK_EMAIL_RE.test(match)) {
58
75
  return match;
59
76
  }
60
- // Also skip plain URIs like `<example.com>` — rare, but preserve.
77
+ // Preserve plain URIs like `<example.com>` — rare, but benign.
61
78
  if (/^[a-z]+\.[a-z]/.test(tagName)) {
62
79
  return match;
63
80
  }
@@ -353,7 +353,27 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
353
353
  // Internals
354
354
  // ---------------------------------------------------------------------------
355
355
 
356
- const PAGE_GAP_PX = 16;
356
+ /**
357
+ * Inter-page gap in CSS pixels — the vertical gutter the kernel reserves
358
+ * between adjacent page frames in `RenderPage[]` Y coordinates.
359
+ *
360
+ * **Reconciled to 48 px on 2026-04-23 (refactor/10 Slice L11-3, delegated
361
+ * from refactor/05 closureBlockers.16/48px-gap-reconciliation +
362
+ * refactor/05 handover §5 deferred item 1).** Previously 16 px while the
363
+ * DOM page-break widget at `pm-page-break-decorations.ts:38` rendered a
364
+ * 48 px inter-page gap, causing a 32 px-per-page-boundary drift between
365
+ * kernel-reported `frame.pages[i].topPx` and the actual painted scroll
366
+ * position. Drift was previously compensated for by the chrome layer's
367
+ * DOM-measurement fallback path (overlay-rects "experimental" notice
368
+ * + scroll-anchor's `prepaintFallback`); reconciliation moves the
369
+ * geometry-direct warm path from "experimental" to "production".
370
+ *
371
+ * Consumer impact: every `topPx` value on `frame.pages[i]` for `i >= 1`
372
+ * shifts by +32 px per preceding page boundary. `LAYOUT_ENGINE_VERSION`
373
+ * bumped in the same commit so persisted laycache envelopes auto-
374
+ * invalidate.
375
+ */
376
+ const PAGE_GAP_PX = 48;
357
377
 
358
378
  function buildPage(
359
379
  page: PublicPageNode,
@@ -111,6 +111,14 @@ export interface ComposeScopeValidationInputs {
111
111
  * no blockers / warnings / approval.
112
112
  */
113
113
  readonly actionId?: AIAction;
114
+ /**
115
+ * Caller-opt-in preservation policy. Honored at the preservation step
116
+ * only — when `opaqueFragments === true`, opaque-fragment findings
117
+ * downgrade from blockers to warnings (still surfaced on
118
+ * `validation.warnings` + audit). Other policy flags are advisory
119
+ * here; the compile step consumes them to drive per-step behavior.
120
+ */
121
+ readonly preservePolicy?: ReplacementScope["preserve"];
114
122
  }
115
123
 
116
124
  /**
@@ -240,6 +248,7 @@ function collectGuardVerdict(
240
248
  function collectPreservationVerdict(
241
249
  inputs: ComposeScopeValidationInputs,
242
250
  blockedReasons: string[],
251
+ warnings: ValidationIssue[],
243
252
  ): void {
244
253
  const { document, scope, positionMap } = inputs;
245
254
  if (!document) return;
@@ -250,10 +259,27 @@ function collectPreservationVerdict(
250
259
  ? (pm.markerScopes.get(scope.handle.stableRef.value) ?? null)
251
260
  : null;
252
261
  const verdict = computePreservationVerdict(document, range, pm);
253
- if (!verdict.replaceable) {
254
- for (const reason of verdict.reasons) {
255
- blockedReasons.push(`preserve:${reason}`);
262
+ if (verdict.replaceable) return;
263
+ const opaqueOptIn = inputs.preservePolicy?.opaqueFragments === true;
264
+ for (const reason of verdict.reasons) {
265
+ const code = `preserve:${reason}`;
266
+ // Opt-in downgrade (2026-04-24): opaque-fragment reasons move from
267
+ // blockers → warnings when the caller opted in via
268
+ // `preservePolicy.opaqueFragments === true`. Scope-marker-inside
269
+ // reasons stay as blockers — they'd require the sibling scope to be
270
+ // destroyed, which is a different kind of safety (scope-identity,
271
+ // not preserve-only payload). Other preserve reasons keep blocker
272
+ // semantics until they grow their own opt-in knob.
273
+ if (opaqueOptIn && reason.startsWith("opaque-fragment:")) {
274
+ warnings.push({
275
+ code,
276
+ message:
277
+ "Opaque fragment present in target range; caller opted in to preserve — compile will narrow the replace range to text-only.",
278
+ source: "preserve",
279
+ });
280
+ continue;
256
281
  }
282
+ blockedReasons.push(code);
257
283
  }
258
284
  }
259
285
 
@@ -339,7 +365,7 @@ export function composeScopeValidation(
339
365
  const warnings: ValidationIssue[] = [];
340
366
 
341
367
  collectGuardVerdict(inputs.scope, inputs.runtime, blockedReasons, warnings);
342
- collectPreservationVerdict(inputs, blockedReasons);
368
+ collectPreservationVerdict(inputs, blockedReasons, warnings);
343
369
  collectCompatibilityVerdict(inputs.runtime, blockedReasons, warnings);
344
370
 
345
371
  const actionId =
@@ -29,6 +29,11 @@ export interface EmitScopeActionAuditInputs {
29
29
  readonly validation: ValidationResult;
30
30
  readonly emittedAtUtc: string;
31
31
  readonly bus?: TelemetryBus;
32
+ /**
33
+ * Gap A — revision IDs the runtime authored during this apply.
34
+ * Populated for suggest-mode dispatch; omitted for direct-edit.
35
+ */
36
+ readonly authoredRevisionIds?: readonly string[];
32
37
  }
33
38
 
34
39
  export function buildScopeActionAudit(
@@ -52,6 +57,9 @@ export function buildScopeActionAudit(
52
57
  ),
53
58
  validation: inputs.validation,
54
59
  emittedAtUtc: inputs.emittedAtUtc,
60
+ ...(inputs.authoredRevisionIds && inputs.authoredRevisionIds.length > 0
61
+ ? { authoredRevisionIds: Object.freeze([...inputs.authoredRevisionIds]) }
62
+ : {}),
55
63
  };
56
64
  }
57
65
 
@@ -339,6 +339,7 @@ export function createScopeCompilerService(
339
339
  ]),
340
340
  warnings: Object.freeze([]),
341
341
  },
342
+ authoredRevisionIds: Object.freeze([]),
342
343
  };
343
344
  }
344
345
  // Caller-supplied proposedContent takes precedence (structured-
@@ -256,6 +256,46 @@ function buildClassificationIndex(
256
256
  return out;
257
257
  }
258
258
 
259
+ /**
260
+ * Coord-08 §9 / A3 — read a caller-set `stableRefHint` off an overlay
261
+ * scope's metadata. Returns the requested strategy when the field is
262
+ * present AND feasible for the compiler to honor, `null` otherwise
263
+ * (falls back to the default selection).
264
+ *
265
+ * Feasibility matrix:
266
+ * - `"scope-id"` / `"semantic-path"` — always feasible.
267
+ * - `"bookmark"` — requires a bookmark registered at the block; no
268
+ * bookmark-lookup is wired in phase 1, so falls back.
269
+ * - `"runtime-handle"` — not a persistent strategy; ignored.
270
+ * - Unknown string — ignored.
271
+ */
272
+ function stableRefHintForScopeId(
273
+ scopeId: string,
274
+ overlay: WorkflowOverlay | null | undefined,
275
+ ): ScopeHandle["stableRef"]["kind"] | null {
276
+ if (!overlay) return null;
277
+ const scope = overlay.scopes.find(
278
+ (s: WorkflowScope) => s.scopeId === scopeId,
279
+ );
280
+ if (!scope || !scope.metadata) return null;
281
+ const hintField = scope.metadata.find(
282
+ (f: WorkflowScopeMetadataField) => f.key === "stableRefHint",
283
+ );
284
+ if (!hintField || typeof hintField.value !== "string") return null;
285
+ switch (hintField.value) {
286
+ case "scope-id":
287
+ case "semantic-path":
288
+ return hintField.value;
289
+ case "bookmark":
290
+ case "runtime-handle":
291
+ // Declared-but-not-implementable: fall back. Adding bookmark
292
+ // lookup is a phase-2 extension; see coord-08 §9.
293
+ return null;
294
+ default:
295
+ return null;
296
+ }
297
+ }
298
+
259
299
  /**
260
300
  * Walk paragraph children and return the first scope-marker id that
261
301
  * *starts* inside the paragraph. When a paragraph carries multiple
@@ -417,6 +457,26 @@ export function enumerateScopes(
417
457
  markerScopeId !== null ? "marker-backed" : "derived";
418
458
  const rangePrecision: ScopeHandle["rangePrecision"] =
419
459
  markerScopeId !== null ? "marker-backed" : "canonical";
460
+ // Coord-08 §9 — honor caller-set stableRefHint when present. Only
461
+ // consulted on marker-backed paragraphs (derived scopes have no
462
+ // overlay entry to carry a hint).
463
+ const hint =
464
+ markerScopeId !== null
465
+ ? stableRefHintForScopeId(markerScopeId, inputs.overlay)
466
+ : null;
467
+ const defaultMarkerStable =
468
+ markerScopeId !== null
469
+ ? ({ kind: "scope-id", value: markerScopeId } as const)
470
+ : undefined;
471
+ const stableRefOverride =
472
+ hint === "semantic-path"
473
+ ? ({
474
+ kind: "semantic-path" as const,
475
+ value: semanticPath.join("/"),
476
+ })
477
+ : hint === "scope-id" && markerScopeId !== null
478
+ ? ({ kind: "scope-id" as const, value: markerScopeId })
479
+ : defaultMarkerStable;
420
480
  const handle = buildHandle(
421
481
  scopeId,
422
482
  documentId,
@@ -424,9 +484,7 @@ export function enumerateScopes(
424
484
  provenance,
425
485
  rangePrecision,
426
486
  undefined,
427
- markerScopeId !== null
428
- ? { kind: "scope-id", value: markerScopeId }
429
- : undefined,
487
+ stableRefOverride,
430
488
  );
431
489
  const classifications =
432
490
  markerScopeId !== null
@@ -66,6 +66,17 @@ export interface ApplyScopeReplacementResult {
66
66
  readonly audit?: ScopeActionAudit;
67
67
  readonly plan?: RuntimeOperationPlan;
68
68
  readonly scope?: SemanticScope;
69
+ /**
70
+ * Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
71
+ * by the runtime during this apply. Populated for suggest-mode
72
+ * dispatch (where `text-insert-tracked` / `text-delete-tracked`
73
+ * commands author `RevisionRecord` entries in
74
+ * `review.revisions`); empty array for direct-edit (no revisions
75
+ * authored). Agents can pass each id into `ai.acceptRevision` /
76
+ * `ai.rejectRevision` to land or discard the proposal without
77
+ * having to diff the runtime's revision map themselves.
78
+ */
79
+ readonly authoredRevisionIds: readonly string[];
69
80
  }
70
81
 
71
82
  function documentHash(doc: CanonicalDocumentEnvelope): string {
@@ -117,7 +128,12 @@ export function applyScopeReplacement(
117
128
  ]),
118
129
  warnings: Object.freeze([]),
119
130
  };
120
- return { applied: false, reason: "scope-not-resolvable", validation };
131
+ return {
132
+ applied: false,
133
+ reason: "scope-not-resolvable",
134
+ validation,
135
+ authoredRevisionIds: Object.freeze([]),
136
+ };
121
137
  }
122
138
 
123
139
  const verdict = composeScopeValidation({
@@ -131,6 +147,7 @@ export function applyScopeReplacement(
131
147
  document: docBefore,
132
148
  enumeratedScope: resolvedEnumerated,
133
149
  ...(inputs.actionId ? { actionId: inputs.actionId } : {}),
150
+ ...(proposed.preserve ? { preservePolicy: proposed.preserve } : {}),
134
151
  });
135
152
 
136
153
  if (!verdict.safe) {
@@ -139,6 +156,7 @@ export function applyScopeReplacement(
139
156
  reason: "validation-blocked",
140
157
  validation: verdict,
141
158
  scope: resolvedScope,
159
+ authoredRevisionIds: Object.freeze([]),
142
160
  };
143
161
  }
144
162
 
@@ -190,19 +208,44 @@ export function applyScopeReplacement(
190
208
  blockedReasons: Object.freeze([blocker]),
191
209
  warnings: verdict.warnings,
192
210
  };
211
+ // Coord-08 U5 — `reason` mirrors `blockers[0]` for symmetry. Agents
212
+ // reading either channel at the top level see the same
213
+ // most-actionable signal (`compile-refused:<kind>[:operation-not-
214
+ // implemented:<op>]`). Pre-U5 (before 2026-04-23) `reason` was the
215
+ // bare prefix `"compile-refused"` — callers had to descend into
216
+ // `validation.blockedReasons[0]` to recover the suffix.
193
217
  return {
194
218
  applied: false,
195
- reason: "compile-refused",
219
+ reason: blocker,
196
220
  validation: refused,
197
221
  scope: resolvedScope,
222
+ authoredRevisionIds: Object.freeze([]),
198
223
  };
199
224
  }
200
225
 
201
226
  const documentHashBefore = documentHash(docBefore);
202
227
 
228
+ // Gap A — capture revision-map keys before dispatch so we can diff
229
+ // post-apply and surface the authored ids to agents. Suggest-mode
230
+ // dispatch (text-insert-tracked / text-delete-tracked) authors
231
+ // insertion + deletion `RevisionRecord` entries in
232
+ // `canonicalDocument.review.revisions`; direct-edit dispatch touches
233
+ // no revisions. The diff approach is side-channel-free and doesn't
234
+ // require plumbing a new return value through the runtime's
235
+ // applyTextCommandInActiveStory seam.
236
+ const revisionsBefore = new Set(
237
+ Object.keys(docBefore.review.revisions ?? {}),
238
+ );
239
+
203
240
  inputs.sink.applyScopeReplacement(plan);
204
241
 
205
- const documentHashAfter = documentHash(inputs.sink.getCanonicalDocument());
242
+ const docAfter = inputs.sink.getCanonicalDocument();
243
+ const documentHashAfter = documentHash(docAfter);
244
+
245
+ const authoredRevisionIds: string[] = [];
246
+ for (const id of Object.keys(docAfter.review.revisions ?? {})) {
247
+ if (!revisionsBefore.has(id)) authoredRevisionIds.push(id);
248
+ }
206
249
 
207
250
  const audit = emitScopeActionAudit({
208
251
  actionId: inputs.actionId ?? "replacement",
@@ -215,6 +258,9 @@ export function applyScopeReplacement(
215
258
  plan,
216
259
  validation: verdict,
217
260
  emittedAtUtc: inputs.emittedAtUtc,
261
+ ...(authoredRevisionIds.length > 0
262
+ ? { authoredRevisionIds }
263
+ : {}),
218
264
  ...(inputs.bus ? { bus: inputs.bus } : {}),
219
265
  });
220
266
 
@@ -224,5 +270,6 @@ export function applyScopeReplacement(
224
270
  audit,
225
271
  plan,
226
272
  scope: resolvedScope,
273
+ authoredRevisionIds: Object.freeze([...authoredRevisionIds]),
227
274
  };
228
275
  }