@fluidframework/container-runtime 2.117.0 → 3.0.0

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 (326) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/README.md +53 -50
  3. package/{api-extractor.json → api-extractor/api-extractor-model.json} +1 -1
  4. package/api-report/container-runtime.legacy.alpha.api.md +3 -1
  5. package/api-report/container-runtime.legacy.beta.api.md +3 -1
  6. package/container-runtime.test-files.tar +0 -0
  7. package/dist/batchTracker.d.ts.map +1 -1
  8. package/dist/batchTracker.js +5 -1
  9. package/dist/batchTracker.js.map +1 -1
  10. package/dist/blobManager/blobManager.d.ts +1 -1
  11. package/dist/blobManager/blobManager.d.ts.map +1 -1
  12. package/dist/blobManager/blobManager.js +264 -231
  13. package/dist/blobManager/blobManager.js.map +1 -1
  14. package/dist/blobManager/blobManagerSnapSum.d.ts.map +1 -1
  15. package/dist/channelCollection.d.ts.map +1 -1
  16. package/dist/channelCollection.js +70 -58
  17. package/dist/channelCollection.js.map +1 -1
  18. package/dist/connectionTelemetry.js +38 -20
  19. package/dist/connectionTelemetry.js.map +1 -1
  20. package/dist/containerCompatibility.d.ts +3 -3
  21. package/dist/containerCompatibility.d.ts.map +1 -1
  22. package/dist/containerCompatibility.js +36 -45
  23. package/dist/containerCompatibility.js.map +1 -1
  24. package/dist/containerHandleContext.d.ts.map +1 -1
  25. package/dist/containerHandleContext.js +4 -0
  26. package/dist/containerHandleContext.js.map +1 -1
  27. package/dist/containerRuntime.d.ts +4 -4
  28. package/dist/containerRuntime.d.ts.map +1 -1
  29. package/dist/containerRuntime.js +346 -223
  30. package/dist/containerRuntime.js.map +1 -1
  31. package/dist/dataStore.d.ts.map +1 -1
  32. package/dist/dataStore.js +9 -1
  33. package/dist/dataStore.js.map +1 -1
  34. package/dist/dataStoreContext.d.ts.map +1 -1
  35. package/dist/dataStoreContext.js +184 -127
  36. package/dist/dataStoreContext.js.map +1 -1
  37. package/dist/dataStoreContexts.js +43 -42
  38. package/dist/dataStoreContexts.js.map +1 -1
  39. package/dist/dataStoreRegistry.js +1 -0
  40. package/dist/dataStoreRegistry.js.map +1 -1
  41. package/dist/deltaManagerProxies.js +5 -0
  42. package/dist/deltaManagerProxies.js.map +1 -1
  43. package/dist/deltaScheduler.js +104 -99
  44. package/dist/deltaScheduler.js.map +1 -1
  45. package/dist/error.js +2 -1
  46. package/dist/error.js.map +1 -1
  47. package/dist/gc/garbageCollection.js +85 -55
  48. package/dist/gc/garbageCollection.js.map +1 -1
  49. package/dist/gc/gcConfigs.js +2 -3
  50. package/dist/gc/gcConfigs.js.map +1 -1
  51. package/dist/gc/gcHelpers.js +12 -13
  52. package/dist/gc/gcHelpers.js.map +1 -1
  53. package/dist/gc/gcReferenceGraphAlgorithm.js +1 -2
  54. package/dist/gc/gcReferenceGraphAlgorithm.js.map +1 -1
  55. package/dist/gc/gcSummaryStateTracker.js +8 -3
  56. package/dist/gc/gcSummaryStateTracker.js.map +1 -1
  57. package/dist/gc/gcTelemetry.js +12 -5
  58. package/dist/gc/gcTelemetry.js.map +1 -1
  59. package/dist/gc/gcUnreferencedStateTracker.js +18 -1
  60. package/dist/gc/gcUnreferencedStateTracker.js.map +1 -1
  61. package/dist/inboundBatchAggregator.js +122 -117
  62. package/dist/inboundBatchAggregator.js.map +1 -1
  63. package/dist/metadata.d.ts.map +1 -1
  64. package/dist/metadata.js +3 -3
  65. package/dist/metadata.js.map +1 -1
  66. package/dist/opLifecycle/batchManager.d.ts.map +1 -1
  67. package/dist/opLifecycle/batchManager.js +11 -5
  68. package/dist/opLifecycle/batchManager.js.map +1 -1
  69. package/dist/opLifecycle/duplicateBatchDetector.js +18 -18
  70. package/dist/opLifecycle/duplicateBatchDetector.js.map +1 -1
  71. package/dist/opLifecycle/index.d.ts +2 -2
  72. package/dist/opLifecycle/index.d.ts.map +1 -1
  73. package/dist/opLifecycle/index.js +2 -1
  74. package/dist/opLifecycle/index.js.map +1 -1
  75. package/dist/opLifecycle/opCompressor.js +2 -1
  76. package/dist/opLifecycle/opCompressor.js.map +1 -1
  77. package/dist/opLifecycle/opDecompressor.js +10 -6
  78. package/dist/opLifecycle/opDecompressor.js.map +1 -1
  79. package/dist/opLifecycle/opGroupingManager.js +5 -3
  80. package/dist/opLifecycle/opGroupingManager.js.map +1 -1
  81. package/dist/opLifecycle/opSerialization.d.ts +11 -1
  82. package/dist/opLifecycle/opSerialization.d.ts.map +1 -1
  83. package/dist/opLifecycle/opSerialization.js +22 -6
  84. package/dist/opLifecycle/opSerialization.js.map +1 -1
  85. package/dist/opLifecycle/opSplitter.d.ts +17 -1
  86. package/dist/opLifecycle/opSplitter.d.ts.map +1 -1
  87. package/dist/opLifecycle/opSplitter.js +46 -9
  88. package/dist/opLifecycle/opSplitter.js.map +1 -1
  89. package/dist/opLifecycle/outbox.d.ts.map +1 -1
  90. package/dist/opLifecycle/outbox.js +17 -13
  91. package/dist/opLifecycle/outbox.js.map +1 -1
  92. package/dist/opLifecycle/remoteMessageProcessor.js +6 -3
  93. package/dist/opLifecycle/remoteMessageProcessor.js.map +1 -1
  94. package/dist/opProperties.d.ts.map +1 -1
  95. package/dist/packageVersion.d.ts +1 -1
  96. package/dist/packageVersion.d.ts.map +1 -1
  97. package/dist/packageVersion.js +1 -1
  98. package/dist/packageVersion.js.map +1 -1
  99. package/dist/pendingStateManager.js +54 -47
  100. package/dist/pendingStateManager.js.map +1 -1
  101. package/dist/runCounter.js +10 -30
  102. package/dist/runCounter.js.map +1 -1
  103. package/dist/runtimeLayerCompatState.d.ts +1 -1
  104. package/dist/runtimeLayerCompatState.d.ts.map +1 -1
  105. package/dist/runtimeLayerCompatState.js +31 -11
  106. package/dist/runtimeLayerCompatState.js.map +1 -1
  107. package/dist/signalTelemetryProcessing.js +15 -17
  108. package/dist/signalTelemetryProcessing.js.map +1 -1
  109. package/dist/storageServiceWithAttachBlobs.js +2 -0
  110. package/dist/storageServiceWithAttachBlobs.js.map +1 -1
  111. package/dist/summary/documentSchema.d.ts.map +1 -1
  112. package/dist/summary/documentSchema.js +51 -13
  113. package/dist/summary/documentSchema.js.map +1 -1
  114. package/dist/summary/orderedClientElection.js +25 -17
  115. package/dist/summary/orderedClientElection.js.map +1 -1
  116. package/dist/summary/summarizerClientElection.d.ts.map +1 -1
  117. package/dist/summary/summarizerClientElection.js +18 -7
  118. package/dist/summary/summarizerClientElection.js.map +1 -1
  119. package/dist/summary/summarizerNode/summarizerNode.d.ts.map +1 -1
  120. package/dist/summary/summarizerNode/summarizerNode.js +22 -13
  121. package/dist/summary/summarizerNode/summarizerNode.js.map +1 -1
  122. package/dist/summary/summarizerNode/summarizerNodeWithGc.d.ts.map +1 -1
  123. package/dist/summary/summarizerNode/summarizerNodeWithGc.js +20 -6
  124. package/dist/summary/summarizerNode/summarizerNodeWithGc.js.map +1 -1
  125. package/dist/summary/summarizerUtils.d.ts.map +1 -1
  126. package/dist/summary/summarizerUtils.js +4 -3
  127. package/dist/summary/summarizerUtils.js.map +1 -1
  128. package/dist/summary/summaryCollection.js +23 -11
  129. package/dist/summary/summaryCollection.js.map +1 -1
  130. package/dist/summary/summaryDelayLoadedModule/runWhileConnectedCoordinator.js +4 -2
  131. package/dist/summary/summaryDelayLoadedModule/runWhileConnectedCoordinator.js.map +1 -1
  132. package/dist/summary/summaryDelayLoadedModule/runningSummarizer.d.ts.map +1 -1
  133. package/dist/summary/summaryDelayLoadedModule/runningSummarizer.js +65 -46
  134. package/dist/summary/summaryDelayLoadedModule/runningSummarizer.js.map +1 -1
  135. package/dist/summary/summaryDelayLoadedModule/summarizer.d.ts.map +1 -1
  136. package/dist/summary/summaryDelayLoadedModule/summarizer.js +15 -6
  137. package/dist/summary/summaryDelayLoadedModule/summarizer.js.map +1 -1
  138. package/dist/summary/summaryDelayLoadedModule/summarizerHeuristics.js +30 -25
  139. package/dist/summary/summaryDelayLoadedModule/summarizerHeuristics.js.map +1 -1
  140. package/dist/summary/summaryDelayLoadedModule/summaryGenerator.js +9 -0
  141. package/dist/summary/summaryDelayLoadedModule/summaryGenerator.js.map +1 -1
  142. package/dist/summary/summaryDelayLoadedModule/summaryResultBuilder.js +3 -5
  143. package/dist/summary/summaryDelayLoadedModule/summaryResultBuilder.js.map +1 -1
  144. package/dist/summary/summaryFormat.d.ts.map +1 -1
  145. package/dist/summary/summaryFormat.js +7 -7
  146. package/dist/summary/summaryFormat.js.map +1 -1
  147. package/dist/summary/summaryHelpers.js +5 -5
  148. package/dist/summary/summaryHelpers.js.map +1 -1
  149. package/dist/summary/summaryManager.js +66 -54
  150. package/dist/summary/summaryManager.js.map +1 -1
  151. package/dist/throttler.d.ts.map +1 -1
  152. package/dist/throttler.js +4 -1
  153. package/dist/throttler.js.map +1 -1
  154. package/dist/versionMarks/inboundBatch.d.ts +8 -6
  155. package/dist/versionMarks/inboundBatch.d.ts.map +1 -1
  156. package/dist/versionMarks/inboundBatch.js +11 -7
  157. package/dist/versionMarks/inboundBatch.js.map +1 -1
  158. package/dist/versionMarks/versionMarkResolver.d.ts +30 -23
  159. package/dist/versionMarks/versionMarkResolver.d.ts.map +1 -1
  160. package/dist/versionMarks/versionMarkResolver.js +112 -63
  161. package/dist/versionMarks/versionMarkResolver.js.map +1 -1
  162. package/lib/batchTracker.d.ts.map +1 -1
  163. package/lib/batchTracker.js +5 -1
  164. package/lib/batchTracker.js.map +1 -1
  165. package/lib/blobManager/blobManager.d.ts +1 -1
  166. package/lib/blobManager/blobManager.d.ts.map +1 -1
  167. package/lib/blobManager/blobManager.js +264 -231
  168. package/lib/blobManager/blobManager.js.map +1 -1
  169. package/lib/blobManager/blobManagerSnapSum.d.ts.map +1 -1
  170. package/lib/channelCollection.d.ts.map +1 -1
  171. package/lib/channelCollection.js +65 -53
  172. package/lib/channelCollection.js.map +1 -1
  173. package/lib/connectionTelemetry.js +36 -18
  174. package/lib/connectionTelemetry.js.map +1 -1
  175. package/lib/containerCompatibility.d.ts +3 -3
  176. package/lib/containerCompatibility.d.ts.map +1 -1
  177. package/lib/containerCompatibility.js +33 -42
  178. package/lib/containerCompatibility.js.map +1 -1
  179. package/lib/containerHandleContext.d.ts.map +1 -1
  180. package/lib/containerHandleContext.js +4 -0
  181. package/lib/containerHandleContext.js.map +1 -1
  182. package/lib/containerRuntime.d.ts +4 -4
  183. package/lib/containerRuntime.d.ts.map +1 -1
  184. package/lib/containerRuntime.js +341 -218
  185. package/lib/containerRuntime.js.map +1 -1
  186. package/lib/dataStore.d.ts.map +1 -1
  187. package/lib/dataStore.js +9 -1
  188. package/lib/dataStore.js.map +1 -1
  189. package/lib/dataStoreContext.d.ts.map +1 -1
  190. package/lib/dataStoreContext.js +182 -125
  191. package/lib/dataStoreContext.js.map +1 -1
  192. package/lib/dataStoreContexts.js +43 -42
  193. package/lib/dataStoreContexts.js.map +1 -1
  194. package/lib/dataStoreRegistry.js +1 -0
  195. package/lib/dataStoreRegistry.js.map +1 -1
  196. package/lib/deltaManagerProxies.js +5 -0
  197. package/lib/deltaManagerProxies.js.map +1 -1
  198. package/lib/deltaScheduler.js +104 -99
  199. package/lib/deltaScheduler.js.map +1 -1
  200. package/lib/error.js +2 -1
  201. package/lib/error.js.map +1 -1
  202. package/lib/gc/garbageCollection.js +85 -55
  203. package/lib/gc/garbageCollection.js.map +1 -1
  204. package/lib/gc/gcSummaryStateTracker.js +8 -3
  205. package/lib/gc/gcSummaryStateTracker.js.map +1 -1
  206. package/lib/gc/gcTelemetry.js +12 -5
  207. package/lib/gc/gcTelemetry.js.map +1 -1
  208. package/lib/gc/gcUnreferencedStateTracker.js +18 -1
  209. package/lib/gc/gcUnreferencedStateTracker.js.map +1 -1
  210. package/lib/inboundBatchAggregator.js +122 -117
  211. package/lib/inboundBatchAggregator.js.map +1 -1
  212. package/lib/legacy.d.ts +1 -1
  213. package/lib/legacyAlpha.d.ts +1 -1
  214. package/lib/metadata.d.ts.map +1 -1
  215. package/lib/opLifecycle/batchManager.d.ts.map +1 -1
  216. package/lib/opLifecycle/batchManager.js +8 -2
  217. package/lib/opLifecycle/batchManager.js.map +1 -1
  218. package/lib/opLifecycle/duplicateBatchDetector.js +18 -18
  219. package/lib/opLifecycle/duplicateBatchDetector.js.map +1 -1
  220. package/lib/opLifecycle/index.d.ts +2 -2
  221. package/lib/opLifecycle/index.d.ts.map +1 -1
  222. package/lib/opLifecycle/index.js +2 -2
  223. package/lib/opLifecycle/index.js.map +1 -1
  224. package/lib/opLifecycle/opCompressor.js +3 -2
  225. package/lib/opLifecycle/opCompressor.js.map +1 -1
  226. package/lib/opLifecycle/opDecompressor.js +10 -6
  227. package/lib/opLifecycle/opDecompressor.js.map +1 -1
  228. package/lib/opLifecycle/opGroupingManager.js +3 -1
  229. package/lib/opLifecycle/opGroupingManager.js.map +1 -1
  230. package/lib/opLifecycle/opSerialization.d.ts +11 -1
  231. package/lib/opLifecycle/opSerialization.d.ts.map +1 -1
  232. package/lib/opLifecycle/opSerialization.js +16 -0
  233. package/lib/opLifecycle/opSerialization.js.map +1 -1
  234. package/lib/opLifecycle/opSplitter.d.ts +17 -1
  235. package/lib/opLifecycle/opSplitter.d.ts.map +1 -1
  236. package/lib/opLifecycle/opSplitter.js +44 -7
  237. package/lib/opLifecycle/opSplitter.js.map +1 -1
  238. package/lib/opLifecycle/outbox.d.ts.map +1 -1
  239. package/lib/opLifecycle/outbox.js +14 -10
  240. package/lib/opLifecycle/outbox.js.map +1 -1
  241. package/lib/opLifecycle/remoteMessageProcessor.js +4 -1
  242. package/lib/opLifecycle/remoteMessageProcessor.js.map +1 -1
  243. package/lib/opProperties.d.ts.map +1 -1
  244. package/lib/packageVersion.d.ts +1 -1
  245. package/lib/packageVersion.d.ts.map +1 -1
  246. package/lib/packageVersion.js +1 -1
  247. package/lib/packageVersion.js.map +1 -1
  248. package/lib/pendingStateManager.js +52 -45
  249. package/lib/pendingStateManager.js.map +1 -1
  250. package/lib/public.d.ts +1 -1
  251. package/lib/runCounter.js +10 -30
  252. package/lib/runCounter.js.map +1 -1
  253. package/lib/runtimeLayerCompatState.d.ts +1 -1
  254. package/lib/runtimeLayerCompatState.d.ts.map +1 -1
  255. package/lib/runtimeLayerCompatState.js +28 -8
  256. package/lib/runtimeLayerCompatState.js.map +1 -1
  257. package/lib/signalTelemetryProcessing.js +15 -17
  258. package/lib/signalTelemetryProcessing.js.map +1 -1
  259. package/lib/storageServiceWithAttachBlobs.js +2 -0
  260. package/lib/storageServiceWithAttachBlobs.js.map +1 -1
  261. package/lib/summary/documentSchema.d.ts.map +1 -1
  262. package/lib/summary/documentSchema.js +51 -13
  263. package/lib/summary/documentSchema.js.map +1 -1
  264. package/lib/summary/orderedClientElection.js +25 -17
  265. package/lib/summary/orderedClientElection.js.map +1 -1
  266. package/lib/summary/summarizerClientElection.d.ts.map +1 -1
  267. package/lib/summary/summarizerClientElection.js +18 -7
  268. package/lib/summary/summarizerClientElection.js.map +1 -1
  269. package/lib/summary/summarizerNode/summarizerNode.d.ts.map +1 -1
  270. package/lib/summary/summarizerNode/summarizerNode.js +22 -13
  271. package/lib/summary/summarizerNode/summarizerNode.js.map +1 -1
  272. package/lib/summary/summarizerNode/summarizerNodeWithGc.d.ts.map +1 -1
  273. package/lib/summary/summarizerNode/summarizerNodeWithGc.js +20 -6
  274. package/lib/summary/summarizerNode/summarizerNodeWithGc.js.map +1 -1
  275. package/lib/summary/summarizerUtils.d.ts.map +1 -1
  276. package/lib/summary/summarizerUtils.js +1 -0
  277. package/lib/summary/summarizerUtils.js.map +1 -1
  278. package/lib/summary/summaryCollection.js +23 -11
  279. package/lib/summary/summaryCollection.js.map +1 -1
  280. package/lib/summary/summaryDelayLoadedModule/runWhileConnectedCoordinator.js +4 -2
  281. package/lib/summary/summaryDelayLoadedModule/runWhileConnectedCoordinator.js.map +1 -1
  282. package/lib/summary/summaryDelayLoadedModule/runningSummarizer.d.ts.map +1 -1
  283. package/lib/summary/summaryDelayLoadedModule/runningSummarizer.js +65 -46
  284. package/lib/summary/summaryDelayLoadedModule/runningSummarizer.js.map +1 -1
  285. package/lib/summary/summaryDelayLoadedModule/summarizer.d.ts.map +1 -1
  286. package/lib/summary/summaryDelayLoadedModule/summarizer.js +15 -6
  287. package/lib/summary/summaryDelayLoadedModule/summarizer.js.map +1 -1
  288. package/lib/summary/summaryDelayLoadedModule/summarizerHeuristics.js +30 -25
  289. package/lib/summary/summaryDelayLoadedModule/summarizerHeuristics.js.map +1 -1
  290. package/lib/summary/summaryDelayLoadedModule/summaryGenerator.js +9 -0
  291. package/lib/summary/summaryDelayLoadedModule/summaryGenerator.js.map +1 -1
  292. package/lib/summary/summaryDelayLoadedModule/summaryResultBuilder.js +3 -5
  293. package/lib/summary/summaryDelayLoadedModule/summaryResultBuilder.js.map +1 -1
  294. package/lib/summary/summaryFormat.d.ts.map +1 -1
  295. package/lib/summary/summaryManager.js +66 -54
  296. package/lib/summary/summaryManager.js.map +1 -1
  297. package/lib/throttler.d.ts.map +1 -1
  298. package/lib/throttler.js +4 -1
  299. package/lib/throttler.js.map +1 -1
  300. package/lib/versionMarks/inboundBatch.d.ts +8 -6
  301. package/lib/versionMarks/inboundBatch.d.ts.map +1 -1
  302. package/lib/versionMarks/inboundBatch.js +10 -5
  303. package/lib/versionMarks/inboundBatch.js.map +1 -1
  304. package/lib/versionMarks/versionMarkResolver.d.ts +30 -23
  305. package/lib/versionMarks/versionMarkResolver.d.ts.map +1 -1
  306. package/lib/versionMarks/versionMarkResolver.js +112 -63
  307. package/lib/versionMarks/versionMarkResolver.js.map +1 -1
  308. package/package.json +60 -102
  309. package/src/containerCompatibility.ts +33 -42
  310. package/src/containerRuntime.ts +15 -12
  311. package/src/opLifecycle/index.ts +7 -1
  312. package/src/opLifecycle/opCompressor.ts +2 -2
  313. package/src/opLifecycle/opSerialization.ts +22 -1
  314. package/src/opLifecycle/opSplitter.ts +63 -6
  315. package/src/packageVersion.ts +1 -1
  316. package/src/runtimeLayerCompatState.ts +28 -21
  317. package/src/summary/documentSchema.ts +31 -6
  318. package/src/versionMarks/DEV.md +595 -158
  319. package/src/versionMarks/inboundBatch.ts +18 -8
  320. package/src/versionMarks/versionMarkResolver.ts +144 -75
  321. package/tsconfig.json +1 -1
  322. package/internal.d.ts +0 -11
  323. package/legacy/alpha.d.ts +0 -11
  324. package/legacy.d.ts +0 -11
  325. /package/api-extractor/{api-extractor.current.json → api-extractor-report.current.json} +0 -0
  326. /package/api-extractor/{api-extractor.legacy.json → api-extractor-report.legacy.json} +0 -0
@@ -1,91 +1,171 @@
1
1
  # Version marks runtime resolver
2
2
 
3
- Version marks keep mark storage out of the Fluid runtime. The app owns mark records, labels, timestamps, retention, promotion, **and the stored locator shape**. Fluid owns only a runtime resolver that can turn a pending batchId into a durable global sequence number, either when the batch is observed live or by scanning retained historical ops.
3
+ Version marks keep mark storage out of the Fluid runtime. The app owns mark records, labels, app-authored timestamps,
4
+ retention, promotion, **and the stored locator shape**. Fluid owns only a runtime resolver that can turn a pending
5
+ batchId into a durable global sequence number and the corresponding server-generated op timestamp, either when the batch
6
+ is observed live or by scanning retained historical ops.
4
7
 
5
8
  ## Implementation map
6
9
 
7
- | File | Responsibility |
8
- | --- | --- |
9
- | `packages/common/container-definitions/src/runtime.ts` | Defines the internal loader-to-runtime extension `IContainerContextInternal` and its optional `fetchOps` capability. |
10
- | `packages/loader/container-loader/src/container.ts` | Implements `fetchOps` by connecting to the current document delta-storage service and forwarding the requested range. |
11
- | `packages/loader/container-loader/src/containerContext.ts` | Carries `fetchOps` through `ContainerContext`. The config key is required so support is explicit, but its value may be `undefined` because the capability itself is optional. |
12
- | `packages/runtime/container-runtime/src/pendingStateManager.ts` | Supplies the reconnect-stable id from the batch-start message of the most recently flushed pending batch for capture. |
13
- | `packages/runtime/container-runtime/src/versionMarks/inboundBatch.ts` | Converts live or historically unpacked `InboundMessageResult` values into completed batch identities and carries identity across piecemeal batches. |
14
- | `packages/runtime/container-runtime/src/versionMarks/versionMarkResolver.ts` | Implements capture, live promotion, the session fast-path cache, historical resolution, miss classification, listener isolation, and cache eviction. |
15
- | `packages/runtime/container-runtime/src/containerRuntime.ts` | Constructs the resolver, wires runtime hooks, creates the historical unpack pipeline, exposes the host-facing resolver, and invokes live batch tracking after pending-state validation. |
16
- | `packages/runtime/container-runtime/src/versionMarks/index.ts` and `src/index.ts` | Export the internal implementation types and the host-consumable resolver interface/result types. |
10
+ | File | Responsibility |
11
+ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
+ | `packages/common/container-definitions/src/runtime.ts` | Defines the internal loader-to-runtime extension `IContainerContextInternal` and its optional `fetchOps` capability. |
13
+ | `packages/loader/container-loader/src/container.ts` | Implements `fetchOps` by connecting to the current document delta-storage service and forwarding the requested range. |
14
+ | `packages/loader/container-loader/src/containerContext.ts` | Carries `fetchOps` through `ContainerContext`. The config key is required so support is explicit, but its value may be `undefined` because the capability itself is optional. |
15
+ | `packages/runtime/container-runtime/src/pendingStateManager.ts` | Supplies the reconnect-stable id from the batch-start message of the most recently flushed pending batch for capture. |
16
+ | `packages/runtime/container-runtime/src/versionMarks/inboundBatch.ts` | Converts live or historically unpacked `InboundMessageResult` values into completed batch identities and carries identity across piecemeal batches. |
17
+ | `packages/runtime/container-runtime/src/versionMarks/versionMarkResolver.ts` | Implements capture, live promotion, the session fast-path cache, historical resolution, miss classification, listener isolation, and cache eviction. |
18
+ | `packages/runtime/container-runtime/src/containerRuntime.ts` | Constructs the resolver, wires runtime hooks, creates the historical unpack pipeline, exposes the host-facing resolver, and invokes live batch tracking after pending-state validation. |
19
+ | `packages/runtime/container-runtime/src/versionMarks/index.ts` and `src/index.ts` | Export the internal implementation types and the host-consumable resolver interface/result types. |
17
20
 
18
21
  ## Locator format
19
22
 
20
- Fluid does not define or export a locator type — the resolver works in primitives (`batchId`, `sequenceNumberLowerBound`, `sequenceNumber`), and the app packs/unpacks its own stored records. A typical app-owned shape is:
23
+ Fluid does not define or export a locator type — the resolver works in primitives (`batchId`,
24
+ `sequenceNumberLowerBound`, `sequenceNumber`, `timestamp`), and the app packs/unpacks its own stored records. A typical
25
+ app-owned shape is:
21
26
 
22
27
  ```ts
23
28
  // App-owned type (not provided by Fluid)
24
29
  type MarkLocator =
25
- | { kind: "resolved"; sequenceNumber: number }
30
+ | { kind: "resolved"; sequenceNumber: number; timestamp?: number }
26
31
  | { kind: "pending"; batchId: string; sequenceNumberLowerBound: number };
27
32
  ```
28
33
 
34
+ `timestamp` is the server-generated timestamp of the op at `sequenceNumber`. It is optional in the API for compatibility
35
+ with records and callers using the earlier resolved shape, but current runtime-produced resolved values populate it
36
+ whenever the corresponding op timestamp is available.
37
+
29
38
  There is no runtime `expired` locator kind. Expiration or user-visible failure states are app-side policy.
30
39
 
31
- A `pending` locator carries two things: `batchId` identifies which op the mark points at (the reconnect-stable batch identity), and `sequenceNumberLowerBound` is the last globally-sequenced point at capture — an exclusive lower bound for an out-of-session history read (the batch's ops are sequenced after it). Since `batchStartCsn` is a per-connection counter, not a global seq, it gives no location hint on its own; `sequenceNumberLowerBound` is the scan anchor for resolving `batchId -> seq` by reading ops starting at `sequenceNumberLowerBound + 1` (see Resolution paths).
40
+ A `pending` locator carries two things: `batchId` identifies which op the mark points at (the reconnect-stable batch
41
+ identity), and `sequenceNumberLowerBound` is the first possible sequence number of the pending batch —
42
+ `referenceSequenceNumber + 1`, where the reference is the last globally-sequenced point at capture (the batch's ops are
43
+ sequenced after it). This is an **inclusive** lower bound for an out-of-session history read. Since `batchStartCsn` is a
44
+ per-connection counter, not a global seq, it gives no location hint on its own; `sequenceNumberLowerBound` is the scan
45
+ anchor for resolving `batchId -> seq` by reading ops starting at `sequenceNumberLowerBound` (see Resolution paths).
32
46
 
33
47
  ## Batch identity
34
48
 
35
- `BatchManager.generateBatchId(originalClientId, batchStartCsn)` produces `${originalClientId}_[${batchStartCsn}]`. `getEffectiveBatchId(...)` returns explicit batch metadata on resubmit, or derives the same id from the original wire client/csn for first submission. `PendingStateManager` preserves that batch info across reconnect and stamps the batchId during resubmit.
49
+ `BatchManager.generateBatchId(originalClientId, batchStartCsn)` produces `${originalClientId}_[${batchStartCsn}]`.
50
+ `getEffectiveBatchId(...)` returns explicit batch metadata on resubmit, or derives the same id from the original wire
51
+ client/csn for first submission. `PendingStateManager` preserves that batch info across reconnect and stamps the batchId
52
+ during resubmit.
36
53
 
37
- For capture, `PendingStateManager.getMostRecentPendingBatchId()` locates the start of the most recently flushed pending batch using its recorded batch length, then derives the effective id from that start message. This matters for resubmitted multi-op batches because the explicit reconnect-stable `batchId` is stamped only on the first message; reading the last message would incorrectly derive a new id from the current client and CSN. Stashed `initialMessages` are ignored until they are applied into the current session's pending queue.
54
+ For capture, `PendingStateManager.getMostRecentPendingBatchId()` locates the start of the most recently flushed pending
55
+ batch using its recorded batch length, then derives the effective id from that start message. This matters for
56
+ resubmitted multi-op batches because the explicit reconnect-stable `batchId` is stamped only on the first message;
57
+ reading the last message would incorrectly derive a new id from the current client and CSN. Stashed `initialMessages`
58
+ are ignored until they are applied into the current session's pending queue.
38
59
 
39
60
  ## Resolver API
40
61
 
41
62
  `VersionMarkResolver` implements `IVersionMarkResolver`:
42
63
 
43
- - `sealAndCaptureVersionMark()` synchronously seals the current outbound batch (flushes the runtime) and captures a mark at the resulting point, returning a `VersionMarkCapture`: either `{ kind: "pending", batchId, sequenceNumberLowerBound }` (an unacked local edit, resolve it later) or `{ kind: "resolved", sequenceNumber }` (no in-flight local work). A batch's `batchId` is only assigned when it is flushed into `PendingStateManager` — see [Batch identity](#batch-identity). Combining sealing and capture prevents a caller from reading an older batch or `undefined` immediately after an edit and prevents the batch id and lower bound from being read at different points. The app packs its own stored record from the result — the runtime does not define the stored locator shape. Call it at savepoint boundaries, not per keystroke, because sealing submits the pending batch.
44
- - `resolve(batchId, sequenceNumberLowerBound)` resolves live-then-history: (1) the ephemeral in-session `batchId -> sequenceNumber` map (batch seen live this session); (2) on a miss, an **out-of-session scan** — reads ops starting at `sequenceNumberLowerBound + 1` via an injected `IHistoricalOpReader`, routes each op through the **same unpack pipeline the live inbound path uses** (chunk reassembly, ungroup, decompress) and derives batch identity with the shared `inboundVersionMarkUpdate` helper, returning the matched batch's **last** op sequence number, or (when not found) `pending` / `unresolvable` distinguished by a read-derived availability check (see Resolution behavior). The reader is generic (backed by any driver's `IDocumentDeltaStorageService.fetchMessages`); when it is not wired, an unknown batchId is reported `pending`.
45
- - `onBatchSequenced(listener)` broadcasts `(batchId, sequenceNumber)` as each batch is processed inbound, so any connected client can promote a matching pending mark in its own store (resolution is not tied to the capturing client). Returns an unsubscribe. Listeners run synchronously on the inbound op path, so each invocation is isolated: a throwing listener is caught, logged (`VersionMarkListenerException`), and skipped — it cannot abort op processing or starve later listeners (mirroring the container's `EventEmitterWithErrorHandling`). A missed live promotion is recoverable — the app can still resolve that mark later via `resolve()`'s history scan — so a listener fault logs and continues rather than faulting the container.
46
-
47
- Host exposure: `ContainerRuntime` exposes an `@internal` `versionMarkResolver` getter backed by the concrete `versionMarkResolverInternal`. An app gets it from the runtime instance passed to `provideEntryPoint`, or exposes it from its own entryPoint. A future public API may move this onto container-runtime definitions rather than the concrete runtime class.
64
+ - `sealAndCaptureVersionMark()` synchronously seals the current outbound batch (flushes the runtime) and captures a mark
65
+ at the resulting point, returning a `VersionMarkCapture`: either
66
+ `{ kind: "pending", batchId, sequenceNumberLowerBound }` (an unacked local edit, resolve it later) or
67
+ `{ kind: "resolved", sequenceNumber, timestamp? }` (no in-flight local work). The resolved timestamp comes from
68
+ `getCurrentReferenceTimestampMs()`, which uses the DeltaManager's last processed message and falls back to the
69
+ last-summary message. A batch's `batchId` is only assigned when it is flushed into `PendingStateManager` — see
70
+ [Batch identity](#batch-identity). Combining sealing and capture prevents a caller from reading an older batch or
71
+ `undefined` immediately after an edit and prevents the batch id and lower bound from being read at different points.
72
+ The app packs its own stored record from the result — the runtime does not define the stored locator shape. Call it at
73
+ savepoint boundaries, not per keystroke, because sealing submits the pending batch.
74
+ - `resolve(batchId, sequenceNumberLowerBound)` resolves live-then-history: (1) the ephemeral in-session
75
+ `batchId -> { sequenceNumber, timestamp }` map (batch seen live this session); (2) on a miss, an **out-of-session
76
+ scan** — reads ops starting at `sequenceNumberLowerBound` (an inclusive lower bound) via an injected
77
+ `IHistoricalOpReader`, routes each op through the **same unpack pipeline the live inbound path uses** (chunk
78
+ reassembly, ungroup, decompress) and derives batch identity with the shared `inboundVersionMarkUpdate` helper,
79
+ returning the matched batch's **last** op sequence number and server timestamp, or (when not found) `pending` /
80
+ `unresolvable` distinguished by a read-derived availability check (see Resolution behavior). The reader is generic
81
+ (backed by any driver's `IDocumentDeltaStorageService.fetchMessages`); when it is not wired, an unknown batchId is
82
+ reported `pending`.
83
+ - `onBatchSequenced(listener)` broadcasts `(batchId, sequenceNumber, timestamp?)` as each batch is processed inbound, so
84
+ any connected client can promote a matching pending mark in its own store (resolution is not tied to the capturing
85
+ client). The timestamp is from the batch's final op and is optional in the callback type for source compatibility with
86
+ existing listeners. Returns an unsubscribe. Listeners run synchronously on the inbound op path, so each invocation is
87
+ isolated: a throwing listener is caught, logged (`VersionMarkListenerException`), and skipped — it cannot abort op
88
+ processing or starve later listeners (mirroring the container's `EventEmitterWithErrorHandling`). A missed live
89
+ promotion is recoverable — the app can still resolve that mark later via `resolve()`'s history scan — so a listener
90
+ fault logs and continues rather than faulting the container.
91
+
92
+ Host exposure: `ContainerRuntime` exposes an `@internal` `versionMarkResolver` getter backed by the concrete
93
+ `versionMarkResolverInternal`. An app gets it from the runtime instance passed to `provideEntryPoint`, or exposes it
94
+ from its own entryPoint. A future public API may move this onto container-runtime definitions rather than the concrete
95
+ runtime class.
48
96
 
49
97
  ### Capture implementation
50
98
 
51
99
  `VersionMarkResolver.sealAndCaptureVersionMark()` executes synchronously in this order:
52
100
 
53
- 1. Call the `flushPendingBatch` hook (`ContainerRuntime.flush`) so the current outbox batch is moved into `PendingStateManager` and assigned stable batch information.
54
- 2. Read `getCurrentSequenceNumber()` (`deltaManager.lastSequenceNumber`) as the capture's globally sequenced exclusive lower bound.
55
- 3. Read `getCurrentPendingBatchId()` (`PendingStateManager.getMostRecentPendingBatchId()`).
56
- 4. If no pending batch exists, return `{ kind: "resolved", sequenceNumber }`. This path does not enable inbound tracking because there is no pending batch to promote.
57
- 5. If a pending batch exists, set the sticky `tracking` flag and return `{ kind: "pending", batchId, sequenceNumberLowerBound }`.
58
-
59
- Keeping sealing and capture in one synchronous operation prevents callers from observing or persisting intermediate state between flushing, reading the sequence number, and reading the batch id.
101
+ 1. Call the `flushPendingBatch` hook (`ContainerRuntime.flush`) so the current outbox batch is moved into
102
+ `PendingStateManager` and assigned stable batch information.
103
+ 1. Read `getCurrentSequenceNumber()` (`deltaManager.lastSequenceNumber`) as the reference sequence number — the last
104
+ globally-sequenced point at capture.
105
+ 1. Read `getCurrentPendingBatchId()` (`PendingStateManager.getMostRecentPendingBatchId()`).
106
+ 1. If no pending batch exists, return `{ kind: "resolved", sequenceNumber, timestamp }` using the reference sequence
107
+ number and `getCurrentReferenceTimestampMs()`
108
+ (`deltaManager.lastMessage?.timestamp ?? messageAtLastSummary?.timestamp`). This path does not enable inbound
109
+ tracking because there is no pending batch to promote.
110
+ 1. If a pending batch exists, set the sticky `tracking` flag and return
111
+ `{ kind: "pending", batchId, sequenceNumberLowerBound }`, where
112
+ `sequenceNumberLowerBound = referenceSequenceNumber + 1` (the pending batch's first possible sequence number — an
113
+ inclusive lower bound).
114
+
115
+ Keeping sealing and capture in one synchronous operation prevents callers from observing or persisting intermediate
116
+ state between flushing, reading the sequence number, and reading the batch id.
60
117
 
61
118
  ## Consumer surface & public-API graduation
62
119
 
63
- An app (e.g. the Loop/office-bohemia host) consumes a small `@legacy @alpha` surface:
120
+ An app (e.g. the Loop/office-bohemia host) consumes a small `@legacy @beta` surface:
64
121
 
65
122
  - Get the resolver: `ContainerRuntime.versionMarkResolver` -> `IVersionMarkResolver`.
66
- - `IVersionMarkResolver` methods: `sealAndCaptureVersionMark()` -> `VersionMarkCapture` (seals the batch and returns the locator data atomically), `onBatchSequenced(listener)` (live promotion), `resolve(batchId, sequenceNumberLowerBound)` -> `ResolveResult` (load-time sweep / restore).
67
- - Types `IVersionMarkResolver`, `ResolveResult`, and `VersionMarkCapture` are exported from `@fluidframework/container-runtime/legacy/alpha`.
68
- - Restore side: `loadContainerToSequenceNumber` and `ILoadContainerToSequenceNumberProps` are exported from `@fluidframework/container-loader/legacy/alpha`, fed the `resolved` sequence number.
69
- - ODSP point-in-time support: `getOdspPointInTimeDocumentServiceFactory` and
70
- `IPointInTimeDocumentServiceFactory` are exported from
71
- `@fluidframework/odsp-driver/legacy/alpha`.
123
+ - `IVersionMarkResolver` methods: `sealAndCaptureVersionMark()` -> `VersionMarkCapture` (seals the batch and returns the
124
+ locator data atomically), `onBatchSequenced(listener)` (live promotion), `resolve(batchId, sequenceNumberLowerBound)`
125
+ -> `ResolveResult` (load-time sweep / restore).
126
+ - Types `IVersionMarkResolver`, `ResolveResult`, and `VersionMarkCapture` are exported from
127
+ `@fluidframework/container-runtime/legacy`.
128
+ - Restore side: `loadContainerToSequenceNumber` and `ILoadContainerToSequenceNumberProps` (`@legacy @beta`) are exported
129
+ from `@fluidframework/container-loader/legacy`, fed the `resolved` sequence number.
130
+ - ODSP point-in-time support: `createOdspDocumentServiceFactory` accepts the implementation
131
+ (`getOdspPointInTimeDocumentServiceFactory` / `IPointInTimeDocumentServiceFactory`) currently exported from the
132
+ `@fluidframework/odsp-driver/legacy/point-in-time` subpath. AB#81443 tracks moving it to the normal
133
+ `@fluidframework/odsp-driver/legacy` entrypoint without retaining the unused implementation graph in consumer bundles.
72
134
 
73
135
  ### Capturing a mark (app side)
74
136
 
75
- Capture is a single `resolver.sealAndCaptureVersionMark()` call returning a `VersionMarkCapture` — either `{ kind: "pending", batchId, sequenceNumberLowerBound }` (an unacked local edit, resolve it later) or `{ kind: "resolved", sequenceNumber }` (no in-flight local work). Notes for consumers:
76
-
77
- - **It seals the current outbound batch synchronously** (flushes the runtime) so the just-submitted edit has a stable `batchId` before it is read. Call it at savepoint boundaries (e.g. an explicit "snapshot this version"), not per keystroke it submits the pending batch as a side effect.
78
- - **The runtime composes the pending-vs-resolved result atomically.** The app no longer reads a batchId and a sequence number lower bound separately or decides pending-vs-resolved itself; that removes the earlier race where a batchId still in the outbox came back stale/`undefined` and got paired with a mismatched lower bound, persisting a wrong coordinate.
79
- - **The app still owns storage.** `VersionMarkCapture` is a transient result, not a persisted locator type — the app maps it into its own stored record.
80
- - **Works while disconnected.** A disconnected flush stamps a stable placeholder `batchId` (carried across resubmit), so capture returns a usable `pending` mark offline; no connection is required to capture.
81
-
82
- Not consumed by the app (internal plumbing): `IContainerContextInternal.fetchOps`, the concrete `VersionMarkResolver`, `IHistoricalOpReader`, `VersionMarkResolverRuntimeHooks` (including `getHistoricalOpReader` and `createHistoricalOpUnpacker`), `inboundVersionMarkUpdate`, and `processInboundBatch`.
83
-
84
- Before promotion beyond `@legacy @alpha`, move the access point off the concrete `@internal` `ContainerRuntime` class onto a public runtime interface (container-runtime-definitions) or the entryPoint / `FluidObject` provider pattern, and resolve the API-shape questions in [Future work](#future-work). The interface is primitive-typed (no `MarkLocator` or driver types leak). The `fetchOps` plumbing remains loader→runtime internal wiring on `IContainerContextInternal`; the resolver, loader helper, and ODSP factory are the host-facing touchpoints.
137
+ Capture is a single `resolver.sealAndCaptureVersionMark()` call returning a `VersionMarkCapture` — either
138
+ `{ kind: "pending", batchId, sequenceNumberLowerBound }` (an unacked local edit, resolve it later) or
139
+ `{ kind: "resolved", sequenceNumber, timestamp? }` (no in-flight local work). Notes for consumers:
140
+
141
+ - **It seals the current outbound batch synchronously** (flushes the runtime) so the just-submitted edit has a stable
142
+ `batchId` before it is read. Call it at savepoint boundaries (e.g. an explicit "snapshot this version"), not per
143
+ keystroke — it submits the pending batch as a side effect.
144
+ - **The runtime composes the pending-vs-resolved result atomically.** The app no longer reads a batchId and a sequence
145
+ number lower bound separately or decides pending-vs-resolved itself; that removes the earlier race where a batchId
146
+ still in the outbox came back stale/`undefined` and got paired with a mismatched lower bound, persisting a wrong
147
+ coordinate.
148
+ - **The app still owns storage.** `VersionMarkCapture` is a transient result, not a persisted locator type — the app
149
+ maps it into its own stored record.
150
+ - **Persist the timestamp with a resolved locator when present.** The runtime populates the server-generated timestamp,
151
+ while the optional API property keeps existing stored records and callers compatible.
152
+ - **Works while disconnected.** A disconnected flush stamps a stable placeholder `batchId` (carried across resubmit), so
153
+ capture returns a usable `pending` mark offline; no connection is required to capture.
154
+
155
+ Not consumed by the app (internal plumbing): `IContainerContextInternal.fetchOps`, the concrete `VersionMarkResolver`,
156
+ `IHistoricalOpReader`, `VersionMarkResolverRuntimeHooks` (including `getHistoricalOpReader` and
157
+ `createHistoricalOpUnpacker`), `inboundVersionMarkUpdate`, and `processInboundBatch`.
158
+
159
+ Before promotion beyond `@legacy @beta`, move the access point off the concrete `@internal` `ContainerRuntime` class
160
+ onto a public runtime interface (container-runtime-definitions) or the entryPoint / `FluidObject` provider pattern, and
161
+ resolve the API-shape questions in [Future work](#future-work). The interface is primitive-typed (no `MarkLocator` or
162
+ driver types leak). The `fetchOps` plumbing remains loader→runtime internal wiring on `IContainerContextInternal`; the
163
+ resolver, loader helper, and ODSP factory are the host-facing touchpoints.
85
164
 
86
165
  ## Loader-to-runtime wiring
87
166
 
88
- `IContainerContextInternal` extends the public/legacy `IContainerContext` only inside the loader/runtime implementation boundary. Its optional function is:
167
+ `IContainerContextInternal` extends the public/legacy `IContainerContext` only inside the loader/runtime implementation
168
+ boundary. Its optional function is:
89
169
 
90
170
  ```ts
91
171
  fetchOps(
@@ -95,15 +175,25 @@ fetchOps(
95
175
  ): Promise<IStream<ISequencedDocumentMessage[]>>
96
176
  ```
97
177
 
98
- The range follows delta-storage semantics, `[from, to)`: `from` is inclusive, `to` is exclusive, and an undefined `to` means there is no fixed upper bound. `ContainerContext` stores the function unchanged. Its config requires a `fetchOps` key even though the value may be `undefined`; this makes each constructor call explicitly state whether the host provides historical reads.
178
+ The range follows delta-storage semantics, `[from, to)`: `from` is inclusive, `to` is exclusive, and an undefined `to`
179
+ means there is no fixed upper bound. `ContainerContext` stores the function unchanged. Its config requires a `fetchOps`
180
+ key even though the value may be `undefined`; this makes each constructor call explicitly state whether the host
181
+ provides historical reads.
99
182
 
100
- The `Container.fetchOps` helper connects to delta storage on every call through `service.connectToDeltaStorage()`. This avoids retaining a handle across reconnects, epoch changes, or service replacement. If the current service cannot provide delta storage, it throws `"Cannot fetch ops: delta storage is unavailable"`. Otherwise it forwards `from`, `to`, and `abortSignal` directly to `IDocumentDeltaStorageService.fetchMessages`.
183
+ The `Container.fetchOps` helper connects to delta storage on every call through `service.connectToDeltaStorage()`. This
184
+ avoids retaining a handle across reconnects, epoch changes, or service replacement. If the current service cannot
185
+ provide delta storage, it throws `"Cannot fetch ops: delta storage is unavailable"`. Otherwise it forwards `from`, `to`,
186
+ and `abortSignal` directly to `IDocumentDeltaStorageService.fetchMessages`.
101
187
 
102
- During `ContainerRuntime` construction, the context is narrowed to `IContainerContextInternal`. The runtime stores the concrete `VersionMarkResolver` in `versionMarkResolverInternal` and exposes it through the host-facing `versionMarkResolver` getter typed as `IVersionMarkResolver`.
188
+ During `ContainerRuntime` construction, the context is narrowed to `IContainerContextInternal`. The runtime stores the
189
+ concrete `VersionMarkResolver` in `versionMarkResolverInternal` and exposes it through the host-facing
190
+ `versionMarkResolver` getter typed as `IVersionMarkResolver`.
103
191
 
104
192
  The runtime hooks are wired as follows:
105
193
 
106
194
  - `getCurrentSequenceNumber` -> `deltaManager.lastSequenceNumber`.
195
+ - `getCurrentTimestamp` -> `getCurrentReferenceTimestampMs()`
196
+ (`deltaManager.lastMessage?.timestamp ?? messageAtLastSummary?.timestamp`).
107
197
  - `getCurrentMinimumSequenceNumber` -> `deltaManager.minimumSequenceNumber`.
108
198
  - `getCurrentPendingBatchId` -> `pendingStateManager.getMostRecentPendingBatchId()`.
109
199
  - `flushPendingBatch` -> `ContainerRuntime.flush()`.
@@ -111,78 +201,189 @@ The runtime hooks are wired as follows:
111
201
  - `getHistoricalOpReader` -> a lightweight `{ fetchMessages: fetchOps }` adapter when `fetchOps` exists.
112
202
  - `createHistoricalOpUnpacker` -> a factory for a fresh `RemoteMessageProcessor` when `fetchOps` exists.
113
203
 
114
- Each historical scan gets its own `RemoteMessageProcessor` because `OpSplitter` keeps chunk-reassembly state. The processor is built with the runtime's chunk-size and max-batch-size options, an `OpDecompressor`, and an `OpGroupingManager` configured with the runtime's grouped-batching setting. The returned unpack function filters system/server messages, clones the op, deserializes string contents with `ensureContentsDeserialized`, and runs the clone through `RemoteMessageProcessor.process`.
204
+ Reusing `getCurrentReferenceTimestampMs()` keeps the sequence number and timestamp paired at load:
205
+ `messageAtLastSummary.sequenceNumber` equals `deltaManager.initialSequenceNumber`, so before any new ops arrive the
206
+ fallback timestamp and reported sequence number come from the same op.
207
+
208
+ Each historical scan gets its own `RemoteMessageProcessor` because `OpSplitter` keeps chunk-reassembly state. The
209
+ processor is built with the runtime's chunk-size and max-batch-size options, an `OpDecompressor`, and an
210
+ `OpGroupingManager` configured with the runtime's grouped-batching setting. The returned unpack function filters
211
+ system/server messages, clones the op, deserializes string contents with `ensureContentsDeserialized`, and runs the
212
+ clone through `RemoteMessageProcessor.process`.
115
213
 
116
- If `fetchOps` is absent, both historical hooks are absent. Live resolution still works, while an unknown id conservatively resolves to `pending`.
214
+ If `fetchOps` is absent, both historical hooks are absent. Live resolution still works, while an unknown id
215
+ conservatively resolves to `pending`.
117
216
 
118
217
  ## Resolution behavior
119
218
 
120
219
  ### Live inbound tracking
121
220
 
122
- `ContainerRuntime` owns `versionMarkInboundBatchId`, which carries a batch id between piecemeal inbound messages. After `PendingStateManager.processInboundMessages` successfully validates an inbound result, the runtime checks `versionMarkResolverInternal.isTracking`. If tracking is disabled, it skips all version-mark work on the hot path. If tracking is enabled, it calls `inboundVersionMarkUpdate(inboundResult, versionMarkInboundBatchId)`, records any completed batch through `processInboundBatch`, and stores the returned `carriedBatchId` for the next message.
221
+ `ContainerRuntime` owns `versionMarkInboundBatchId`, which carries a batch id between piecemeal inbound messages. After
222
+ `PendingStateManager.processInboundMessages` successfully validates an inbound result, the runtime checks
223
+ `versionMarkResolverInternal.isTracking`. If tracking is disabled, it skips all version-mark work on the hot path. If
224
+ tracking is enabled, it calls `inboundVersionMarkUpdate(inboundResult, versionMarkInboundBatchId)`, records any
225
+ completed batch through `processInboundBatch`, and stores the returned `carriedBatchId` for the next message.
123
226
 
124
- The version-mark update runs **after** pending-state validation because that validation throws for a batch that must be rejected (fork detection or pending-content mismatch). `processInboundBatch` synchronously fires `onBatchSequenced`, which an app may use to promote a mark in an external store; sequencing the update after validation ensures a rejected batch never causes that irreversible side effect.
227
+ The version-mark update runs **after** pending-state validation because that validation throws for a batch that must be
228
+ rejected (fork detection or pending-content mismatch). `processInboundBatch` synchronously fires `onBatchSequenced`,
229
+ which an app may use to promote a mark in an external store; sequencing the update after validation ensures a rejected
230
+ batch never causes that irreversible side effect.
125
231
 
126
232
  `inboundVersionMarkUpdate` handles every `InboundMessageResult` shape:
127
233
 
128
- - `fullBatch`: derive the effective id from `batchStart`; resolve at the last message's sequence number. An empty grouped batch has no messages, so it uses the batch-start key message's sequence number.
234
+ - `fullBatch`: derive the effective id from `batchStart`; resolve at the last message's sequence number and server
235
+ timestamp. An empty grouped batch has no messages, so it uses the batch-start key message's sequence number and
236
+ timestamp.
129
237
  - `batchStartingMessage`: derive and carry the batch id without recording a sequence number yet.
130
- - `nextBatchMessage` with `batchEnd: true`: if an id is being carried, resolve it at this final message's sequence number and clear the carry.
238
+ - `nextBatchMessage` with `batchEnd: true`: if an id is being carried, resolve it at this final message's sequence
239
+ number and server timestamp, then clear the carry.
131
240
  - Mid-batch messages, or an end message without a carried id: preserve the current carry and emit no completed batch.
132
241
 
133
- `VersionMarkResolver.processInboundBatch` suppresses an exact duplicate `(batchId, sequenceNumber)` update. Otherwise it inserts the mapping, evicts entries below the current MSN, and synchronously invokes every subscribed listener. Each listener has its own `try/catch`; a fault emits `VersionMarkListenerException` and iteration continues.
242
+ `VersionMarkResolver.processInboundBatch` records and broadcasts only the first resolved point observed for each
243
+ `batchId`. A later update for the same id is ignored, even if a spurious service redelivery assigns it a different
244
+ sequence number or timestamp; retaining the first-landed point avoids turning a tolerated duplicate into a container
245
+ fault or remapping an already-promoted mark. For a new id, the resolver inserts the resolved point, evicts entries below
246
+ the current MSN, and synchronously invokes every subscribed listener. Each listener has its own `try/catch`; a fault
247
+ emits `VersionMarkListenerException` and iteration continues.
134
248
 
135
- The runtime does not store or mutate app marks. The listener only lets the app replace its own pending locator with the supplied sequence number.
249
+ The runtime does not store or mutate app marks. The listener only lets the app replace its own pending locator with the
250
+ supplied sequence number and server timestamp.
136
251
 
137
252
  ### Resolve control flow
138
253
 
139
254
  `resolve(batchId, sequenceNumberLowerBound)` performs:
140
255
 
256
+ 1. Enable tracking so inbound batches are recorded from here on (see [Tracking gate](#tracking-gate-istracking)).
141
257
  1. Look up `batchId` in the session map. A hit immediately returns `resolved` and never consults storage.
142
- 2. Call `getHistoricalOpReader` on a miss. If no reader is available, return `pending`.
143
- 3. Set `from = sequenceNumberLowerBound + 1`, create a fresh unpacker, and create an `AbortController`.
144
- 4. Request `fetchMessages(from, undefined, abortSignal)`.
145
- 5. Read every stream chunk until the target is found or the stream returns `done`.
146
- 6. Return the matched completed batch's last sequence number, or classify the miss.
147
- 7. Abort the controller in `finally`, both on success and on exhaustion/error, so the underlying fetch can stop any remaining work.
258
+ 1. Call `getHistoricalOpReader` on a miss. If no reader is available, return `pending`.
259
+ 1. Set `from = sequenceNumberLowerBound`, create a fresh unpacker, and create an `AbortController`.
260
+ 1. Request `fetchMessages(from, undefined, abortSignal)`.
261
+ 1. Read every stream chunk until the target is found or the stream returns `done`.
262
+ 1. Return the matched completed batch's last sequence number and server timestamp, or classify the miss.
263
+ 1. Abort the controller in `finally`, both on success and on exhaustion/error, so the underlying fetch can stop any
264
+ remaining work.
265
+ 1. `resolve()` emits one `Resolve` telemetry event (`outcome`, `path`, `durationMs`) before returning — on every path,
266
+ including a thrown scan (`outcome: "error"`, emitted via `finally`). See [Telemetry](#telemetry).
267
+
268
+ For each raw op, the scan records the first returned sequence number before filtering because that value is also the
269
+ trim-availability signal. It then:
270
+
271
+ 1. Passes the op to the injected historical unpacker. `undefined` means the op produced no complete inbound result.
272
+ 1. Calls `inboundVersionMarkUpdate`, carrying the batch id across piecemeal results exactly as the live path does.
273
+ 1. Returns immediately when the completed batch id matches the requested id.
274
+
275
+ Routing scanned ops through the live unpack pipeline is required for **chunked batches**. Chunking strips the `batchId`
276
+ from the final chunk's wire metadata and restores it only after `OpSplitter` reassembly. A raw metadata scan would
277
+ therefore miss a resubmitted chunked batch. The shared pipeline also keeps grouped and compressed batch handling
278
+ consistent with live processing.
279
+
280
+ **Scan-anchor handling (chunk streams).** Ordinary multi-op batches are not split by the capture anchor:
281
+ `InboundBatchAggregator` keeps them atomic when delivering them to the runtime. Chunk streams are different. The
282
+ DeltaManager advances `lastSequenceNumber` for each intermediate chunk before `OpSplitter` has reconstructed the
283
+ original message, so `sequenceNumberLowerBound` can start in the middle of a chunk stream.
284
+
285
+ Each historical scan uses a fresh `OpSplitter` (chunk-reassembly state must not be shared across scans) configured to
286
+ allow the first observed stream for each client to begin after chunk 1. It discards the unreconstructable remainder of
287
+ that stream, then enforces normal chunk ordering for that client. The live inbound `OpSplitter` remains strict. This
288
+ behavior stays within the injected unpack pipeline so `VersionMarkResolver` does not inspect or track virtualization
289
+ details. `opSplitter.spec.ts` covers partial and complete streams interleaved across clients, and
290
+ `versionMarkResolver.spec.ts` retains one integration regression through the real unpack pipeline.
291
+
292
+ An **ordinary (non-chunked) batch tail is never clipped**, on the live queue or the replayed `fetchMessages` stream, so
293
+ the scan needs no ordinary-batch orphan-end guard. `InboundBatchAggregator` aggregates a runtime-observed batch
294
+ atomically and `DeltaQueue` drains it synchronously, so a runtime-observed `lastSequenceNumber` never lands
295
+ mid-ordinary-batch; the capture anchor (`sequenceNumberLowerBound`) therefore cannot start between an ordinary batch's
296
+ first and last op. This holds for the replay path because it feeds the same `RemoteMessageProcessor`/`OpSplitter`
297
+ pipeline, and delta storage returns whole batches (only chunk streams expose intermediate sequence numbers). If a
298
+ clipped ordinary-batch tail ever did reach the unpacker, it would not resolve silently:
299
+ `RemoteMessageProcessor.getResultBasedOnBatchMetadata` still asserts `0x9d5` on a `batch: false` marker with no batch in
300
+ progress, so the invariant fails loudly rather than masking a regression from a future replay/capture-path change.
301
+
302
+ This orphan-tail premise relies on every persisted locator using the current inclusive scheme
303
+ (`sequenceNumberLowerBound = referenceSequenceNumber + 1`), so a scan always begins at the pending batch's own first op,
304
+ never at the last op `S` of a preceding batch. That holds because the earlier exclusive scheme (which stored `S` and
305
+ scanned from `S + 1` at read time) was **never shipped** — there are no pre-PR persisted locators. If one somehow
306
+ existed, scanning from `S` could start on a preceding batch's `batch: false` orphan tail (tripping `0x9d5`) or
307
+ reclassify a miss from `pending` to `unresolvable`; because the old format never shipped, neither case can arise and no
308
+ compat guard is needed. Any future change that alters the stored anchor's meaning must revisit this (see
309
+ [Persisted-locator format compatibility](#resolver-correctness-and-robustness-gaps)).
310
+
311
+ ### `pending` vs `unresolvable` on a miss (read-derived availability)
312
+
313
+ Here, **trimmed** means that older sequenced ops are no longer retained or returned by the service's delta storage. It
314
+ does not refer to eviction from the resolver's fast-path cache or to an op falling below the minimum sequence number
315
+ (MSN).
316
+
317
+ When the scan does not find the batch, the result distinguishes **`pending`** ("not sequenced yet — retry later") from
318
+ **`unresolvable`** ("its ops were trimmed — gone forever"). Both look identical from the batch id alone (the batch is
319
+ simply absent), so the distinction uses a **read-derived availability signal**: the current tip
320
+ (`getCurrentSequenceNumber`) plus where the scan's first op landed relative to `from = sequenceNumberLowerBound`:
321
+
322
+ - `from > tip` — nothing is sequenced at/after the lower bound yet, so the batch cannot have landed → **`pending`**.
323
+ - Empty read while ops should exist (`from <= tip`) — the requested range came back empty, which for a strict driver
324
+ (ODSP's `validateMessages` empties a from-misaligned trimmed range) means the range was trimmed → **`unresolvable`**.
325
+ - First available op is past `from` — a trim gap at the anchor; the mark's batch (sequenced just after the reference
326
+ point) was trimmed → **`unresolvable`**. A found batch resolves before this check, so a gap **on a miss** genuinely
327
+ means the batch's ops are gone, not merely preceded by other clients' ops (which are still present at `from`).
328
+ - Ops present from `from` but the batch is not among them — it has not been sequenced yet → **`pending`**.
148
329
 
149
- For each raw op, the scan records the first returned sequence number before filtering because that value is also the trim-availability signal. It then:
330
+ This is an **interim, read-derived** signal: it infers availability from how the driver responds to a trimmed range,
331
+ which is driver-behavior-dependent (strict-empty vs return-from-earliest) and degrades to the conservative outcome when
332
+ ambiguous. A dedicated driver op-availability / retention API (e.g. an explicit earliest-retained-sequence-number query
333
+ on `IDocumentDeltaStorageService`, coordinated across drivers) would replace it with a precise, contractual signal — a
334
+ separate follow-up.
150
335
 
151
- 1. Drops a `batch: false` marker when no batch is currently in progress. The scan anchor may land on the clipped tail of an ordinary batch whose start was before `from`; feeding that orphan end marker to `RemoteMessageProcessor` would violate its batch-state invariant.
152
- 2. Passes the op to the unpacker. `undefined` means a filtered system op or an incomplete chunk waiting for more fragments.
153
- 3. Mirrors the unpacker's batch-in-progress state from `batchStartingMessage` and final `nextBatchMessage` results.
154
- 4. Calls `inboundVersionMarkUpdate`, carrying the batch id across piecemeal results exactly as the live path does.
155
- 5. Returns immediately when the completed batch id matches the requested id.
336
+ ### Error handling and invariants
156
337
 
157
- The target batch was pending at capture, so it must sequence after `sequenceNumberLowerBound` and cannot be the clipped ordinary batch.
338
+ - A historical reader must never return an op below `from`. `classifyMiss` asserts this because trim classification is
339
+ invalid if the range contract is violated.
340
+ - Delta-storage connection/fetch failures and unpacking failures propagate to the caller. They are operational failures,
341
+ not legitimate `pending` or `unresolvable` results.
342
+ - The `AbortController` is aborted in `finally`, including when a reader, stream, or unpacker throws.
343
+ - Listener failures are isolated, logged, and skipped because a missed live promotion remains recoverable through
344
+ history.
345
+ - Inbound pending-state validation runs before notification. A rejected or forked batch cannot cause an app-side
346
+ promotion.
347
+ - `resolvedBatchById` is only a session cache. Correctness must not depend on an entry remaining present; a miss can
348
+ fall back to retained history.
158
349
 
159
- Routing scanned ops through the live unpack pipeline is required for **chunked batches**. Chunking strips the `batchId` from the final chunk's wire metadata and restores it only after `OpSplitter` reassembly. A raw metadata scan would therefore miss a resubmitted chunked batch. The shared pipeline also keeps grouped and compressed batch handling consistent with live processing.
350
+ ## Telemetry
160
351
 
161
- **Scan-anchor limitation (chunk streams).** Ordinary multi-op batches are not split by the capture anchor: `InboundBatchAggregator` keeps them atomic when delivering them to the runtime. Chunk streams are different. The DeltaManager advances `lastSequenceNumber` for each intermediate chunk before `OpSplitter` has reconstructed the original message, so `sequenceNumberLowerBound + 1` can start in the middle of a chunk stream.
352
+ Version-mark telemetry is greenfield; this section is the **contract** every event (FF-side and app-side) codes against,
353
+ so all apps can feed one shared dashboard. Design events around the questions a dashboard asks, not around code lines.
162
354
 
163
- The current orphan batch-end guard only drops a leading `batch: false` marker from a clipped ordinary batch. It does not make `RemoteMessageProcessor` tolerate missing leading chunks, so a history scan that starts mid-chunk stream is not handled correctly. A follow-up should either anchor capture on the last fully reconstructed runtime batch or make the history scan tolerate and skip an incomplete leading chunk stream. Add coverage that captures an anchor after an intermediate chunk and verifies that historical resolution does not throw or misidentify the target batch.
355
+ ### Principles
164
356
 
165
- ### `pending` vs `unresolvable` on a miss (read-derived availability)
357
+ - **One event per logical operation, with a low-cardinality `outcome`/`path` enum** not many scattered events.
358
+ Group-by dimensions must stay low-cardinality (enums, booleans, counts, durations).
359
+ - **Never put high-cardinality/PII values (`batchId`, `clientId`, `docId`) in group-by dimensions.** When a correlation
360
+ key is needed, emit it as a **tagged detail** (`TelemetryDataTag`), not a dimension.
361
+ - **Severity discipline:** informational (`sendTelemetryEvent`) for usage/health and _smells_; `sendErrorEvent` only for
362
+ provable faults (corruption is already covered by asserts).
363
+ - **Instrument the infrequent control points** (capture, resolve) directly — they are savepoint/load-time, not hot.
364
+ Never emit per-op/per-batch events on `onBatchSequenced`; aggregate (counters or `SampledTelemetryHelper`) if
365
+ per-batch signal is ever needed.
366
+ - The resolver's logger is namespaced `VersionMarkResolver`, so event names below are emitted as
367
+ `VersionMarkResolver:<name>`.
166
368
 
167
- Here, **trimmed** means that older sequenced ops are no longer retained or returned by the service's delta storage. It does not refer to eviction from the resolver's fast-path cache or to an op falling below the minimum sequence number (MSN).
369
+ ### Events
168
370
 
169
- When the scan does not find the batch, the result distinguishes **`pending`** ("not sequenced yet — retry later") from **`unresolvable`** ("its ops were trimmed — gone forever"). Both look identical from the batch id alone (the batch is simply absent), so the distinction uses a **read-derived availability signal**: the current tip (`getCurrentSequenceNumber`) plus where the scan's first op landed relative to `from = sequenceNumberLowerBound + 1`:
371
+ | Event | When | Dimensions | Answers |
372
+ | -------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
373
+ | `Resolve` **(implemented)** | end of `resolve()` (via `finally`, so a thrown scan is still reported) | `outcome` (`resolved`\|`pending`\|`unresolvable`\|`error`), `path` (`session`\|`history`\|`noReader`), `durationMs`, `sequenceNumber` (when resolved) | success rate; how often history is needed; latency; `unresolvable` = data-loss KPI; `error` = the scan threw. `session` means the final result came from the live map, including a live resolution found by the post-history recheck. |
374
+ | `Capture` **(implemented — AB#80270)** | `sealAndCaptureVersionMark()` | `kind` (`pending`\|`resolved`) | capture volume; pending ratio |
170
375
 
171
- - `from > tip` — nothing is sequenced at/after the reference point yet, so the batch cannot have landed → **`pending`**.
172
- - Empty read while ops should exist (`from <= tip`) — the requested range came back empty, which for a strict driver (ODSP's `validateMessages` empties a from-misaligned trimmed range) means the range was trimmed → **`unresolvable`**.
173
- - First available op is past `from` — a trim gap at the anchor; the mark's batch (sequenced just after the reference point) was trimmed → **`unresolvable`**. A found batch resolves before this check, so a gap **on a miss** genuinely means the batch's ops are gone, not merely preceded by other clients' ops (which are still present at `from`).
174
- - Ops present from `from` but the batch is not among them — it has not been sequenced yet → **`pending`**.
376
+ ### Correlation and the app funnel (planned)
175
377
 
176
- This is an **interim, read-derived** signal: it infers availability from how the driver responds to a trimmed range, which is driver-behavior-dependent (strict-empty vs return-from-earliest) and degrades to the conservative outcome when ambiguous. A dedicated driver op-availability / retention API (e.g. an explicit earliest-retained-sequence-number query on `IDocumentDeltaStorageService`, coordinated across drivers) would replace it with a precise, contractual signal — a separate follow-up.
378
+ FF emits only what it can observe (the runtime mechanism). The product funnel locator persisted, restore
379
+ initiated/applied, trigger source (user vs. NiTL agent), user accept/discard — is owned by the app/host (office-bohemia;
380
+ AB#80271) and cannot be emitted from FF. For a shared cross-app dashboard, both layers must:
177
381
 
178
- ### Error handling and invariants
382
+ - use this **stable, app-agnostic schema** plus an `app`/`host` dimension (the host logger supplies host context), and
383
+ - stamp shared **correlation keys** — `containerId` (auto-tagged by the FF logger) and the mark's `batchId` (as a tagged
384
+ detail) — so FF `Resolve` events join to the app's restore events for the same mark.
179
385
 
180
- - A historical reader must never return an op below `from`. `classifyMiss` asserts this because trim classification is invalid if the range contract is violated.
181
- - Delta-storage connection/fetch failures and unpacking failures propagate to the caller. They are operational failures, not legitimate `pending` or `unresolvable` results.
182
- - The `AbortController` is aborted in `finally`, including when a reader, stream, or unpacker throws.
183
- - Listener failures are isolated, logged, and skipped because a missed live promotion remains recoverable through history.
184
- - Inbound pending-state validation runs before notification. A rejected or forked batch cannot cause an app-side promotion.
185
- - `sequenceNumberByBatchId` is only a session cache. Correctness must not depend on an entry remaining present; a miss can fall back to retained history.
386
+ The shared rollout dashboard is tracked in AB#80150.
186
387
 
187
388
  ## Loading a mark
188
389
 
@@ -192,138 +393,374 @@ Loading (restoring) a mark is two explicit steps, and the resolver is the bridge
192
393
  locator --resolve()--> sequenceNumber --loadContainerToSequenceNumber()--> IContainer
193
394
  ```
194
395
 
195
- 1. `resolve(batchId, sequenceNumberLowerBound)` turns the locator into a concrete `sequenceNumber` (a `resolved` mark already carries its `sequenceNumber`, so it skips this step entirely).
196
- 2. `loadContainerToSequenceNumber({ request, loadToSequenceNumber, ... })` (the loader's point-in-time primitive) materializes a read-only container at that sequence number.
396
+ 1. `resolve(batchId, sequenceNumberLowerBound)` turns the locator into a concrete `sequenceNumber` (a `resolved` mark
397
+ already carries its `sequenceNumber`, so it skips this step entirely).
398
+ 1. `loadContainerToSequenceNumber({ request, loadToSequenceNumber, ... })` (the loader's point-in-time primitive)
399
+ materializes a read-only container at that sequence number.
197
400
 
198
- The load primitive stays **mark-agnostic** — it takes a raw `sequenceNumber` and never learns what a "mark" is. This is the deliberate design choice (decision A): the resolver owns the locator→sequence translation, the loader owns materialization, and the two do not merge.
199
- Its current container-loader placement is prototype-era ownership; the planned extraction of the
200
- host-facing load orchestration into a dedicated feature package is documented in the
401
+ The load primitive stays **mark-agnostic** — it takes a raw `sequenceNumber` and never learns what a "mark" is. This is
402
+ the deliberate design choice (decision A): the resolver owns the locator→sequence translation, the loader owns
403
+ materialization, and the two do not merge. Its current container-loader placement is prototype-era ownership; the
404
+ planned extraction of the host-facing load orchestration into a dedicated feature package is documented in the
201
405
  [point-in-time loading guide](../../../../loader/container-loader/src/pointInTime/DEV.md#package-ownership-and-planned-extraction).
202
406
 
407
+ The ODSP point-in-time implementation creates the recoverable (historical snapshot) document service before the live
408
+ document service used for bounded forward replay. If live-service creation fails, it disposes the already-created
409
+ recoverable service and rethrows the original live-service error (AB#77964). If cleanup itself throws, that secondary
410
+ failure is logged without replacing the creation error.
411
+
203
412
  ### Why the load takes a sequence number, not a locator
204
413
 
205
- This mirrors `IUrlResolver.resolve(request): Promise<IResolvedUrl | undefined>` — resolution is a **separate step that returns a value** (including "can't resolve" as a value, not a throw), and load consumes the resolved form. It also matches how `IFluidHandle` surfaces a pending payload state rather than hiding it: when resolution has a legitimate non-error "not yet" outcome, the codebase exposes it as a first-class value.
414
+ This mirrors `IUrlResolver.resolve(request): Promise<IResolvedUrl | undefined>` — resolution is a **separate step that
415
+ returns a value** (including "can't resolve" as a value, not a throw), and load consumes the resolved form. It also
416
+ matches how `IFluidHandle` surfaces a pending payload state rather than hiding it: when resolution has a legitimate
417
+ non-error "not yet" outcome, the codebase exposes it as a first-class value.
206
418
 
207
419
  A locator-taking load (a single `loadContainerToMark(locator)` call) was considered and rejected:
208
420
 
209
- - Return-type impedance: such a call returns `Promise<IContainer>`, but two of the three resolve outcomes (`pending`, `unresolvable`) yield no container. It would have to either throw for both (collapsing "retry later" and "gone forever" into one error path) or return a union the caller must branch on anyway.
210
- - Lifecycle mismatch: the resolver is a **live-session** object bound to an already-open container's runtime and its op reader, while the load creates a **new read-only historical** container. A one-call wrapper would need a live resolver injected into a load that produces a different container instance, conflating two container lifecycles.
211
- - Redundancy: `resolve()` must exist as a standalone call regardless — the app uses it for live promotion (`onBatchSequenced`), for the load-time sweep, and to render pending / unresolvable state in the UI. A wrapper would just duplicate it.
421
+ - Return-type impedance: such a call returns `Promise<IContainer>`, but two of the three resolve outcomes (`pending`,
422
+ `unresolvable`) yield no container. It would have to either throw for both (collapsing "retry later" and "gone
423
+ forever" into one error path) or return a union the caller must branch on anyway.
424
+ - Lifecycle mismatch: the resolver is a **live-session** object bound to an already-open container's runtime and its op
425
+ reader, while the load creates a **new read-only historical** container. A one-call wrapper would need a live resolver
426
+ injected into a load that produces a different container instance, conflating two container lifecycles.
427
+ - Redundancy: `resolve()` must exist as a standalone call regardless — the app uses it for live promotion
428
+ (`onBatchSequenced`), for the load-time sweep, and to render pending / unresolvable state in the UI. A wrapper would
429
+ just duplicate it.
212
430
 
213
- If one-call ergonomics are ever wanted, the right shape is a thin wrapper that **returns the three-state result** (`IContainer` on `resolved`, else `pending` / `unresolvable`) layered on top of these two primitives — not a change to the primitives.
431
+ If one-call ergonomics are ever wanted, the right shape is a thin wrapper that **returns the three-state result**
432
+ (`IContainer` on `resolved`, else `pending` / `unresolvable`) layered on top of these two primitives — not a change to
433
+ the primitives.
214
434
 
215
435
  ## Removed runtime persistence
216
436
 
217
- The previous runtime-owned marks map and `.versionMarks` summary blob were removed. There is no runtime durable mark store and no summarized durable `batchId -> sequenceNumber` index. Once a batch resolves, the app must persist the sequence number in its own store.
437
+ The previous runtime-owned marks map and `.versionMarks` summary blob were removed. There is no runtime durable mark
438
+ store and no summarized durable `batchId -> resolved point` index. Once a batch resolves, the app must persist the
439
+ sequence number and, when present, its server timestamp in its own store.
218
440
 
219
441
  ## Fast-path cache bounding (MSN eviction)
220
442
 
221
- `VersionMarkResolver.sequenceNumberByBatchId` (`Map<batchId, sequenceNumber>`) is a **live-session fast-path cache**, not a source of truth: `processInboundBatch` inserts one entry per inbound batch, and `resolve()` reads it only as the fast path before falling back to the historical-op scan (`resolveFromHistory`). The protocol invariant is that a `batchId` maps to one `sequenceNumber`; the implementation suppresses an identical repeated update and otherwise uses `Map.set`. A miss degrades to the history scan when a reader is available, so eviction affects speed rather than correctness while the ops remain retained.
443
+ `VersionMarkResolver.resolvedBatchById` (`Map<batchId, { sequenceNumber, timestamp }>`) is a **live-session fast-path
444
+ cache**, not a source of truth: `processInboundBatch` inserts the first entry observed for each batch id, and
445
+ `resolve()` reads it only as the fast path before falling back to the historical-op scan (`resolveFromHistory`).
446
+ Although a batch id normally maps to one resolved point, a spurious service redelivery can reuse an effective batch id
447
+ at a different sequence number. The implementation keeps the first-landed resolution and ignores later updates for that
448
+ id. A miss degrades to the history scan when a reader is available, so eviction affects speed rather than correctness
449
+ while the ops remain retained.
222
450
 
223
451
  ### What MSN means here
224
452
 
225
- The minimum sequence number (MSN) is the protocol's collaboration-window floor. When an op is below the current MSN, every active client has advanced far enough to have processed it. The resolver reads the value from `deltaManager.minimumSequenceNumber` through `getCurrentMinimumSequenceNumber`.
453
+ The minimum sequence number (MSN) is the protocol's collaboration-window floor. When an op is below the current MSN,
454
+ every active client has advanced far enough to have processed it. The resolver reads the value from
455
+ `deltaManager.minimumSequenceNumber` through `getCurrentMinimumSequenceNumber`.
226
456
 
227
- MSN is **not** part of a stored mark and is not used to create, resolve, expire, or validate marks. Marks come only from an app calling `sealAndCaptureVersionMark()` and storing the returned locator. MSN also is **not** the service's op-retention boundary: an op can be below MSN and still be available from delta storage, or can later be trimmed according to service policy. `unresolvable` is about delta-storage retention, not MSN.
457
+ MSN is **not** part of a stored mark and is not used to create, resolve, expire, or validate marks. Marks come only from
458
+ an app calling `sealAndCaptureVersionMark()` and storing the returned locator. MSN also is **not** the service's
459
+ op-retention boundary: an op can be below MSN and still be available from delta storage, or can later be trimmed
460
+ according to service policy. `unresolvable` is about delta-storage retention, not MSN.
228
461
 
229
- The resolver uses MSN only to answer a cache-lifetime question: how long should a live container retain every observed `batchId -> sequenceNumber` mapping? Without eviction, a long-running container would add one entry for every tracked inbound batch and the map would grow without bound. MSN provides a protocol-derived, workload-sensitive boundary instead of a fixed entry count or timeout.
462
+ The resolver uses MSN only to answer a cache-lifetime question: how long should a live container retain every observed
463
+ `batchId -> { sequenceNumber, timestamp }` mapping? Without eviction, a long-running container would add one entry for
464
+ every tracked inbound batch and the map would grow without bound. MSN provides a protocol-derived, workload-sensitive
465
+ boundary instead of a fixed entry count or timeout.
230
466
 
231
467
  ### Why eviction below MSN is useful
232
468
 
233
- For the normal live-promotion path, a batch below MSN has already passed every active client. If tracking was enabled, `processInboundBatch` has already fired `onBatchSequenced`, giving the app an opportunity to replace its stored pending locator with the durable sequence number. Keeping that batch in the resolver's session cache after it leaves the collaboration window is therefore only an optimization for repeated lookups.
469
+ For the normal live-promotion path, a batch below MSN has already passed every active client. If tracking was enabled,
470
+ `processInboundBatch` has already fired `onBatchSequenced`, giving the app an opportunity to replace its stored pending
471
+ locator with the durable sequence number and server timestamp. Keeping that batch in the resolver's session cache after
472
+ it leaves the collaboration window is therefore only an optimization for repeated lookups.
234
473
 
235
- Eviction does not delete an app-owned mark or its resolved sequence number. A later `resolve()` cache miss scans retained historical ops when `fetchOps` is available. If no historical reader is wired, an evicted id returns `pending`; in that configuration the consumer must rely on the live `onBatchSequenced` promotion having been persisted. Likewise, listener failure is recoverable only when historical reads remain available.
474
+ Eviction does not delete an app-owned mark or its resolved sequence number. A later `resolve()` cache miss scans
475
+ retained historical ops when `fetchOps` is available. If no historical reader is wired, an evicted id returns `pending`;
476
+ in that configuration the consumer must rely on the live `onBatchSequenced` promotion having been persisted. Likewise,
477
+ listener failure is recoverable only when historical reads remain available.
236
478
 
237
- MSN speaks only about active clients in the current collaboration window. A disconnected client, a client loading much later, or a host that subscribes after a batch was processed cannot rely on the live cache or notification; those cases are why the stored locator includes a history anchor and why historical resolution exists.
479
+ MSN speaks only about active clients in the current collaboration window. A disconnected client, a client loading much
480
+ later, or a host that subscribes after a batch was processed cannot rely on the live cache or notification; those cases
481
+ are why the stored locator includes a history anchor and why historical resolution exists.
238
482
 
239
483
  ### Eviction algorithm and invariants
240
484
 
241
- `processInboundBatch` inserts the completed batch, then calls `evictBelowMinimumSequenceNumber()`. Entries are observed and inserted in sequence order. The eviction loop walks the `Map` from its oldest insertion:
485
+ `processInboundBatch` inserts the completed batch, then calls `evictBelowMinimumSequenceNumber()`. Entries are observed
486
+ and inserted in sequence order. The eviction loop walks the `Map` from its oldest insertion:
242
487
 
243
488
  1. Delete each entry whose `sequenceNumber < minimumSequenceNumber`.
244
- 2. Stop at the first entry whose `sequenceNumber >= minimumSequenceNumber`; all later entries are also expected to be in the collaboration window.
489
+ 1. Stop at the first entry whose `sequenceNumber >= minimumSequenceNumber`; all later entries are also expected to be in
490
+ the collaboration window.
245
491
 
246
- This makes cleanup proportional to the number of entries actually evicted (amortized O(evicted)). The just-recorded inbound batch is at or above the current MSN, so it is retained. The implementation also relies on the invariant that a stable `batchId` never remaps to a different sequence number; changing an existing key without moving its insertion position would otherwise break the ordered early-exit assumption.
492
+ This makes cleanup proportional to the number of entries actually evicted (amortized O(evicted)). The just-recorded
493
+ inbound batch is at or above the current MSN, so it is retained. The implementation also relies on the invariant that a
494
+ stable `batchId` never remaps to a different sequence number (the protocol guarantees this); `processInboundBatch`
495
+ **asserts** it rather than silently overwriting, because changing an existing key without moving its insertion position
496
+ would break the ordered early-exit assumption.
247
497
 
248
498
  ### Tracking gate (`isTracking`)
249
499
 
250
- Per-inbound-batch work (deriving the batch identity and populating the map/notifying listeners) is **gated on a sticky `isTracking` flag**, so a container that never uses version marks does no version-mark work on the hot path. This mirrors #22497, which gated `DuplicateBatchDetector` on offline load being enabled even though its cost was small — there is no reason to pay a predictable per-batch cost for a feature that can't do anything. Tracking flips on (and stays on) the first time the feature is actually used this session: a **pending** `sealAndCaptureVersionMark()` (a resolved capture needs no tracking) or an `onBatchSequenced` subscription. The runtime reads `versionMarkResolverInternal.isTracking` and skips the whole update block while it is false. A batch in flight at the moment tracking flips on may be missed, which is harmless: an app's own captured mark is for a not-yet-sequenced edit (tracked once it lands), and cross-session resolution uses the history scan regardless.
500
+ Per-inbound-batch work (deriving the batch identity and populating the map/notifying listeners) is **gated on a sticky
501
+ `isTracking` flag**, so a container that never uses version marks does no version-mark work on the hot path. This
502
+ mirrors #22497, which gated `DuplicateBatchDetector` on offline load being enabled even though its cost was small —
503
+ there is no reason to pay a predictable per-batch cost for a feature that can't do anything. Tracking flips on (and
504
+ stays on) the first time the feature is actually used this session: a **pending** `sealAndCaptureVersionMark()` (a
505
+ resolved capture needs no tracking), an `onBatchSequenced` subscription, or a `resolve()` call. `resolve()` enables it
506
+ up front so that a batch which sequences during the history scan (or live, in the no-reader case) is recorded and can be
507
+ recovered by the post-scan session-map recheck, even when the caller never captured or subscribed. The runtime reads
508
+ `versionMarkResolverInternal.isTracking` and skips the whole update block while it is false. A batch in flight at the
509
+ moment tracking flips on may be missed, which is harmless: an app's own captured mark is for a not-yet-sequenced edit
510
+ (tracked once it lands), and cross-session resolution uses the history scan regardless.
251
511
 
252
512
  ## Current test map
253
513
 
254
514
  - `src/test/versionMarks/inboundBatch.spec.ts` covers full, empty, derived-id, explicit-id, and piecemeal batch updates.
255
- - `src/test/versionMarks/versionMarkResolver.spec.ts` covers capture ordering/results, the tracking gate, live-map precedence, no-reader behavior, fresh and resubmitted batches, multi-op batches across stream reads, chunk reassembly, clipped leading ordinary batches, miss classifications, range arguments, reader-contract assertion, abort behavior, listener isolation/unsubscribe/deduplication, and MSN eviction.
256
- - `src/test/pendingStateManager.spec.ts` covers ignoring unapplied stashed messages and reading an explicit reconnect-stable id from the start of the most recently flushed multi-op batch.
257
- - `src/test/containerRuntime.spec.ts` covers the complete context `fetchOps` -> historical unpack -> resolver path (including system/server-op filtering and abort-after-match), plus the ordering guarantee that failed inbound validation does not notify listeners.
515
+ - `src/test/versionMarks/versionMarkResolver.spec.ts` covers capture ordering/results, the tracking gate, live-map
516
+ precedence, no-reader behavior, fresh and resubmitted batches, multi-op batches across stream reads, chunk reassembly,
517
+ a partial initial stream interleaved with a complete chunked target through the real unpack pipeline, miss
518
+ classifications, range arguments, reader-contract assertion, abort behavior, listener
519
+ isolation/unsubscribe/deduplication, MSN eviction, and the `Resolve` telemetry event.
520
+ - `src/test/opLifecycle/opSplitter.spec.ts` covers strict chunk ordering plus the historical configuration that discards
521
+ only a client's first partial stream while independently reconstructing complete interleaved streams.
522
+ - `src/test/opLifecycle/opSerialization.spec.ts` covers `tryGetDeserializedRuntimeOpCopy` (runtime-op copy with
523
+ deserialized contents; non-runtime and clientless ops return undefined without deserializing).
524
+ - `src/test/pendingStateManager.spec.ts` covers ignoring unapplied stashed messages and reading an explicit
525
+ reconnect-stable id from the start of the most recently flushed multi-op batch.
526
+ - `src/test/containerRuntime.spec.ts` covers the complete context `fetchOps` -> historical unpack -> resolver path
527
+ (including system/server-op filtering and abort-after-match), plus the ordering guarantee that failed inbound
528
+ validation does not notify listeners.
258
529
 
259
530
  ## Future work
260
531
 
261
532
  ### Missing end-to-end coverage
262
533
 
263
- The existing real-service ODSP suites under `packages/test/test-end-to-end-tests/src/test/pointInTime/` begin with a known sequence number and exercise loading. They do not create that sequence number through the version-mark API. Extend `pointInTimeTestUtils.ts` with a host entry point that exposes `IVersionMarkResolver`, then add:
264
-
265
- 1. **Pending mark to historical load:** Make a local edit, call `sealAndCaptureVersionMark()`, persist the pending locator outside the runtime, sequence the batch, resolve the locator, and load the resulting sequence number. Verify the loaded state includes the marked edit and excludes later edits.
266
- 2. **Already-resolved capture:** Capture with no local pending batch and load the returned sequence number directly, proving the no-resolution path produces the expected historical state.
267
- 3. **Live promotion from another client:** Capture on one client and use `onBatchSequenced` on another connected client to promote the stored locator, proving batch identity is observable across clients.
268
- 4. **Reconnect and resubmission:** Capture before disconnect, reconnect and resubmit the multi-op batch with its original explicit `batchId`, then verify both live and historical resolution still find the mark.
269
- 5. **Capturing-client loss:** Close the capturing client before its acknowledgement, open a fresh client, resolve from retained historical ops, and load the marked state. This is the primary cross-session recovery scenario.
270
- 6. **Transformed batches:** Capture edits that produce grouped, compressed, and chunked batches and verify the real inbound/history pipelines recover the same effective batch identity.
271
- 7. **Pending and retention outcomes:** Resolve before the batch sequences and observe `pending`; when the test environment can deterministically trim the target ops, verify the same stored locator becomes `unresolvable`.
272
- 8. **Offline and staging lifecycles:** Cover stash/rehydration plus staging commit and discard once those capture contracts are finalized.
273
- 9. **Repeated capture and multiple pending batches:** Capture twice while the same batch remains unacknowledged, then capture after a second local batch is flushed. Verify each mark identifies the latest batch whose state it includes and that changing remote sequence numbers between captures does not produce an invalid lower bound.
274
- 10. **Tracking activation boundaries:** Subscribe while an inbound batch is already being processed and immediately after a batch completed. Verify the documented behavior at each boundary and prove that a missed notification remains recoverable through `resolve()`.
275
- 11. **Sequenced-before-persisted restore:** Resolve a mark from the live map as soon as its batch sequences, then immediately start a point-in-time load before ODSP has flushed that op to durable delta storage. Define whether the host waits, explicitly requests an op flush, or retries later, and verify a resolved locator never implies that the target is already materializable by a storage-only loader.
534
+ The existing real-service ODSP suites under `packages/test/test-end-to-end-tests/src/test/pointInTime/` begin with a
535
+ known sequence number and exercise loading. They do not create that sequence number through the version-mark API. Extend
536
+ `pointInTimeTestUtils.ts` with a host entry point that exposes `IVersionMarkResolver`, then add:
537
+
538
+ 1. **Pending mark to historical load:** Make a local edit, call `sealAndCaptureVersionMark()`, persist the pending
539
+ locator outside the runtime, sequence the batch, resolve the locator, and load the resulting sequence number. Verify
540
+ the loaded state includes the marked edit and excludes later edits.
541
+ 1. **Already-resolved capture:** Capture with no local pending batch and load the returned sequence number directly,
542
+ proving the no-resolution path produces the expected historical state.
543
+ 1. **Live promotion from another client:** Capture on one client and use `onBatchSequenced` on another connected client
544
+ to promote the stored locator, proving batch identity is observable across clients.
545
+ 1. **Reconnect and resubmission:** Capture before disconnect, reconnect and resubmit the multi-op batch with its
546
+ original explicit `batchId`, then verify both live and historical resolution still find the mark.
547
+ 1. **Capturing-client loss:** Close the capturing client before its acknowledgement, open a fresh client, resolve from
548
+ retained historical ops, and load the marked state. This is the primary cross-session recovery scenario.
549
+ 1. **Transformed batches:** Capture edits that produce grouped, compressed, and chunked batches and verify the real
550
+ inbound/history pipelines recover the same effective batch identity.
551
+ 1. **Pending and retention outcomes:** Resolve before the batch sequences and observe `pending`; when the test
552
+ environment can deterministically trim the target ops, verify the same stored locator becomes `unresolvable`.
553
+ 1. **Offline and staging lifecycles:** Cover stash/rehydration plus staging commit and discard once those capture
554
+ contracts are finalized.
555
+ 1. **Repeated capture and multiple pending batches:** Capture twice while the same batch remains unacknowledged, then
556
+ capture after a second local batch is flushed. Verify each mark identifies the latest batch whose state it includes
557
+ and that changing remote sequence numbers between captures does not produce an invalid lower bound.
558
+ 1. **Tracking activation boundaries:** Subscribe while an inbound batch is already being processed and immediately after
559
+ a batch completed. Verify the documented behavior at each boundary and prove that a missed notification remains
560
+ recoverable through `resolve()`.
561
+ 1. **Sequenced-before-persisted restore:** Resolve a mark from the live map as soon as its batch sequences, then
562
+ immediately start a point-in-time load before ODSP has flushed that op to durable delta storage. Define whether the
563
+ host waits, explicitly requests an op flush, or retries later, and verify a resolved locator never implies that the
564
+ target is already materializable by a storage-only loader.
276
565
 
277
566
  ### Resolver correctness and robustness gaps
278
567
 
279
- 1. **Chunk-stream scan anchors:** The history scan can begin after one or more chunks of a message have already been trimmed from the requested range. `RemoteMessageProcessor` cannot currently reconstruct that incomplete leading chunk stream. Either capture a lower bound that is known to follow the last fully reconstructed runtime batch, or teach historical resolution to discard an incomplete leading chunk stream before processing the next complete batch. Cover anchors on every intermediate chunk, including a target batch immediately after the clipped stream.
280
- 2. **Live/history resolution races:** A batch can be recorded by `processInboundBatch` while a history scan for the same `batchId` is in progress. Recheck the live map before returning a miss so `resolve()` does not return stale `pending` or `unresolvable` after the batch has resolved in-session. Also decide whether concurrent calls for the same locator should share one scan rather than issuing duplicate delta-storage reads.
281
- 3. **Finite and cancelable history reads:** The current scan requests `[lowerBound + 1, undefined)`, has no caller-supplied cancellation signal, and can inherit long driver retry behavior. Consider snapshotting an upper bound from the observed tip, accepting an `AbortSignal`, and canceling outstanding scans when the runtime is disposed. Cover cancellation before fetch, during `fetchMessages`, during `stream.read()`, and after a match races cancellation.
282
- 4. **Stored-locator validation:** `batchId` and `sequenceNumberLowerBound` may come from app-owned persisted data. Define whether `resolve()` trusts that data or rejects an empty batch ID, negative/fractional/non-safe sequence numbers, and a lower bound whose `+ 1` overflows safe integer precision. Invalid persisted coordinates must not silently scan the wrong range or be classified as a legitimate mark state.
283
- 5. **Conflicting batch identity:** `processInboundBatch` suppresses an identical repeated `(batchId, sequenceNumber)` pair but currently overwrites the map if the same `batchId` appears at a different sequence number. The protocol says that remapping is impossible, and map insertion order is used by MSN eviction. Assert or fail explicitly on a conflicting remap and add a regression test so corruption cannot invalidate the eviction ordering assumption.
284
- 6. **Historical stream contract:** Add direct coverage for duplicated, decreasing, gapped, and malformed batch/chunk sequences, plus a target found after a trim gap. Define which violations are rejected by the driver, which are rejected by the resolver, and which conservatively return `pending`; never infer `unresolvable` from a stream that violated the requested range contract.
568
+ 1. **Concurrent history scans:** AB#82260 fixes the stale-miss race by rechecking the live map after a non-resolved
569
+ history result and preferring a batch that sequenced during the scan. Still decide whether concurrent calls for the
570
+ same locator should share one scan rather than issuing duplicate delta-storage reads.
571
+ 1. **Finite and cancelable history reads:** The current scan requests `[lowerBound, undefined)`, has no caller-supplied
572
+ cancellation signal, and can inherit long driver retry behavior. Consider snapshotting an upper bound from the
573
+ observed tip, accepting an `AbortSignal`, and canceling outstanding scans when the runtime is disposed. Cover
574
+ cancellation before fetch, during `fetchMessages`, during `stream.read()`, and after a match races cancellation.
575
+ 1. **Stored-locator validation:** `batchId` and `sequenceNumberLowerBound` may come from app-owned persisted data.
576
+ Define whether `resolve()` trusts that data or rejects an empty batch ID and negative/fractional/non-safe sequence
577
+ numbers. Invalid persisted coordinates must not silently scan the wrong range or be classified as a legitimate mark
578
+ state. The safe-integer `+ 1` consideration now applies at **capture** rather than read: `sealAndCaptureVersionMark`
579
+ stores `referenceSequenceNumber + 1`, so that addition (not a read-time `lowerBound + 1`) is the arithmetic subject
580
+ to safe-integer precision.
581
+ 1. **Persisted-locator format compatibility:** The app persists the `pending` locator (`batchId` +
582
+ `sequenceNumberLowerBound`) and may resolve it much later, so the meaning of those fields is a compatibility
583
+ contract. The risk is that `sequenceNumberLowerBound` is a bare number with no format/version marker, so a semantic
584
+ change is invisible to a reader: e.g. the exclusive→inclusive change would make old code reading a new-format locator
585
+ compute `stored + 1` and silently scan past the target. This is not a concern today because the exclusive format was
586
+ **never shipped** (see [Resolve control flow](#resolve-control-flow)) — the current inclusive format is the implicit
587
+ v1 baseline and there are no other persisted formats. **Decision (deferred):** add nothing now; treat today's format
588
+ as v1 and only do compatibility work _if/when_ the locator's meaning or shape actually changes. If it never changes,
589
+ there is nothing to do. Marks are resolved cross-client (whichever client picks one up first) across a mixed-version
590
+ fleet, so a future change must be introduced compat-safely — following FF precedent, either (a) gate the new format's
591
+ **write** behind `minVersionForCollab`/`OldestSupportedClientVersion` so older readers in the compat window never
592
+ encounter it (as versioned codecs pick their write version), or (b) add an explicit `formatVersion` field and detect
593
+ it on read, defaulting a missing marker to v1 and failing closed on an unrecognized one (as
594
+ `summaryFormat.getAttributesFormatVersion` maps a missing version to legacy v0). A tiny two-field locator suits the
595
+ lightweight `summaryFormat`-style field inspection over the full versioned-codec subsystem in
596
+ `dds/tree/src/codec/versioned`. Because the app owns the stored record (`VersionMarkCapture` is transient), decide at
597
+ that point whether FF stamps the version into the capture result (preferred, since FF owns the semantics) or
598
+ documents that the app must version its own record.
599
+ 1. **Historical stream contract:** Add direct coverage for duplicated, decreasing, gapped, and malformed batch/chunk
600
+ sequences, plus a target found after a trim gap. Define which violations are rejected by the driver, which are
601
+ rejected by the resolver, and which conservatively return `pending`; never infer `unresolvable` from a stream that
602
+ violated the requested range contract.
285
603
 
286
604
  ### API and design follow-ups
287
605
 
288
- - Review the `IContainerContextInternal extends IContainerContext` cross-layer integration with Navin to establish the preferred pattern for features that span loader and runtime layers. In particular, determine whether explicit layer-compat support would make this interface evolution safer or more maintainable.
289
- - Consider merging `getCurrentPendingBatchId` into `flushPendingBatch` so sealing the batch returns its resulting `batchId`. This would keep the ordered flush-then-read operation inside one runtime hook instead of requiring the resolver to call two hooks in sequence.
290
- - Reevaluate whether distinguishing `pending` from `unresolvable` is valuable enough to justify the driver-dependent heuristics in `classifyMiss`. The current implementation makes educated guesses from empty reads and sequence gaps, so its confidence depends on how each driver's delta storage reports trimmed ranges. Consider returning the conservative `pending` result for ambiguous misses, collapsing the states, or deferring a definitive `unresolvable` result until delta storage exposes an explicit retention boundary.
291
- - Before promoting this API to `@beta`, try extending the existing `batchEnd` event to expose the effective stable batch ID and reuse that event for mark promotion. Avoid finalizing `onBatchSequenced` as a parallel batch-sequenced notification API unless the existing event cannot support this use case.
292
- - Define the teardown contract for listeners and in-flight `resolve()` calls. Subscriptions should not retain app objects after runtime disposal, and a resolver obtained from a closed runtime should fail predictably rather than starting new storage work.
606
+ - **Loader/runtime compatibility pattern (resolved with Navin).** `fetchOps` is a new loader-provided API consumed by
607
+ runtime logic. Making the member optional and using its presence as the capability signal is the correct cross-layer
608
+ pattern; a separate `supportedFeatures` negotiation is unnecessary for a new API. Fluid supports a 12-month Runtime ->
609
+ Loader compatibility window, so a newer runtime must continue to work with an older loader that predates `fetchOps`.
610
+ The capability first shipped in client 2.115.0 at layer generation 9. Keep the optional branch through generation 20;
611
+ at generation 21 every supported loader is guaranteed to provide it, and AB#81034 tracks making the member required
612
+ and removing the branch.
613
+ - Consider merging `getCurrentPendingBatchId` into `flushPendingBatch` so sealing the batch returns its resulting
614
+ `batchId`. This would keep the ordered flush-then-read operation inside one runtime hook instead of requiring the
615
+ resolver to call two hooks in sequence.
616
+ - Reevaluate whether distinguishing `pending` from `unresolvable` is valuable enough to justify the driver-dependent
617
+ heuristics in `classifyMiss`. The current implementation makes educated guesses from empty reads and sequence gaps, so
618
+ its confidence depends on how each driver's delta storage reports trimmed ranges. Consider returning the conservative
619
+ `pending` result for ambiguous misses, collapsing the states, or deferring a definitive `unresolvable` result until
620
+ delta storage exposes an explicit retention boundary. This is separate from a missing historical reader, described
621
+ below.
622
+ - In a future beta-breaking release, make `timestamp` required on the resolved variants of `VersionMarkCapture` and
623
+ `ResolveResult`, and make the third `onBatchSequenced` listener argument required. It is optional today only so
624
+ existing callers, callbacks, mocks, and stored resolved records remain source-compatible. Before taking the break,
625
+ verify all supported resolution paths always produce a server timestamp, migrate known consumers, and follow the
626
+ beta-breaking release process.
627
+ - **`batchEnd` vs `onBatchSequenced` (deferred).** The version-mark APIs are already `@legacy @beta`, so this is no
628
+ longer a pre-beta prerequisite. `onBatchSequenced` broadcasts `(batchId, sequenceNumber)` for live mark promotion; the
629
+ open question is whether to instead extend the existing `batchEnd` event (on `IContainerRuntimeBaseEvents`) to expose
630
+ the effective stable batch ID and reuse it, retiring `onBatchSequenced`.
631
+
632
+ **Case for consolidating (Mark's original point):** the new event is largely a subset of `batchEnd` plus one
633
+ generally-applicable field (the stable batch id). If API changes were free we would not add a second parallel
634
+ notification API; every extra public API is long-term surface we are then responsible for maintaining, documenting,
635
+ and evolving. Avoiding that cruft is the benefit.
636
+
637
+ **Case for keeping `onBatchSequenced` separate:** it is **not** a strict subset of `batchEnd` — its firing contract is
638
+ correctness-load-bearing and differs in three ways: (1) it fires **only after pending-state validation**, so a
639
+ rejected/forked batch never triggers promotion, whereas `batchEnd` fires even on error; (2) it is **gated on
640
+ `isTracking`**, firing only for containers actually using version marks, whereas `batchEnd` fires on all clients; (3)
641
+ the stable-batch-id derivation is itself tracking-gated. A consumer migrated onto `batchEnd` would have to re-derive
642
+ that validated-only + tracking-gated filtering itself, and getting it wrong would promote a mark on a batch that was
643
+ never durably sequenced. There is also cost on the emit side (deriving/exposing the id for every `batchEnd`, including
644
+ on untracked clients).
645
+
646
+ **If we do decide to consolidate, the phased (non-breaking-until-last-step) path is:**
647
+ 1. **Add the stable batch id to `batchEnd`** — additive event-payload change, non-breaking, lands in any minor
648
+ (optional/nullable field, since `batchEnd` fires when no stable id exists).
649
+ 1. **Deprecate `onBatchSequenced`** (`@deprecated` TSDoc pointing at `batchEnd`, with removal version + tracking
650
+ issue) — non-breaking, signals the migration and starts the partner lead-time clock.
651
+ 1. **Remove `onBatchSequenced`** — the only beta-breaking step; requires **both** an x.x0 (or major) break window
652
+ _and_ the ~12-week partner lead time (office-bohemia consumes this API), staged on a `test/breaks/client/#.#0/`
653
+ branch.
654
+
655
+ **Gate on step 3:** removal is only safe if a `batchEnd` consumer can faithfully reproduce `onBatchSequenced`'s
656
+ validated-only + tracking-gated semantics. If it cannot, keep `onBatchSequenced` and do not finalize the
657
+ consolidation. **Decision: deferred** — none of this is required for the current beta, it does not block
658
+ office-bohemia testing (the feature works today via `sealAndCaptureVersionMark`/`resolve` plus `onBatchSequenced`, and
659
+ a missed live promotion is recoverable via the history scan), and the only breaking step is gated to a future x.x0
660
+ regardless.
661
+
662
+ - Define the teardown contract for listeners and in-flight `resolve()` calls. Subscriptions should not retain app
663
+ objects after runtime disposal, and a resolver obtained from a closed runtime should fail predictably rather than
664
+ starting new storage work.
665
+
666
+ ### Missing `fetchOps` and resolve-result semantics
667
+
668
+ When a newer runtime is paired with an older loader, `fetchOps` is absent. The runtime already handles that pairing
669
+ safely: it does not construct the historical reader/unpacker, and a live-map miss returns `{ kind: "pending" }` with
670
+ telemetry path `noReader`. The process does not crash, and a batch observed later in the same live session can still
671
+ resolve through `processInboundBatch`.
672
+
673
+ The open production-design question is semantic clarity. `pending` currently covers both:
674
+
675
+ 1. the batch has not sequenced yet and a normal retry may resolve it; and
676
+ 1. this loader cannot inspect retained history, so the runtime cannot determine whether the batch sequenced in another
677
+ session.
678
+
679
+ This union is consumed by the host/service layer, not displayed directly to an end user. It can still affect end-user
680
+ behavior indirectly if the host leaves a version unresolved or retries forever. `unresolvable` is not an exact
681
+ substitute: its current contract means retained history was available but the target ops were trimmed or otherwise
682
+ proven gone. A later container load with a newer loader could resolve a mark that the old-loader pairing could not
683
+ inspect.
684
+
685
+ Before production, choose and document one policy:
686
+
687
+ - **Non-breaking clarification (preferred if sufficient):** add an optional reason to the existing `pending` member,
688
+ such as `reason?: "awaitingSequence" | "historicalOpsUnavailable"`. Existing consumers and older runtime results
689
+ remain valid, while an updated host can choose a different retry policy.
690
+ - **Clean distinct outcome:** add an `unavailable`/`indeterminate` result, or make a reason required. This is a breaking
691
+ change to the exported `@legacy @beta` union because exhaustive consumers must handle the new shape. It requires a
692
+ changeset, regenerated API reports, API Council approval, an allowed beta-break window, and office-bohemia partner
693
+ lead time/integration testing.
694
+ - **Reuse `unresolvable` (not recommended without redefining it):** this avoids a new union member, but would blur a
695
+ terminal retained-history result with a capability-limited result that may become resolvable after loading with a
696
+ newer loader. If selected, its contract and host retry behavior must be deliberately changed and documented.
697
+ - **Keep current behavior:** explicitly define no-reader `pending` as a conservative compatibility result and require
698
+ the host to bound retries or rely on live promotion. This avoids an API change but preserves the ambiguity.
699
+
700
+ Whichever policy is selected, test both a current loader and an old-loader-shaped context with no `fetchOps`, and
701
+ document whether a host should retain and retry the mark after a later deployment/reload. Do not remove the optional
702
+ branch as part of this decision; that cleanup remains independently gated by AB#81034 and generation 21.
293
703
 
294
704
  ### Flush side effect and corner cases
295
705
 
296
- The flush side effect in `sealAndCaptureVersionMark()` is acceptable, but it creates corner cases that should be handled explicitly and covered in tests and consumer documentation.
706
+ The flush side effect in `sealAndCaptureVersionMark()` is acceptable, but it creates corner cases that should be handled
707
+ explicitly and covered in tests and consumer documentation.
297
708
 
298
- Consider adding a `notCaptured` result to `VersionMarkCapture` for expected caller-state conditions where capture cannot safely begin:
709
+ Consider adding a `notCaptured` result to `VersionMarkCapture` for expected caller-state conditions where capture cannot
710
+ safely begin:
299
711
 
300
712
  ```ts
301
713
  type VersionMarkCapture =
302
- | { kind: "pending"; batchId: string; sequenceNumberLowerBound: number }
303
- | { kind: "resolved"; sequenceNumber: number }
304
- | {
305
- kind: "notCaptured";
306
- reason: "unsafeToFlush" | "stagingNotSupported";
307
- };
714
+ | { kind: "pending"; batchId: string; sequenceNumberLowerBound: number }
715
+ | { kind: "resolved"; sequenceNumber: number }
716
+ | {
717
+ kind: "notCaptured";
718
+ reason: "unsafeToFlush" | "stagingNotSupported";
719
+ };
308
720
  ```
309
721
 
310
- This is preferable to throwing when the runtime can detect the condition before changing batch state. In particular, capture should preflight reentrant/inbound processing, manual batch accumulation inside `orderSequentially`, and staging mode before calling `flush()`. Today `ContainerRuntime.flush()` treats failures as critical: for example, flushing inside `orderSequentially` asserts, closes the container, and rethrows. A mark request made at an inconvenient but recoverable time should instead return `notCaptured`, perform no flush, create no locator, and leave the container usable.
311
-
312
- `notCaptured` should not become a catch-all for failures after flushing starts. Unexpected submission failures, an oversized or invalid batch, a closed/disposed runtime, and other operational or data-processing errors should continue to throw and follow their existing container lifecycle. Returning a normal result after partial mutation would hide an indeterminate capture. Keep the reason union small and actionable; callers can retry `unsafeToFlush` after leaving the current callback, while `stagingNotSupported` means they must wait for commit/discard or use a future staging-aware capture contract.
313
-
314
- Adding this variant is an API change. Before promotion, decide whether `sealAndCaptureVersionMark()` should always return the three-way union or whether unsafe contexts should remain programmer errors. If `notCaptured` is adopted, document that it guarantees no mark was produced and no capture-triggered flush occurred.
315
-
316
- Staging mode also needs an explicit contract for both commit and discard. Capturing staged edits should not send them immediately. Committing should preserve the captured batch identity through submission/resubmission so the mark resolves normally. After discard, the captured batch will never sequence, so the resulting mark behavior must be defined and documented.
722
+ This is preferable to throwing when the runtime can detect the condition before changing batch state. In particular,
723
+ capture should preflight reentrant/inbound processing, manual batch accumulation inside `orderSequentially`, and staging
724
+ mode before calling `flush()`. Today `ContainerRuntime.flush()` treats failures as critical: for example, flushing
725
+ inside `orderSequentially` asserts, closes the container, and rethrows. A mark request made at an inconvenient but
726
+ recoverable time should instead return `notCaptured`, perform no flush, create no locator, and leave the container
727
+ usable.
728
+
729
+ `notCaptured` should not become a catch-all for failures after flushing starts. Unexpected submission failures, an
730
+ oversized or invalid batch, a closed/disposed runtime, and other operational or data-processing errors should continue
731
+ to throw and follow their existing container lifecycle. Returning a normal result after partial mutation would hide an
732
+ indeterminate capture. Keep the reason union small and actionable; callers can retry `unsafeToFlush` after leaving the
733
+ current callback, while `stagingNotSupported` means they must wait for commit/discard or use a future staging-aware
734
+ capture contract.
735
+
736
+ Adding this variant is an API change. Before promotion, decide whether `sealAndCaptureVersionMark()` should always
737
+ return the three-way union or whether unsafe contexts should remain programmer errors. If `notCaptured` is adopted,
738
+ document that it guarantees no mark was produced and no capture-triggered flush occurred.
739
+
740
+ Staging mode also needs an explicit contract for both commit and discard. Capturing staged edits should not send them
741
+ immediately. Committing should preserve the captured batch identity through submission/resubmission so the mark resolves
742
+ normally. After discard, the captured batch will never sequence, so the resulting mark behavior must be defined and
743
+ documented.
317
744
 
318
745
  Suggested test coverage:
319
746
 
320
- 1. **Real batch cut:** Submit an op through `ContainerRuntime`, call capture before the TurnBased flush, and verify that capture flushes the op into `PendingStateManager` and returns that exact batch's ID rather than a mocked ID.
321
- 2. **Unsafe contexts:** Call capture during inbound processing and inside `orderSequentially`; verify `notCaptured` with `reason: "unsafeToFlush"`, no flush, and no container closure. Also verify a later retry succeeds.
322
- 3. **Staging commit and discard:** Capture staged edits and verify nothing is sent immediately. Verify that commit preserves the captured ID through resubmit and resolution, and define and test the result after discard.
323
- 4. **Offline rehydration:** Capture while disconnected, stash and rehydrate, resubmit from the new client, and resolve using the original ID. This also covers end-to-end preservation and stamping of the batch identity through pending-state rehydration, beyond the existing unit tests for explicit original `batchId` metadata.
747
+ 1. **Real batch cut:** Submit an op through `ContainerRuntime`, call capture before the TurnBased flush, and verify that
748
+ capture flushes the op into `PendingStateManager` and returns that exact batch's ID rather than a mocked ID.
749
+ 1. **Unsafe contexts:** Call capture during inbound processing and inside `orderSequentially`; verify `notCaptured` with
750
+ `reason: "unsafeToFlush"`, no flush, and no container closure. Also verify a later retry succeeds.
751
+ 1. **Staging commit and discard:** Capture staged edits and verify nothing is sent immediately. Verify that commit
752
+ preserves the captured ID through resubmit and resolution, and define and test the result after discard.
753
+ 1. **Offline rehydration:** Capture while disconnected, stash and rehydrate, resubmit from the new client, and resolve
754
+ using the original ID. This also covers end-to-end preservation and stamping of the batch identity through
755
+ pending-state rehydration, beyond the existing unit tests for explicit original `batchId` metadata.
324
756
 
325
757
  ## Historical-op retention limitation
326
758
 
327
- Cross-client or headless resolution of an old pending mark falls back to the historical-op scan. This covers the case where the capturing client dies before its own ack and no other live client promoted the mark: a fresh client can resolve the stored `batchId` from retained ops even though that op will not reappear on the live inbound stream.
759
+ Cross-client or headless resolution of an old pending mark falls back to the historical-op scan. This covers the case
760
+ where the capturing client dies before its own ack and no other live client promoted the mark: a fresh client can
761
+ resolve the stored `batchId` from retained ops even though that op will not reappear on the live inbound stream.
328
762
 
329
- Resolution is still bounded by op retention. Once the target range has been trimmed, there is no runtime-owned durable `batchId -> sequenceNumber` index to recover it, so the resolver returns `unresolvable`. As described above, the current trim detection is read-derived and driver-dependent; a future explicit op-availability API would make that classification contractual.
763
+ Resolution is still bounded by op retention. Once the target range has been trimmed, there is no runtime-owned durable
764
+ `batchId -> { sequenceNumber, timestamp }` index to recover it, so the resolver returns `unresolvable`. As described
765
+ above, the current trim detection is read-derived and driver-dependent; a future explicit op-availability API would make
766
+ that classification contractual.