@recordtimelabel/core 0.6.2 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,33 +1,47 @@
1
1
  import {
2
2
  RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
3
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
3
4
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
5
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
4
6
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
5
7
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
8
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
9
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
6
10
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
7
11
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
8
12
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
9
13
  buildRecordTimeLabelRequestId,
10
14
  createRecordTimeLabelTransportFailureResults,
15
+ isRecordTimeLabelOperationConflictFailure,
11
16
  normalizeRecordTimeLabelCloudFailure,
12
17
  normalizeRecordTimeLabelEnvelopeResponse,
13
18
  normalizeRecordTimeLabelOperationResults,
19
+ normalizeRecordTimeLabelImmutableId,
20
+ normalizeRecordTimeLabelPlannerId,
14
21
  toRecordTimeLabelCloudFailureError,
15
22
  toRecordTimeLabelWireOperation
16
23
  } from './protocol.js';
17
24
 
18
25
  export {
19
26
  RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
27
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
20
28
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
29
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
21
30
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
22
31
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
32
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
33
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
23
34
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
24
35
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
25
36
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
26
37
  buildRecordTimeLabelRequestId,
27
38
  createRecordTimeLabelTransportFailureResults,
39
+ isRecordTimeLabelOperationConflictFailure,
28
40
  normalizeRecordTimeLabelCloudFailure,
29
41
  normalizeRecordTimeLabelEnvelopeResponse,
30
42
  normalizeRecordTimeLabelOperationResults,
43
+ normalizeRecordTimeLabelImmutableId,
44
+ normalizeRecordTimeLabelPlannerId,
31
45
  toRecordTimeLabelCloudFailureError,
32
46
  toRecordTimeLabelWireOperation
33
47
  } from './protocol.js';
@@ -127,7 +141,7 @@ export {
127
141
  selectBestMatchingTwitchVod
128
142
  };
129
143
 
130
- export const RECORD_TIMELABEL_CORE_VERSION = '0.6.2';
144
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.5';
131
145
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
132
146
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
133
147
  'fifo-retry-fence',
@@ -137,7 +151,10 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
137
151
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
138
152
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
139
153
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
140
- RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE
154
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
155
+ RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
156
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
157
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS
141
158
  ]);
142
159
  export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
143
160
  export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
@@ -145,6 +162,11 @@ export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
145
162
  export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
146
163
  export const RTL_MAX_TARGET_WRITES = 100;
147
164
  export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
165
+ export const RECORD_TIMELABEL_PLANNER_MODES = Object.freeze({
166
+ DETERMINISTIC: 'deterministic',
167
+ LEGACY_CONTEXT: 'legacy-context'
168
+ });
169
+ export const RECORD_TIMELABEL_SAFE_ID_MAX_BYTES = 512;
148
170
 
149
171
  export const OPERATION_TYPES = Object.freeze({
150
172
  RECORD_CREATE: 'record.create',
@@ -195,6 +217,7 @@ export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
195
217
  OWNER_MISMATCH: 'operation_owner_mismatch',
196
218
  WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
197
219
  ID_CONFLICT: 'operation_id_conflict',
220
+ INVALID_DOCUMENT_ID: 'invalid_document_id',
198
221
  LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
199
222
  LIFECYCLE_CONFLICT: 'lifecycle_conflict'
200
223
  });
@@ -211,7 +234,20 @@ const clone = (value, seen = new WeakMap()) => {
211
234
  const copy = Array.isArray(value) ? [] : {};
212
235
  seen.set(value, copy);
213
236
  Reflect.ownKeys(value).forEach((key) => {
214
- copy[key] = clone(value[key], seen);
237
+ // Assignment to the magic `__proto__` key invokes the legacy setter on a
238
+ // normal object. Define the data property explicitly so cloning untrusted
239
+ // document maps cannot alter the clone's prototype (or any global
240
+ // prototype through a later merge).
241
+ if (Array.isArray(copy) && key === 'length') {
242
+ copy.length = value.length;
243
+ return;
244
+ }
245
+ Object.defineProperty(copy, key, {
246
+ value: clone(value[key], seen),
247
+ enumerable: true,
248
+ configurable: true,
249
+ writable: true
250
+ });
215
251
  });
216
252
  return copy;
217
253
  };
@@ -284,9 +320,21 @@ const normalizeId = (value) => {
284
320
  return String(value).trim();
285
321
  };
286
322
 
323
+ const RTL_RESERVED_DOCUMENT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
324
+ const RTL_SAFE_DOCUMENT_ID_MAX_BYTES = 512;
325
+ const rtlIsSafeDocumentId = (value) => {
326
+ // Document identity is a wire-level string. Never coerce numbers (or trim
327
+ // any other value) before applying the shared immutable-ID contract.
328
+ if (typeof value !== 'string') return false;
329
+ const normalized = normalizeRecordTimeLabelImmutableId(value);
330
+ return normalized !== null && !RTL_RESERVED_DOCUMENT_KEYS.has(normalized);
331
+ };
332
+
287
333
  const safeFolderId = (folderId) => {
288
334
  const normalized = normalizeId(folderId);
289
- if (!normalized || VIRTUAL_FOLDER_IDS.has(normalized)) return DEFAULT_FOLDER_ID;
335
+ if (!normalized || !rtlIsSafeDocumentId(normalized) || VIRTUAL_FOLDER_IDS.has(normalized)) {
336
+ return DEFAULT_FOLDER_ID;
337
+ }
290
338
  return normalized;
291
339
  };
292
340
 
@@ -316,7 +364,7 @@ const normalizeTombstones = (value) => {
316
364
 
317
365
  return entries.reduce((result, [rawId, rawTombstone]) => {
318
366
  const id = normalizeId(rawId);
319
- if (!id) return result;
367
+ if (!id || !rtlIsSafeDocumentId(id)) return result;
320
368
  const tombstone = rawTombstone && typeof rawTombstone === 'object'
321
369
  ? { ...rawTombstone }
322
370
  : {};
@@ -341,7 +389,8 @@ const normalizeTrashEntries = (value) => {
341
389
  const id = normalizeId(rawId || rawEntry.id);
342
390
  const kind = normalizeId(rawEntry.kind);
343
391
  const entityId = normalizeId(rawEntry.entityId);
344
- if (!id || !entityId || !new Set(['record', 'folder']).has(kind)) return result;
392
+ if (!id || !entityId || !rtlIsSafeDocumentId(id) || !rtlIsSafeDocumentId(entityId) ||
393
+ !new Set(['record', 'folder']).has(kind)) return result;
345
394
  const deletedAt = toFiniteTimestamp(rawEntry.deletedAt);
346
395
  const purgeAt = toFiniteTimestamp(rawEntry.purgeAt) ||
347
396
  ((deletedAt || Date.now()) + RTL_TRASH_RETENTION_MS);
@@ -366,7 +415,7 @@ export const getActiveTrashEntries = (value, now = Date.now()) => Object.values(
366
415
  const mergeTombstones = (left = {}, right = {}) => {
367
416
  const merged = { ...left };
368
417
  Object.entries(right).forEach(([id, tombstone]) => {
369
- const current = merged[id];
418
+ const current = rtlOwn(merged, id) ? merged[id] : null;
370
419
  const currentGeneration = Number(current?.lifecycleGeneration || 0);
371
420
  const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
372
421
  if (!current || nextGeneration > currentGeneration ||
@@ -381,7 +430,7 @@ const mergeTombstones = (left = {}, right = {}) => {
381
430
  const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
382
431
  const merged = normalizeTrashEntries(remoteEntries);
383
432
  Object.entries(normalizeTrashEntries(localEntries)).forEach(([id, entry]) => {
384
- const current = merged[id];
433
+ const current = rtlOwn(merged, id) ? merged[id] : null;
385
434
  const currentVersion = Number(current?.lifecycleGeneration || 0);
386
435
  const nextVersion = Number(entry.lifecycleGeneration || 0);
387
436
  if (!current || nextVersion > currentVersion ||
@@ -517,10 +566,37 @@ export const mergeRecords = (left, right) => {
517
566
  }
518
567
  });
519
568
 
520
- if (left.hasVod || right.hasVod) merged.hasVod = true;
521
569
  return merged;
522
570
  };
523
571
 
572
+ /**
573
+ * Apply a user-authored record patch without using enrichment/LWW merge
574
+ * semantics. The latter intentionally keeps rich fields from either side,
575
+ * which is correct for remote snapshot reconciliation but would make an
576
+ * explicit `{hasVod: false}` or `{title: ''}` impossible to persist. An
577
+ * explicit patch is presence-based: omitted keys are retained, while every
578
+ * own key (including `undefined`, `null`, `false`, and the empty string) is
579
+ * authoritative for this operation.
580
+ */
581
+ export const applyExplicitRecordUpdate = (record = {}, patch = {}, operationTime = Date.now()) => {
582
+ const current = record && typeof record === 'object' ? record : {};
583
+ const source = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
584
+ const next = {...current};
585
+ Object.keys(source).forEach((key) => {
586
+ if (RTL_RESERVED_DOCUMENT_KEYS.has(key)) return;
587
+ Object.defineProperty(next, key, {
588
+ value: source[key],
589
+ enumerable: true,
590
+ configurable: true,
591
+ writable: true
592
+ });
593
+ });
594
+ if (!Object.prototype.hasOwnProperty.call(source, 'updatedAt')) {
595
+ next.updatedAt = operationTime;
596
+ }
597
+ return next;
598
+ };
599
+
524
600
  export const normalizeRecords = (records = {}, options = {}) => {
525
601
  const source = records && typeof records === 'object' ? records : {};
526
602
  const tombstones = normalizeTombstones(options.deletedRecordTombstones);
@@ -533,7 +609,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
533
609
  safeList.forEach((record, index) => {
534
610
  if (!record || typeof record !== 'object') return;
535
611
  const id = normalizeId(record.id) || normalizeId(record.recordId);
536
- if (!id) return;
612
+ if (!id || !rtlIsSafeDocumentId(id)) return;
537
613
 
538
614
  const targetFolderId = resolveRecordFolderId(folderId, record);
539
615
 
@@ -549,7 +625,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
549
625
  const folderTombstone = folderTombstones[targetFolderId];
550
626
  if (folderTombstone) return;
551
627
 
552
- if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
628
+ if (!rtlOwn(normalized, targetFolderId)) normalized[targetFolderId] = [];
553
629
 
554
630
  const existing = placements.get(id);
555
631
  if (!existing) {
@@ -598,7 +674,7 @@ const ensureFolders = (folders = []) => {
598
674
  toArray(folders).forEach((folder) => {
599
675
  if (!folder || typeof folder !== 'object') return;
600
676
  const id = normalizeId(folder.id);
601
- if (!id || seen.has(id)) return;
677
+ if (!id || !rtlIsSafeDocumentId(id) || seen.has(id)) return;
602
678
  seen.add(id);
603
679
  nextFolders.push({ ...folder, id });
604
680
  });
@@ -625,10 +701,10 @@ const ensureFolders = (folders = []) => {
625
701
  const ensureRecordFolders = (records, folders) => {
626
702
  const nextRecords = { ...records };
627
703
  folders.forEach((folder) => {
628
- if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
704
+ if (!rtlOwn(nextRecords, folder.id)) nextRecords[folder.id] = [];
629
705
  });
630
- if (!nextRecords.all) nextRecords.all = [];
631
- if (!nextRecords[DEFAULT_FOLDER_ID]) nextRecords[DEFAULT_FOLDER_ID] = [];
706
+ if (!rtlOwn(nextRecords, 'all')) nextRecords.all = [];
707
+ if (!rtlOwn(nextRecords, DEFAULT_FOLDER_ID)) nextRecords[DEFAULT_FOLDER_ID] = [];
632
708
  nextRecords.all = [];
633
709
  return nextRecords;
634
710
  };
@@ -924,8 +1000,12 @@ export const applyOperation = (state = {}, operation = {}) => {
924
1000
  const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
925
1001
  if (!record) return normalized;
926
1002
  const recordId = normalizeId(record.id || payload.recordId);
927
- if (!recordId) return normalized;
928
- const tombstone = nextState.deletedRecordTombstones[recordId];
1003
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
1004
+ const rawFolderId = normalizeId(payload.folderId || record.folderId);
1005
+ if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
1006
+ const tombstone = rtlOwn(nextState.deletedRecordTombstones, recordId)
1007
+ ? nextState.deletedRecordTombstones[recordId]
1008
+ : null;
929
1009
  if (tombstone) return normalized;
930
1010
 
931
1011
  const folderId = safeFolderId(payload.folderId || record.folderId);
@@ -946,24 +1026,25 @@ export const applyOperation = (state = {}, operation = {}) => {
946
1026
 
947
1027
  case OPERATION_TYPES.RECORD_UPDATE: {
948
1028
  const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
949
- if (!recordId) return normalized;
1029
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
950
1030
  const entry = findRecordEntry(nextState.records, recordId);
951
1031
  if (!entry) return normalized;
952
1032
  const patch = payload.patch || payload.record || {};
953
- const updatedRecord = mergeRecords(entry.record, {
954
- ...entry.record,
955
- ...patch,
956
- id: recordId,
957
- updatedAt: patch.updatedAt || operationTime
958
- });
1033
+ if (Object.keys(patch).some((key) => RTL_RESERVED_DOCUMENT_KEYS.has(key))) {
1034
+ return normalized;
1035
+ }
1036
+ const updatedRecord = applyExplicitRecordUpdate(entry.record, patch, operationTime);
1037
+ updatedRecord.id = recordId;
959
1038
  nextState.records[entry.folderId][entry.index] = updatedRecord;
960
1039
  break;
961
1040
  }
962
1041
 
963
1042
  case OPERATION_TYPES.RECORD_MOVE: {
964
1043
  const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
965
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
966
- if (!recordId) return normalized;
1044
+ const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
1045
+ if (!recordId || !rtlIsSafeDocumentId(recordId) ||
1046
+ !rtlIsSafeDocumentId(rawTargetFolderId)) return normalized;
1047
+ const targetFolderId = safeFolderId(rawTargetFolderId);
967
1048
  const entry = findRecordEntry(nextState.records, recordId);
968
1049
  if (!entry) return normalized;
969
1050
  const movedRecord = {
@@ -981,7 +1062,9 @@ export const applyOperation = (state = {}, operation = {}) => {
981
1062
  }
982
1063
 
983
1064
  case OPERATION_TYPES.RECORD_REORDER: {
984
- const folderId = safeFolderId(payload.folderId);
1065
+ const rawFolderId = normalizeId(payload.folderId);
1066
+ if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
1067
+ const folderId = safeFolderId(rawFolderId);
985
1068
  const existingFolderRecords = toArray(nextState.records?.[folderId]);
986
1069
  const payloadRecords = toArray(payload.records);
987
1070
  const recordsById = new Map();
@@ -1007,6 +1090,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1007
1090
  ? payload.recordIds
1008
1091
  : payloadRecords.map((record) => getRecordId(record))
1009
1092
  );
1093
+ if (orderedIds.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
1010
1094
  if (orderedIds.length === 0) return normalized;
1011
1095
 
1012
1096
  const seen = new Set();
@@ -1030,13 +1114,21 @@ export const applyOperation = (state = {}, operation = {}) => {
1030
1114
 
1031
1115
  case OPERATION_TYPES.RECORD_DELETE: {
1032
1116
  const recordId = normalizeId(payload.recordId || payload.id);
1033
- if (!recordId) return normalized;
1117
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
1034
1118
  const existingEntry = findRecordEntry(nextState.records, recordId);
1035
1119
  const recordSnapshot = payload.record && typeof payload.record === 'object'
1036
1120
  ? {...payload.record, id: recordId}
1037
1121
  : existingEntry?.record;
1038
1122
  const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
1039
- if (!recordSnapshot && nextState.trashEntries[trashEntryId]) return normalized;
1123
+ if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
1124
+ const existingTombstone = nextState.deletedRecordTombstones[recordId];
1125
+ if (!recordSnapshot && (nextState.trashEntries[trashEntryId] || existingTombstone)) {
1126
+ return normalized;
1127
+ }
1128
+ if (recordSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
1129
+ Number(recordSnapshot.lifecycleGeneration || 0)) {
1130
+ return normalized;
1131
+ }
1040
1132
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
1041
1133
  const previousGeneration = Math.max(
1042
1134
  Number(recordSnapshot?.lifecycleGeneration || 0),
@@ -1071,16 +1163,29 @@ export const applyOperation = (state = {}, operation = {}) => {
1071
1163
  }
1072
1164
 
1073
1165
  case OPERATION_TYPES.RECORD_RESTORE: {
1166
+ const restoreRecordId = normalizeId(payload.recordId || payload.id);
1074
1167
  const trashEntryId = normalizeId(payload.trashEntryId) ||
1075
- `record:${normalizeId(payload.recordId || payload.id)}`;
1076
- const trashEntry = nextState.trashEntries[trashEntryId];
1168
+ `record:${restoreRecordId}`;
1169
+ if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
1170
+ const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
1171
+ ? nextState.trashEntries[trashEntryId]
1172
+ : null;
1077
1173
  if (!trashEntry || trashEntry.kind !== 'record') return normalized;
1078
1174
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
1079
1175
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
1080
1176
  if (expectedGeneration === null ||
1081
1177
  expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
1082
1178
  const recordId = normalizeId(trashEntry.entityId);
1083
- if (!recordId || findRecordEntry(nextState.records, recordId)) return normalized;
1179
+ if (!recordId || !rtlIsSafeDocumentId(recordId) || findRecordEntry(nextState.records, recordId)) {
1180
+ return normalized;
1181
+ }
1182
+ const lifecycleTombstone = rtlOwn(nextState.deletedRecordTombstones, recordId)
1183
+ ? nextState.deletedRecordTombstones[recordId]
1184
+ : null;
1185
+ if (!lifecycleTombstone ||
1186
+ Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
1187
+ return normalized;
1188
+ }
1084
1189
  const recordSnapshot = trashEntry.payload?.record;
1085
1190
  if (!recordSnapshot) return normalized;
1086
1191
  const originalFolderId = safeFolderId(trashEntry.originalFolderId);
@@ -1109,8 +1214,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1109
1214
  case OPERATION_TYPES.FOLDER_CREATE: {
1110
1215
  const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
1111
1216
  const folderId = normalizeId(folder?.id || payload.folderId);
1112
- if (!folderId) return normalized;
1113
- if (nextState.deletedFolderTombstones[folderId]) return normalized;
1217
+ if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
1218
+ if (rtlOwn(nextState.deletedFolderTombstones, folderId) &&
1219
+ nextState.deletedFolderTombstones[folderId]) return normalized;
1114
1220
  const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
1115
1221
  const nextFolder = {
1116
1222
  ...(folder || {}),
@@ -1128,7 +1234,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1128
1234
 
1129
1235
  case OPERATION_TYPES.FOLDER_UPDATE: {
1130
1236
  const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
1131
- if (!folderId) return normalized;
1237
+ if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
1132
1238
  const patch = payload.patch || payload.folder || {};
1133
1239
  nextState.folders = nextState.folders.map((folder) => (
1134
1240
  folder.id === folderId
@@ -1140,12 +1246,24 @@ export const applyOperation = (state = {}, operation = {}) => {
1140
1246
 
1141
1247
  case OPERATION_TYPES.FOLDER_DELETE: {
1142
1248
  const folderId = normalizeId(payload.folderId || payload.id);
1143
- if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
1249
+ if (!folderId || !rtlIsSafeDocumentId(folderId) ||
1250
+ folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
1144
1251
  const folderSnapshot = payload.folder && typeof payload.folder === 'object'
1145
1252
  ? {...payload.folder, id: folderId}
1146
1253
  : nextState.folders.find((folder) => folder.id === folderId);
1147
1254
  const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
1148
- if (!folderSnapshot && nextState.trashEntries[`folder:${folderId}`]) return normalized;
1255
+ const existingTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
1256
+ ? nextState.deletedFolderTombstones[folderId]
1257
+ : null;
1258
+ if (!folderSnapshot &&
1259
+ ((rtlOwn(nextState.trashEntries, `folder:${folderId}`) &&
1260
+ nextState.trashEntries[`folder:${folderId}`]) || existingTombstone)) {
1261
+ return normalized;
1262
+ }
1263
+ if (folderSnapshot && existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
1264
+ Number(folderSnapshot.lifecycleGeneration || 0)) {
1265
+ return normalized;
1266
+ }
1149
1267
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
1150
1268
  const previousGeneration = Math.max(
1151
1269
  Number(folderSnapshot?.lifecycleGeneration || 0),
@@ -1154,6 +1272,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1154
1272
  const lifecycleGeneration = previousGeneration + 1;
1155
1273
  if (folderSnapshot) {
1156
1274
  const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
1275
+ if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
1157
1276
  nextState.trashEntries[trashEntryId] = {
1158
1277
  id: trashEntryId,
1159
1278
  kind: 'folder',
@@ -1187,7 +1306,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1187
1306
  };
1188
1307
  folderRecords.forEach((record) => {
1189
1308
  const recordId = getRecordId(record);
1190
- if (!recordId) return;
1309
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return;
1191
1310
  nextState.deletedRecordTombstones[recordId] = {
1192
1311
  id: recordId,
1193
1312
  folderId,
@@ -1201,16 +1320,28 @@ export const applyOperation = (state = {}, operation = {}) => {
1201
1320
  }
1202
1321
 
1203
1322
  case OPERATION_TYPES.FOLDER_RESTORE: {
1323
+ const restoreFolderId = normalizeId(payload.folderId || payload.id);
1204
1324
  const trashEntryId = normalizeId(payload.trashEntryId) ||
1205
- `folder:${normalizeId(payload.folderId || payload.id)}`;
1206
- const trashEntry = nextState.trashEntries[trashEntryId];
1325
+ `folder:${restoreFolderId}`;
1326
+ if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
1327
+ const trashEntry = rtlOwn(nextState.trashEntries, trashEntryId)
1328
+ ? nextState.trashEntries[trashEntryId]
1329
+ : null;
1207
1330
  if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
1208
1331
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
1209
1332
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
1210
1333
  if (expectedGeneration === null ||
1211
1334
  expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
1212
1335
  const folderId = normalizeId(trashEntry.entityId);
1213
- if (!folderId || nextState.folders.some((folder) => folder.id === folderId)) return normalized;
1336
+ if (!folderId || !rtlIsSafeDocumentId(folderId) ||
1337
+ nextState.folders.some((folder) => folder.id === folderId)) return normalized;
1338
+ const lifecycleTombstone = rtlOwn(nextState.deletedFolderTombstones, folderId)
1339
+ ? nextState.deletedFolderTombstones[folderId]
1340
+ : null;
1341
+ if (!lifecycleTombstone ||
1342
+ Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
1343
+ return normalized;
1344
+ }
1214
1345
  const folderSnapshot = trashEntry.payload?.folder;
1215
1346
  if (!folderSnapshot) return normalized;
1216
1347
  const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
@@ -1299,6 +1430,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1299
1430
 
1300
1431
  case OPERATION_TYPES.FOLDER_REORDER:
1301
1432
  nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
1433
+ if (nextState.folderOrder.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
1302
1434
  break;
1303
1435
 
1304
1436
  case OPERATION_TYPES.GROUP_REORDER:
@@ -1406,6 +1538,11 @@ export const mergeLocalRemote = ({
1406
1538
  });
1407
1539
  });
1408
1540
 
1541
+ // Pending operations are explicit local commands. Replay them against the
1542
+ // merged remote baseline in FIFO order; do not apply the remote document's
1543
+ // LWW timestamp as a second gate, or an offline update with an older
1544
+ // createdAt would be silently lost. `applyExplicitRecordUpdate` preserves
1545
+ // omitted properties while honoring present empty/false/null values.
1409
1546
  toArray(pendingOps).forEach((operation) => {
1410
1547
  mergedState = applyOperation(mergedState, operation);
1411
1548
  });
@@ -1740,7 +1877,9 @@ const reorderV2RecordsByFolderRecordOrder = (records = {}, recordOrderByFolder =
1740
1877
 
1741
1878
  const cleanV2RecordDocument = (record = {}, folderId, now) => {
1742
1879
  const recordId = normalizeId(record.id);
1743
- if (!recordId) return null;
1880
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return null;
1881
+ const normalizedFolderId = normalizeId(folderId);
1882
+ if (normalizedFolderId && !rtlIsSafeDocumentId(normalizedFolderId)) return null;
1744
1883
  const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
1745
1884
  delete cleanRecord.pendingSync;
1746
1885
  delete cleanRecord.syncAttempts;
@@ -1753,7 +1892,7 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
1753
1892
 
1754
1893
  const cleanV2FolderDocument = (folder = {}, now) => {
1755
1894
  const folderId = normalizeId(folder.id);
1756
- if (!folderId) return null;
1895
+ if (!folderId || !rtlIsSafeDocumentId(folderId)) return null;
1757
1896
  const {
1758
1897
  schemaVersion,
1759
1898
  recordOrder,
@@ -1771,7 +1910,7 @@ const cleanV2FolderDocument = (folder = {}, now) => {
1771
1910
 
1772
1911
  const cleanV2OperationDocument = (operation = {}, now) => {
1773
1912
  const operationId = normalizeId(operation.id);
1774
- if (!operationId || !operation.type) return null;
1913
+ if (!operationId || !rtlIsSafeDocumentId(operationId) || !operation.type) return null;
1775
1914
  return {
1776
1915
  ...operation,
1777
1916
  id: operationId,
@@ -1783,7 +1922,8 @@ const cleanV2OperationDocument = (operation = {}, now) => {
1783
1922
  const cleanV2TrashDocument = (entry = {}, now) => {
1784
1923
  const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
1785
1924
  const trashEntry = Object.values(normalized)[0];
1786
- if (!trashEntry) return null;
1925
+ if (!trashEntry || !rtlIsSafeDocumentId(trashEntry.id) ||
1926
+ !rtlIsSafeDocumentId(trashEntry.entityId)) return null;
1787
1927
  return {
1788
1928
  ...trashEntry,
1789
1929
  deletedAt: trashEntry.deletedAt || now,
@@ -1799,6 +1939,7 @@ const buildV2LifecycleTombstoneDocuments = (
1799
1939
  const documents = {};
1800
1940
  const append = (kind, tombstones) => {
1801
1941
  Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
1942
+ if (!rtlIsSafeDocumentId(entityId)) return;
1802
1943
  const id = `${kind}:${entityId}`;
1803
1944
  documents[encodeURIComponent(id)] = {
1804
1945
  id,
@@ -3010,6 +3151,168 @@ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
3010
3151
 
3011
3152
  const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
3012
3153
 
3154
+ const rtlRawNonNegativeSafeInteger = (value) => (
3155
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
3156
+ );
3157
+
3158
+ const rtlRawSafeId = (value) => typeof value === 'string' && rtlIsSafeDocumentId(value);
3159
+
3160
+ const rtlRawFiniteTimestamp = (value) => (
3161
+ typeof value === 'number' && Number.isFinite(value)
3162
+ );
3163
+
3164
+ const rtlRawValidOpaqueGroupOrder = (value) => (
3165
+ typeof value === 'string' && value.length > 0 &&
3166
+ new TextEncoder().encode(value).byteLength <= RTL_SAFE_DOCUMENT_ID_MAX_BYTES &&
3167
+ !/[\u0000-\u001F\u007F-\u009F]/u.test(value)
3168
+ );
3169
+
3170
+ const rtlRawValidOrder = (value, {opaqueGroup = false} = {}) => (
3171
+ Array.isArray(value) && value.every((id) => (
3172
+ opaqueGroup ? rtlRawValidOpaqueGroupOrder(id) : rtlRawSafeId(id)
3173
+ ))
3174
+ );
3175
+
3176
+ const rtlRawValidTombstoneTable = (value) => (
3177
+ rtlDurableIsObject(value) && Object.entries(value).every(([id, tombstone]) => (
3178
+ rtlRawSafeId(id) && rtlDurableIsObject(tombstone) &&
3179
+ (!Object.prototype.hasOwnProperty.call(tombstone, 'id') || rtlRawSafeId(tombstone.id)) &&
3180
+ Number.isSafeInteger(tombstone.lifecycleGeneration) && tombstone.lifecycleGeneration > 0 &&
3181
+ rtlRawFiniteTimestamp(tombstone.deletedAt)
3182
+ ))
3183
+ );
3184
+
3185
+ const rtlRawValidTrashTable = (value) => (
3186
+ rtlDurableIsObject(value) && Object.entries(value).every(([id, entry]) => (
3187
+ rtlRawSafeId(id) && rtlDurableIsObject(entry) &&
3188
+ rtlRawSafeId(entry.id) && entry.id === id &&
3189
+ (entry.kind === 'record' || entry.kind === 'folder') &&
3190
+ rtlRawSafeId(entry.entityId) &&
3191
+ Number.isSafeInteger(entry.lifecycleGeneration) && entry.lifecycleGeneration > 0 &&
3192
+ rtlRawFiniteTimestamp(entry.deletedAt) && rtlRawFiniteTimestamp(entry.purgeAt) &&
3193
+ (!Object.prototype.hasOwnProperty.call(entry, 'payload') || rtlDurableIsObject(entry.payload))
3194
+ ))
3195
+ );
3196
+
3197
+ const rtlHasCompleteBaselineState = (state) => {
3198
+ if (!rtlDurableIsObject(state) || ![
3199
+ 'folders', 'records', 'groupOrder', 'folderOrder', 'settings', 'rtlSyncMeta',
3200
+ 'deletedRecordTombstones', 'deletedFolderTombstones', 'trashEntries'
3201
+ ].every((key) => Object.prototype.hasOwnProperty.call(state, key))) return false;
3202
+ if (!Array.isArray(state.folders) || !rtlDurableIsObject(state.records) ||
3203
+ !rtlRawValidOrder(state.groupOrder, {opaqueGroup: true}) || !rtlRawValidOrder(state.folderOrder) ||
3204
+ !rtlDurableIsObject(state.settings) || !rtlDurableIsObject(state.rtlSyncMeta) ||
3205
+ !rtlRawValidTombstoneTable(state.deletedRecordTombstones) ||
3206
+ !rtlRawValidTombstoneTable(state.deletedFolderTombstones) ||
3207
+ !rtlRawValidTrashTable(state.trashEntries)) return false;
3208
+
3209
+ const folderIds = new Set();
3210
+ for (const folder of state.folders) {
3211
+ if (!rtlDurableIsObject(folder) || !rtlRawSafeId(folder.id) || folderIds.has(folder.id) ||
3212
+ (Object.prototype.hasOwnProperty.call(folder, 'recordOrder') && !rtlRawValidOrder(folder.recordOrder))) {
3213
+ return false;
3214
+ }
3215
+ folderIds.add(folder.id);
3216
+ }
3217
+ const recordIds = new Set();
3218
+ for (const [folderId, records] of Object.entries(state.records)) {
3219
+ if (!rtlRawSafeId(folderId) || !Array.isArray(records)) return false;
3220
+ for (const record of records) {
3221
+ if (!rtlDurableIsObject(record) || !rtlRawSafeId(record.id) || recordIds.has(record.id)) {
3222
+ return false;
3223
+ }
3224
+ recordIds.add(record.id);
3225
+ }
3226
+ }
3227
+ return true;
3228
+ };
3229
+
3230
+ // Resume cursors and markers are opaque to Core, but any identity fields an
3231
+ // adapter persists alongside them are still part of the workspace fence. A
3232
+ // cursor from another UID/session/epoch must force a fresh bootstrap rather
3233
+ // than being silently adopted after normalization.
3234
+ const rtlResumeIdentityMatches = (value, captured, ownerUid, workspaceEpoch) => {
3235
+ if (value === null || value === undefined || !rtlDurableIsObject(value)) return false;
3236
+ const hasOwnerIdentity = ['ownerUid', 'uid', 'workspaceOwnerUid']
3237
+ .some((key) => Object.prototype.hasOwnProperty.call(value, key));
3238
+ const hasEpochIdentity = ['workspaceEpoch', 'epoch']
3239
+ .some((key) => Object.prototype.hasOwnProperty.call(value, key));
3240
+ const hasSessionIdentity = ['authSessionBinding']
3241
+ .some((key) => Object.prototype.hasOwnProperty.call(value, key));
3242
+ if (!hasOwnerIdentity || !hasEpochIdentity || !hasSessionIdentity) return false;
3243
+ for (const key of ['ownerUid', 'uid', 'workspaceOwnerUid']) {
3244
+ if (Object.prototype.hasOwnProperty.call(value, key) &&
3245
+ rtlNormalizeUid(value[key]) !== rtlNormalizeUid(ownerUid)) {
3246
+ return false;
3247
+ }
3248
+ }
3249
+ for (const key of ['workspaceEpoch', 'epoch']) {
3250
+ if (Object.prototype.hasOwnProperty.call(value, key) &&
3251
+ (!rtlRawNonNegativeSafeInteger(value[key]) ||
3252
+ value[key] !== workspaceEpoch)) {
3253
+ return false;
3254
+ }
3255
+ }
3256
+ // Legacy sessionToken/sessionTokenId markers are credentials or opaque
3257
+ // fences that cannot be safely persisted. They deliberately fail closed;
3258
+ // callers must bootstrap and receive a newly bound marker.
3259
+ if (Object.prototype.hasOwnProperty.call(value, 'sessionToken') ||
3260
+ Object.prototype.hasOwnProperty.call(value, 'sessionTokenId')) return false;
3261
+ for (const key of ['authSessionBinding']) {
3262
+ if (Object.prototype.hasOwnProperty.call(value, key) &&
3263
+ (captured?.authSessionBinding === undefined || captured?.authSessionBinding === null ||
3264
+ !Object.is(value[key], captured.authSessionBinding))) {
3265
+ return false;
3266
+ }
3267
+ }
3268
+ return true;
3269
+ };
3270
+
3271
+ const rtlResumeMarkerMatches = (value, captured, ownerUid, workspaceEpoch) =>
3272
+ rtlResumeIdentityMatches(value, captured, ownerUid, workspaceEpoch);
3273
+
3274
+ const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
3275
+ if (!rtlDurableIsObject(loaded) || loaded.schemaVersion !== RTL_DURABLE_SCHEMA_VERSION) {
3276
+ return false;
3277
+ }
3278
+ const ownerUid = rtlNormalizeUid(loaded.ownerUid);
3279
+ const expectedOwnerUid = rtlNormalizeUid(captured?.uid);
3280
+ if (!ownerUid || !expectedOwnerUid || ownerUid !== expectedOwnerUid) return false;
3281
+ if (!rtlRawNonNegativeSafeInteger(loaded.workspaceEpoch) ||
3282
+ (captured?.hasEpoch && loaded.workspaceEpoch !== captured.workspaceEpoch)) {
3283
+ return false;
3284
+ }
3285
+ const baseline = loaded.remoteBaseline;
3286
+ if (!rtlDurableIsObject(baseline) ||
3287
+ !Object.prototype.hasOwnProperty.call(baseline, 'state') ||
3288
+ !Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
3289
+ !Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
3290
+ !rtlHasCompleteBaselineState(baseline.state) ||
3291
+ !rtlRawNonNegativeSafeInteger(baseline.revision)) {
3292
+ return false;
3293
+ }
3294
+ if (loaded.syncMeta !== undefined &&
3295
+ !rtlDurableIsObject(loaded.syncMeta)) return false;
3296
+ if (typeof baseline.changeCursor !== 'string' ||
3297
+ !rtlResumeIdentityMatches(
3298
+ loaded.syncMeta?.changeCursorBinding,
3299
+ captured,
3300
+ ownerUid,
3301
+ loaded.workspaceEpoch
3302
+ )) {
3303
+ return false;
3304
+ }
3305
+ const resumeMarker = loaded.syncMeta?.resumeMarker ??
3306
+ loaded.syncMeta?.resume ??
3307
+ loaded.syncMeta?.bootstrapMarker;
3308
+ return resumeMarker === undefined || rtlResumeMarkerMatches(
3309
+ resumeMarker,
3310
+ captured,
3311
+ ownerUid,
3312
+ loaded.workspaceEpoch
3313
+ );
3314
+ };
3315
+
3013
3316
  const rtlToFiniteNumber = (value, fallback = 0) => {
3014
3317
  const number = Number(value);
3015
3318
  return Number.isFinite(number) ? number : fallback;
@@ -3035,17 +3338,74 @@ const rtlNormalizeWorkspaceEpoch = (value, fallback = 0) => {
3035
3338
 
3036
3339
  const rtlCloneOperation = (operation) => clone(operation && typeof operation === 'object' ? operation : {});
3037
3340
 
3341
+ // Durable storage may contain adapter metadata at arbitrary nesting levels. Strip
3342
+ // credential-shaped keys case/separator-insensitively, while retaining the explicit
3343
+ // non-credential authSessionBinding used to bind a committed workspace to a session.
3344
+ const rtlIsCredentialKey = (key) => {
3345
+ const normalized = String(key).replace(/[\s_-]/g, '').toLowerCase();
3346
+ if (normalized === 'authsessionbinding') return false;
3347
+ return new Set([
3348
+ 'sessiontoken', 'sessiontokenid', 'sessionid', 'authtoken', 'accesstoken',
3349
+ 'refreshtoken', 'idtoken', 'authorization', 'credential', 'credentials',
3350
+ 'firebasecredential', 'jwttoken', 'bearertoken', 'accesskey', 'refreshkey',
3351
+ 'authsessionid', 'authsessionkey'
3352
+ ]).has(normalized) || /(?:token|credential|authorization|secret)$/.test(normalized);
3353
+ };
3354
+
3038
3355
  const rtlStripSessionTokens = (value) => {
3039
3356
  if (Array.isArray(value)) return value.map((entry) => rtlStripSessionTokens(entry));
3040
3357
  if (!rtlDurableIsObject(value)) return value;
3041
3358
  const result = {};
3042
3359
  Object.entries(value).forEach(([key, entry]) => {
3043
- if (key === 'sessionToken' || key === 'sessionTokenId' || key === 'authToken') return;
3044
- result[key] = rtlStripSessionTokens(entry);
3360
+ if (rtlIsCredentialKey(key)) return;
3361
+ Object.defineProperty(result, key, {
3362
+ value: rtlStripSessionTokens(entry),
3363
+ enumerable: true,
3364
+ configurable: true,
3365
+ writable: true
3366
+ });
3045
3367
  });
3046
3368
  return result;
3047
3369
  };
3048
3370
 
3371
+ // Domain state and operation payloads are opaque to Core. They may legitimately
3372
+ // contain fields such as `designToken` or `session` that are part of the user's
3373
+ // data, so the session-secret scrubber is limited to Core-owned envelopes.
3374
+ const rtlSanitizeDurableOperation = (operation) => {
3375
+ const sanitized = rtlStripSessionTokens(operation);
3376
+ if (rtlDurableIsObject(operation) &&
3377
+ Object.prototype.hasOwnProperty.call(operation, 'payload')) {
3378
+ sanitized.payload = clone(operation.payload);
3379
+ }
3380
+ return sanitized;
3381
+ };
3382
+
3383
+ const rtlSanitizeDurableWorkspace = (workspace) => {
3384
+ const sanitized = rtlStripSessionTokens(workspace);
3385
+ if (!rtlDurableIsObject(workspace)) return sanitized;
3386
+ if (rtlDurableIsObject(workspace.remoteBaseline) &&
3387
+ Object.prototype.hasOwnProperty.call(workspace.remoteBaseline, 'state')) {
3388
+ sanitized.remoteBaseline = {
3389
+ ...sanitized.remoteBaseline,
3390
+ state: clone(workspace.remoteBaseline.state)
3391
+ };
3392
+ }
3393
+ if (Array.isArray(workspace.pendingOperations)) {
3394
+ sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
3395
+ }
3396
+ if (rtlDurableIsObject(workspace.rejectedOperations)) {
3397
+ sanitized.rejectedOperations = {...sanitized.rejectedOperations};
3398
+ Object.entries(workspace.rejectedOperations).forEach(([id, entry]) => {
3399
+ if (!rtlDurableIsObject(entry) || !rtlDurableIsObject(entry.operation)) return;
3400
+ sanitized.rejectedOperations[id] = {
3401
+ ...sanitized.rejectedOperations[id],
3402
+ operation: rtlSanitizeDurableOperation(entry.operation)
3403
+ };
3404
+ });
3405
+ }
3406
+ return sanitized;
3407
+ };
3408
+
3049
3409
  const rtlNormalizeOperation = (operation = {}, {
3050
3410
  client,
3051
3411
  clientId,
@@ -3056,35 +3416,6 @@ const rtlNormalizeOperation = (operation = {}, {
3056
3416
  const input = operation && typeof operation === 'object' ? operation : {};
3057
3417
  const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
3058
3418
  const normalizedPayload = payload;
3059
- const trimPayloadId = (key) => {
3060
- if (Object.prototype.hasOwnProperty.call(normalizedPayload, key)) {
3061
- normalizedPayload[key] = normalizeId(normalizedPayload[key]) || normalizedPayload[key];
3062
- }
3063
- };
3064
- [
3065
- 'recordId', 'folderId', 'targetFolderId', 'trashEntryId', 'batchId', 'id',
3066
- 'clientInstanceId', 'instanceId'
3067
- ].forEach(trimPayloadId);
3068
- ['recordIds', 'folderOrder', 'groupOrder', 'expandedGroups', 'groupIds', 'ids'].forEach((key) => {
3069
- if (Array.isArray(normalizedPayload[key])) {
3070
- normalizedPayload[key] = normalizeIdList(normalizedPayload[key]);
3071
- }
3072
- });
3073
- if (rtlDurableIsObject(normalizedPayload.record)) {
3074
- normalizedPayload.record = clone(normalizedPayload.record);
3075
- if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'id')) {
3076
- normalizedPayload.record.id = normalizeId(normalizedPayload.record.id) || normalizedPayload.record.id;
3077
- }
3078
- if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'folderId')) {
3079
- normalizedPayload.record.folderId = normalizeId(normalizedPayload.record.folderId) || normalizedPayload.record.folderId;
3080
- }
3081
- }
3082
- if (rtlDurableIsObject(normalizedPayload.folder)) {
3083
- normalizedPayload.folder = clone(normalizedPayload.folder);
3084
- if (Object.prototype.hasOwnProperty.call(normalizedPayload.folder, 'id')) {
3085
- normalizedPayload.folder.id = normalizeId(normalizedPayload.folder.id) || normalizedPayload.folder.id;
3086
- }
3087
- }
3088
3419
 
3089
3420
  const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
3090
3421
  const resolvedNow = Number.isFinite(operationNow)
@@ -3099,7 +3430,9 @@ const rtlNormalizeOperation = (operation = {}, {
3099
3430
 
3100
3431
  return {
3101
3432
  ...rtlStripSessionTokens(input),
3102
- id: normalizeId(input.id) || generatedId,
3433
+ // Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
3434
+ // invalid whitespace or hostile ID instead of receiving a silently rewritten one.
3435
+ id: input.id === null || input.id === undefined ? generatedId : input.id,
3103
3436
  type: typeof input.type === 'string' ? input.type.trim() : input.type,
3104
3437
  payload: normalizedPayload,
3105
3438
  clientId: resolvedClientId,
@@ -3132,10 +3465,12 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
3132
3465
  const baseline = rtlDurableIsObject(value) ? value : {};
3133
3466
  return {
3134
3467
  state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
3135
- revision: Math.max(0, rtlToFiniteNumber(baseline.revision, 0)),
3468
+ revision: rtlRawNonNegativeSafeInteger(baseline.revision) ? baseline.revision : 0,
3136
3469
  changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
3137
3470
  ? null
3138
- : String(baseline.changeCursor)
3471
+ : rtlDurableIsObject(baseline.changeCursor)
3472
+ ? clone(baseline.changeCursor)
3473
+ : String(baseline.changeCursor)
3139
3474
  };
3140
3475
  };
3141
3476
 
@@ -3149,6 +3484,72 @@ const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) =>
3149
3484
  syncMeta: {}
3150
3485
  });
3151
3486
 
3487
+ const RTL_BOOTSTRAP_RESUME_META_KEYS = [
3488
+ 'resumeMarker',
3489
+ 'resume',
3490
+ 'bootstrapMarker',
3491
+ 'authSessionBinding',
3492
+ 'changeCursorBinding'
3493
+ ];
3494
+
3495
+ const rtlForceOperationIdentity = (operation, ownerUid, workspaceEpoch, options = {}) => ({
3496
+ ...rtlNormalizePendingOperation(operation, {
3497
+ ...options,
3498
+ ownerUid,
3499
+ workspaceEpoch
3500
+ }),
3501
+ ownerUid: rtlNormalizeUid(ownerUid),
3502
+ workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0)
3503
+ });
3504
+
3505
+ const rtlClearBootstrapResumeMeta = (syncMeta) => {
3506
+ const next = rtlDurableIsObject(syncMeta) ? {...syncMeta} : {};
3507
+ RTL_BOOTSTRAP_RESUME_META_KEYS.forEach((key) => {
3508
+ delete next[key];
3509
+ });
3510
+ return next;
3511
+ };
3512
+
3513
+ const rtlRebindSameOwnerWorkspaceEpoch = (workspace, captured, now) => {
3514
+ const ownerUid = rtlNormalizeUid(captured?.uid);
3515
+ const workspaceEpoch = rtlNormalizeWorkspaceEpoch(captured?.workspaceEpoch, 0);
3516
+ const next = clone(workspace);
3517
+ next.ownerUid = ownerUid;
3518
+ next.workspaceEpoch = workspaceEpoch;
3519
+ next.pendingOperations = toArray(next.pendingOperations).map((operation) => (
3520
+ rtlForceOperationIdentity(operation, ownerUid, workspaceEpoch, {now})
3521
+ ));
3522
+ if (rtlDurableIsObject(next.rejectedOperations)) {
3523
+ const rejected = {};
3524
+ Object.entries(next.rejectedOperations).forEach(([id, entry]) => {
3525
+ const value = rtlDurableIsObject(entry) ? {...entry} : entry;
3526
+ if (rtlDurableIsObject(value) && value.operation) {
3527
+ value.operation = rtlForceOperationIdentity(
3528
+ value.operation,
3529
+ ownerUid,
3530
+ workspaceEpoch,
3531
+ {now}
3532
+ );
3533
+ }
3534
+ Object.defineProperty(rejected, id, {
3535
+ value,
3536
+ enumerable: true,
3537
+ configurable: true,
3538
+ writable: true
3539
+ });
3540
+ });
3541
+ next.rejectedOperations = rejected;
3542
+ }
3543
+ next.remoteBaseline = {
3544
+ ...(rtlDurableIsObject(next.remoteBaseline)
3545
+ ? next.remoteBaseline
3546
+ : rtlNormalizeRemoteBaseline({})),
3547
+ changeCursor: null
3548
+ };
3549
+ next.syncMeta = rtlClearBootstrapResumeMeta(next.syncMeta);
3550
+ return next;
3551
+ };
3552
+
3152
3553
  const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3153
3554
  const source = rtlDurableIsObject(input) ? input : {};
3154
3555
  const legacy = !source.remoteBaseline && (
@@ -3187,7 +3588,12 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3187
3588
  Object.entries(rejectedSource).forEach(([rawId, value]) => {
3188
3589
  const id = normalizeId(rawId || value?.id || value?.operationId);
3189
3590
  if (!id) return;
3190
- rejectedOperations[id] = clone(value && typeof value === 'object' ? value : {reason: value});
3591
+ Object.defineProperty(rejectedOperations, id, {
3592
+ value: clone(value && typeof value === 'object' ? value : {reason: value}),
3593
+ enumerable: true,
3594
+ configurable: true,
3595
+ writable: true
3596
+ });
3191
3597
  if (rejectedOperations[id].operation) {
3192
3598
  rejectedOperations[id].operation = rtlNormalizePendingOperation(
3193
3599
  rejectedOperations[id].operation,
@@ -3212,6 +3618,31 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3212
3618
  };
3213
3619
 
3214
3620
  const rtlOperationIdentityReason = (operation, workspace) => {
3621
+ const candidateIds = [
3622
+ operation?.id,
3623
+ operation?.payload?.id,
3624
+ operation?.payload?.recordId,
3625
+ operation?.payload?.folderId,
3626
+ operation?.payload?.targetFolderId,
3627
+ operation?.payload?.trashEntryId,
3628
+ operation?.payload?.record?.id,
3629
+ operation?.payload?.record?.folderId,
3630
+ operation?.payload?.folder?.id,
3631
+ ...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
3632
+ ...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
3633
+ ];
3634
+ if (candidateIds.some((value) => (
3635
+ value !== undefined && value !== null &&
3636
+ !rtlIsSafeDocumentId(value)
3637
+ ))) {
3638
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
3639
+ }
3640
+ const groupOrder = Array.isArray(operation?.payload?.groupOrder)
3641
+ ? operation.payload.groupOrder
3642
+ : [];
3643
+ if (groupOrder.some((value) => !rtlIsSafeOpaqueGroupId(value))) {
3644
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
3645
+ }
3215
3646
  const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
3216
3647
  const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
3217
3648
  if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
@@ -3336,7 +3767,8 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
3336
3767
  }
3337
3768
  rejectedCount += 1;
3338
3769
  const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
3339
- rejected[rejectedId] = {
3770
+ Object.defineProperty(rejected, rejectedId, {
3771
+ value: {
3340
3772
  id: rejectedId,
3341
3773
  operation: clone(operation),
3342
3774
  status: 'rejected',
@@ -3351,7 +3783,11 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
3351
3783
  ),
3352
3784
  workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
3353
3785
  }
3354
- };
3786
+ },
3787
+ enumerable: true,
3788
+ configurable: true,
3789
+ writable: true
3790
+ });
3355
3791
  });
3356
3792
  return {accepted, rejected, rejectedCount, deduplicatedCount};
3357
3793
  };
@@ -3369,6 +3805,11 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3369
3805
  Object.prototype.hasOwnProperty.call(current, 'sessionToken')
3370
3806
  ? current.sessionToken
3371
3807
  : null;
3808
+ // Durable resume proofs use an explicit, non-credential auth binding. The
3809
+ // transport/session token remains an in-memory callback fence only.
3810
+ const authSessionBinding = current && typeof current === 'object'
3811
+ ? (current.authSessionBinding ?? current.authSessionId ?? current.authSessionKey ?? null)
3812
+ : null;
3372
3813
  const uid = rtlNormalizeUid(current && typeof current === 'object'
3373
3814
  ? (current.uid ?? current.ownerUid)
3374
3815
  : null);
@@ -3380,6 +3821,9 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3380
3821
  current,
3381
3822
  hasSession: typeof session?.current === 'function',
3382
3823
  sessionToken,
3824
+ authSessionBinding: typeof authSessionBinding === 'string' && authSessionBinding.trim()
3825
+ ? authSessionBinding
3826
+ : null,
3383
3827
  uid,
3384
3828
  workspaceEpoch: hasEpoch
3385
3829
  ? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
@@ -3405,18 +3849,54 @@ const rtlSessionContext = (captured, workspace, client) => ({
3405
3849
  });
3406
3850
 
3407
3851
  const rtlEnvelopePayload = (value) => {
3852
+ const normalizeRevisionAlias = (candidate) => {
3853
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
3854
+ return candidate;
3855
+ }
3856
+ if (
3857
+ !Object.prototype.hasOwnProperty.call(candidate, 'revision') &&
3858
+ Object.prototype.hasOwnProperty.call(candidate, 'remoteRevision')
3859
+ ) {
3860
+ return {...candidate, revision: candidate.remoteRevision};
3861
+ }
3862
+ return candidate;
3863
+ };
3408
3864
  if (value && typeof value === 'object') {
3409
- if (value.remoteBaseline) return value.remoteBaseline;
3410
- if (value.baseline) return value.baseline;
3411
- if (value.state !== undefined || value.data !== undefined || value.revision !== undefined || value.changeCursor !== undefined) {
3412
- return value;
3865
+ // A canonical failure may carry an authoritative baseline for an
3866
+ // operation-id conflict. It must win over compatibility state fields
3867
+ // left beside `failure`; otherwise a stale outer state can be applied
3868
+ // while the canonical failure is being handled.
3869
+ if (value.failure && typeof value.failure === 'object') {
3870
+ const nested = rtlEnvelopePayload(value.failure);
3871
+ if (nested) return nested;
3872
+ }
3873
+ if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
3874
+ if (value.baseline) return normalizeRevisionAlias(value.baseline);
3875
+ if (
3876
+ value.state !== undefined ||
3877
+ value.data !== undefined ||
3878
+ value.revision !== undefined ||
3879
+ value.remoteRevision !== undefined ||
3880
+ value.changeCursor !== undefined
3881
+ ) {
3882
+ return normalizeRevisionAlias(value);
3883
+ }
3884
+ if (value.snapshot && typeof value.snapshot === 'object') {
3885
+ return normalizeRevisionAlias(value.snapshot);
3886
+ }
3887
+ if (value.error && typeof value.error === 'object') {
3888
+ const nested = rtlEnvelopePayload(value.error);
3889
+ if (nested) return nested;
3413
3890
  }
3414
- if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
3415
3891
  // Adapters migrating from the v1 cloud port may still return a raw
3416
3892
  // normalized state. Treat the presence of state-owned keys as an implicit
3417
3893
  // baseline with the persisted revision.
3418
3894
  if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
3419
- return {state: value, revision: value.revision, changeCursor: value.changeCursor};
3895
+ return {
3896
+ state: value,
3897
+ revision: value.revision ?? value.remoteRevision,
3898
+ changeCursor: value.changeCursor
3899
+ };
3420
3900
  }
3421
3901
  }
3422
3902
  return null;
@@ -3424,44 +3904,63 @@ const rtlEnvelopePayload = (value) => {
3424
3904
 
3425
3905
  const rtlEnvelopeSuccess = (value) => {
3426
3906
  if (!value || typeof value !== 'object') return false;
3427
- if (value.success === false || value.ok === false || value.error || value.code === 'error') return false;
3907
+ if (
3908
+ value.success === false ||
3909
+ value.ok === false ||
3910
+ value.error ||
3911
+ value.failure ||
3912
+ value.code === 'error'
3913
+ ) return false;
3428
3914
  return value.success === true || value.ok === true || (
3429
3915
  value.success === undefined && value.ok === undefined && !value.error
3430
3916
  );
3431
3917
  };
3432
3918
 
3433
- const rtlRetryableEnvelopeError = (value) => {
3434
- const error = value?.error && typeof value.error === 'object' ? value.error : value;
3435
- const status = Number(error?.status ?? error?.statusCode ?? value?.status ?? value?.statusCode);
3436
- const details = [
3437
- error?.name,
3438
- error?.code,
3439
- error?.reason,
3440
- error?.message,
3441
- value?.code,
3442
- value?.reason,
3443
- value?.message
3444
- ].filter(Boolean).join(' ').toLowerCase();
3445
- return Boolean(
3446
- error?.retryable === true ||
3447
- value?.retryable === true ||
3448
- status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 ||
3449
- details.includes('bulk_job_in_progress') || details.includes('retryable') ||
3450
- details.includes('temporarily_unavailable') || details.includes('unavailable') ||
3451
- details.includes('deadline_exceeded') || details.includes('failed to fetch') ||
3452
- details.includes('fetch failed') || details.includes('network-request-failed') ||
3453
- details.includes('network request failed') || details.includes('network_error') ||
3454
- details.includes('err_network') || details.includes('econnreset') ||
3455
- details.includes('etimedout')
3456
- );
3919
+ const rtlExplicitRevisionInfo = (value) => {
3920
+ const revisions = [];
3921
+ let invalid = false;
3922
+ const visited = new Set();
3923
+ const add = (source, key) => {
3924
+ if (!source || typeof source !== 'object' ||
3925
+ !Object.prototype.hasOwnProperty.call(source, key)) return;
3926
+ const raw = source[key];
3927
+ const numeric = typeof raw === 'number' ||
3928
+ (typeof raw === 'string' && raw.trim() !== '')
3929
+ ? Number(raw)
3930
+ : NaN;
3931
+ if (!Number.isSafeInteger(numeric) || numeric < 0) {
3932
+ invalid = true;
3933
+ return;
3934
+ }
3935
+ revisions.push(numeric);
3936
+ };
3937
+ const visit = (source, depth = 0) => {
3938
+ if (!source || typeof source !== 'object' || visited.has(source) || depth > 8) return;
3939
+ visited.add(source);
3940
+ add(source, 'revision');
3941
+ add(source, 'remoteRevision');
3942
+ [
3943
+ source.remoteBaseline,
3944
+ source.baseline,
3945
+ source.snapshot,
3946
+ source.failure,
3947
+ source.error
3948
+ ].forEach((nested) => visit(nested, depth + 1));
3949
+ };
3950
+ visit(value);
3951
+ const unique = [...new Set(revisions)];
3952
+ if (unique.length > 1) invalid = true;
3953
+ return {
3954
+ present: revisions.length > 0 || invalid,
3955
+ valid: !invalid,
3956
+ revision: unique[0] ?? null
3957
+ };
3457
3958
  };
3458
3959
 
3459
- const rtlRetryAfterMs = (value) => {
3460
- const error = value?.error && typeof value.error === 'object' ? value.error : value;
3461
- const rawRetryAfter = error?.retryAfterMs ?? value?.retryAfterMs;
3462
- if (rawRetryAfter === null || rawRetryAfter === undefined || rawRetryAfter === '') return null;
3463
- const retryAfter = Number(rawRetryAfter);
3464
- return Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
3960
+ const rtlInvalidRevisionError = () => {
3961
+ const error = new Error('sync_protocol_invalid_revision');
3962
+ error.code = 'sync_protocol_invalid_revision';
3963
+ return error;
3465
3964
  };
3466
3965
 
3467
3966
  const rtlResultOperationId = (result = {}) => normalizeId(
@@ -3569,6 +4068,12 @@ export const createRecordTimeLabelSyncEngine = ({
3569
4068
 
3570
4069
  const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
3571
4070
 
4071
+ const workspaceMatchesSession = (captured) => {
4072
+ if (!captured?.hasSession) return true;
4073
+ return rtlNormalizeUid(workspace.ownerUid) === captured.uid &&
4074
+ Number(workspace.workspaceEpoch) === Number(captured.workspaceEpoch);
4075
+ };
4076
+
3572
4077
  const isCurrent = async (captured) => {
3573
4078
  if (destroyed || !captured) return false;
3574
4079
  if (typeof session?.isCurrent === 'function') {
@@ -3595,11 +4100,26 @@ export const createRecordTimeLabelSyncEngine = ({
3595
4100
 
3596
4101
  const persist = async (candidate, captured) => {
3597
4102
  if (!(await isCurrent(captured))) return false;
4103
+ const cursor = candidate?.remoteBaseline?.changeCursor;
4104
+ candidate.syncMeta = rtlDurableIsObject(candidate.syncMeta)
4105
+ ? {...candidate.syncMeta}
4106
+ : {};
4107
+ if (cursor !== null && cursor !== undefined) {
4108
+ candidate.syncMeta.changeCursorBinding = {
4109
+ ownerUid: captured?.uid ?? null,
4110
+ uid: captured?.uid ?? null,
4111
+ workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
4112
+ authSessionBinding: captured?.authSessionBinding ?? null
4113
+ };
4114
+ } else {
4115
+ delete candidate.syncMeta.changeCursorBinding;
4116
+ }
4117
+ const durableCandidate = rtlSanitizeDurableWorkspace(candidate);
3598
4118
  // The second argument is an optional adapter-side fence. The public
3599
4119
  // StoragePort remains compatible with save(workspace); adapters that can
3600
4120
  // enforce an atomic session/epoch check should consume this context before
3601
4121
  // mutating durable storage.
3602
- await storage.save(clone(candidate), {
4122
+ await storage.save(clone(durableCandidate), {
3603
4123
  sessionToken: captured?.sessionToken ?? null,
3604
4124
  uid: captured?.uid ?? null,
3605
4125
  ownerUid: captured?.uid ?? null,
@@ -3618,7 +4138,9 @@ export const createRecordTimeLabelSyncEngine = ({
3618
4138
  }
3619
4139
  const nextBaseline = rtlNormalizeRemoteBaseline({
3620
4140
  state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
3621
- revision: Number.isFinite(revision) ? revision : candidate.remoteBaseline.revision,
4141
+ revision: Number.isSafeInteger(revision) && revision >= 0
4142
+ ? revision
4143
+ : candidate.remoteBaseline.revision,
3622
4144
  changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
3623
4145
  ? remote.changeCursor
3624
4146
  : candidate.remoteBaseline.changeCursor
@@ -3638,18 +4160,18 @@ export const createRecordTimeLabelSyncEngine = ({
3638
4160
 
3639
4161
  const normalizeBootstrapResponse = (response) => {
3640
4162
  if (!rtlEnvelopeSuccess(response)) {
3641
- throw toRecordTimeLabelCloudFailureError({
3642
- ...((response && typeof response === 'object') ? response : {}),
3643
- message: response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed',
3644
- code: response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed',
3645
- reason: response?.error?.reason || response?.reason ||
3646
- response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed'
3647
- });
4163
+ const source = response && typeof response === 'object' ? response : {
4164
+ message: 'recordtimelabel_bootstrap_failed',
4165
+ code: 'recordtimelabel_bootstrap_failed',
4166
+ reason: 'recordtimelabel_bootstrap_failed'
4167
+ };
4168
+ throw toRecordTimeLabelCloudFailureError(source);
3648
4169
  }
3649
4170
  const remote = rtlEnvelopePayload(response);
3650
4171
  const revision = Number(remote?.revision);
3651
4172
  const state = remote?.state ?? remote?.data;
3652
- if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
4173
+ if (!remote || !rtlDurableIsObject(state) ||
4174
+ !Number.isSafeInteger(revision) || revision < 0) {
3653
4175
  throw toRecordTimeLabelCloudFailureError({
3654
4176
  code: 'recordtimelabel_invalid_bootstrap_response',
3655
4177
  reason: 'recordtimelabel_invalid_bootstrap_response',
@@ -3703,7 +4225,7 @@ export const createRecordTimeLabelSyncEngine = ({
3703
4225
  const rethrowClassifiedCloudFailure = (captured, error) => {
3704
4226
  const failure = normalizeRecordTimeLabelCloudFailure(error);
3705
4227
  rememberAuthTransitionFailure(captured, failure);
3706
- if (error?.class === failure.class) throw error;
4228
+ if (error instanceof Error && error?.class === failure.class) throw error;
3707
4229
  throw toRecordTimeLabelCloudFailureError(failure);
3708
4230
  };
3709
4231
 
@@ -3714,6 +4236,12 @@ export const createRecordTimeLabelSyncEngine = ({
3714
4236
  error.code = 'recordtimelabel_bootstrap_revision_behind_required';
3715
4237
  error.bootstrapRevision = Number(baseline?.revision);
3716
4238
  error.minimumRevision = minimum;
4239
+ error.failure = normalizeRecordTimeLabelCloudFailure({
4240
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4241
+ code: error.code,
4242
+ reason: error.code,
4243
+ message: error.message
4244
+ });
3717
4245
  throw error;
3718
4246
  }
3719
4247
  return baseline;
@@ -3880,7 +4408,17 @@ export const createRecordTimeLabelSyncEngine = ({
3880
4408
  const epochMismatch = Boolean(
3881
4409
  captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
3882
4410
  );
3883
- if (ownerMismatch || epochMismatch) {
4411
+ if (ownerMismatch) {
4412
+ return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
4413
+ }
4414
+ if (epochMismatch) {
4415
+ if (
4416
+ sourceOwnerUid &&
4417
+ sourceOwnerUid === currentUid &&
4418
+ Number(currentEpoch) > Number(loadedWorkspace.workspaceEpoch)
4419
+ ) {
4420
+ return rtlRebindSameOwnerWorkspaceEpoch(loadedWorkspace, captured, now);
4421
+ }
3884
4422
  return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
3885
4423
  }
3886
4424
  const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
@@ -3939,103 +4477,556 @@ export const createRecordTimeLabelSyncEngine = ({
3939
4477
  nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
3940
4478
  });
3941
4479
 
3942
- const processRemote = async (remoteValue, captured) => {
3943
- if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
4480
+ const failureOutcome = (failure, error, extra = {}) => ({
4481
+ success: false,
4482
+ failure: clone(failure),
4483
+ error,
4484
+ class: failure.class,
4485
+ status: failure.status,
4486
+ code: failure.code,
4487
+ reason: failure.reason,
4488
+ retryable: failure.retryable,
4489
+ retryAfterMs: failure.retryAfterMs,
4490
+ bootstrapRequired: failure.bootstrapRequired,
4491
+ pendingCount: workspace.pendingOperations.length,
4492
+ ...extra
4493
+ });
4494
+
4495
+ const persistHydrationBarrier = async (captured, extraMeta = {}) => {
4496
+ hydrationRequired = true;
3944
4497
  const candidate = clone(workspace);
3945
- const remote = rtlEnvelopePayload(remoteValue);
3946
- const remoteRevision = Number(remote?.revision);
3947
- if (!Number.isFinite(remoteRevision)) {
3948
- return {success: false, protocolError: true, reason: 'invalid_remote_revision'};
3949
- }
3950
- if (remoteRevision <= candidate.remoteBaseline.revision) {
3951
- return {success: true, ignored: true};
3952
- }
3953
- let baselineValue = remoteValue;
3954
- const remoteState = remote?.state ?? remote?.data;
3955
- const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
3956
- if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
3957
- if (typeof cloud?.bootstrap !== 'function') {
3958
- return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3959
- }
3960
- const context = rtlSessionContext(captured, candidate, client);
3961
- baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
3962
- if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3963
- }
3964
- const applied = applyRemoteBaseline(candidate, baselineValue);
3965
- if (applied.stale || !applied.changed) return {success: true, ignored: true};
3966
- if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
4498
+ candidate.syncMeta = {
4499
+ ...candidate.syncMeta,
4500
+ hydrationRequired: true,
4501
+ ...extraMeta
4502
+ };
4503
+ if (!(await persist(candidate, captured))) return false;
3967
4504
  workspace = candidate;
3968
- notify({type: 'remote_merged'});
3969
- return getSnapshot();
4505
+ return true;
3970
4506
  };
3971
4507
 
3972
- const createRemoteReadyState = () => {
3973
- const generation = ++remoteReadyGeneration;
3974
- let resolvePromise;
3975
- let rejectPromise;
3976
- const state = {
3977
- generation,
3978
- settled: false,
3979
- promise: new Promise((resolve, reject) => {
3980
- resolvePromise = resolve;
3981
- rejectPromise = reject;
3982
- }),
3983
- resolve(value = {}) {
3984
- if (state.settled) return;
3985
- state.settled = true;
3986
- resolvePromise({
3987
- success: true,
3988
- ...(value && typeof value === 'object' ? value : {}),
3989
- generation
3990
- });
3991
- },
3992
- reject(error) {
3993
- if (state.settled) return;
3994
- state.settled = true;
3995
- rejectPromise(error);
3996
- }
3997
- };
3998
- // Readiness is an opt-in host boundary. Keep a rejection observable to a
3999
- // waiter without turning clients that have not adopted it yet into an
4000
- // unhandled-rejection source.
4001
- state.promise.catch(() => null);
4002
- return state;
4508
+ const clearPersistedHydrationBarrier = (candidate) => {
4509
+ if (!rtlDurableIsObject(candidate?.syncMeta) ||
4510
+ !Object.prototype.hasOwnProperty.call(candidate.syncMeta, 'hydrationRequired')) {
4511
+ return candidate;
4512
+ }
4513
+ const nextMeta = {...candidate.syncMeta};
4514
+ delete nextMeta.hydrationRequired;
4515
+ candidate.syncMeta = nextMeta;
4516
+ return candidate;
4003
4517
  };
4004
4518
 
4005
- const staleRemoteReady = (state, reason = 'stale_session') => {
4006
- if (!state || state.settled) return;
4007
- state.resolve({success: false, stale: true, reason});
4519
+ const persistTerminalFailureBlock = async ({
4520
+ failure,
4521
+ error,
4522
+ captured,
4523
+ timestamp,
4524
+ identityRejectedCount,
4525
+ extra = {}
4526
+ }) => {
4527
+ const candidate = clone(workspace);
4528
+ candidate.syncMeta = {
4529
+ ...candidate.syncMeta,
4530
+ lastSyncAttemptAt: timestamp,
4531
+ lastSyncError: failure.message || failure.code || 'terminal',
4532
+ lastSyncFailure: clone(failure),
4533
+ terminalFailureBlock: clone(failure)
4534
+ };
4535
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4536
+ workspace = candidate;
4537
+ return failureOutcome(failure, error, {
4538
+ protocolError: true,
4539
+ blocked: true,
4540
+ identityRejectedCount,
4541
+ ...extra
4542
+ });
4008
4543
  };
4009
4544
 
4010
- const isCurrentRemoteReady = (state) => (
4011
- !destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
4012
- );
4013
-
4014
- const stopCloudSubscription = ({settleReady = true} = {}) => {
4015
- if (typeof unsubscribeCloud === 'function') {
4016
- try { unsubscribeCloud(); } catch (error) {
4017
- logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
4018
- }
4545
+ const rebaseAfterBootstrapRequired = async ({
4546
+ failure,
4547
+ error,
4548
+ captured,
4549
+ context,
4550
+ timestamp,
4551
+ identityRejectedCount
4552
+ }) => {
4553
+ hydrationRequired = true;
4554
+ if (!(await persistHydrationBarrier(captured, {
4555
+ lastSyncAttemptAt: timestamp,
4556
+ lastSyncFailure: clone(failure),
4557
+ lastSyncError: failure.message || failure.code || 'bootstrap-required'
4558
+ }))) {
4559
+ return {success: false, reason: 'stale_session'};
4019
4560
  }
4020
- unsubscribeCloud = null;
4021
- subscriptionContext = null;
4022
- if (settleReady) staleRemoteReady(remoteReadyState);
4023
- };
4024
-
4025
- const startCloudSubscription = (captured, candidate) => {
4026
- stopCloudSubscription();
4027
- const readyState = createRemoteReadyState();
4028
- remoteReadyState = readyState;
4029
- if (!initialized || hydrationRequired) {
4030
- readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
4031
- return;
4561
+ if (typeof cloud?.bootstrap !== 'function') {
4562
+ return failureOutcome(failure, error, {
4563
+ bootstrapRequired: true,
4564
+ hydrationBarrier: true,
4565
+ identityRejectedCount
4566
+ });
4567
+ }
4568
+ let baseline;
4569
+ try {
4570
+ baseline = normalizeBootstrapResponse(await cloud.bootstrap(
4571
+ context,
4572
+ bootstrapAttemptOptions('apply-failure-rebase')
4573
+ ));
4574
+ } catch (bootstrapError) {
4575
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4576
+ rethrowClassifiedCloudFailure(captured, bootstrapError);
4577
+ }
4578
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4579
+ const candidate = clone(workspace);
4580
+ const applied = applyRemoteBaseline(candidate, baseline);
4581
+ if (applied.stale) {
4582
+ const staleFailure = normalizeRecordTimeLabelCloudFailure({
4583
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4584
+ code: 'bootstrap_revision_behind_required',
4585
+ reason: 'bootstrap_revision_behind_required',
4586
+ message: 'bootstrap_revision_behind_required'
4587
+ });
4588
+ return failureOutcome(staleFailure, toRecordTimeLabelCloudFailureError(staleFailure), {
4589
+ bootstrapRequired: true,
4590
+ hydrationBarrier: true,
4591
+ identityRejectedCount
4592
+ });
4593
+ }
4594
+ candidate.syncMeta = {...candidate.syncMeta};
4595
+ delete candidate.syncMeta.terminalFailureBlock;
4596
+ candidate.syncMeta.lastSyncFailure = clone(failure);
4597
+ candidate.syncMeta.lastSyncAttemptAt = timestamp;
4598
+ candidate.syncMeta.lastSyncError = null;
4599
+ clearPersistedHydrationBarrier(candidate);
4600
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4601
+ workspace = candidate;
4602
+ hydrationRequired = false;
4603
+ notify({type: 'hydration_rebased', failure: clone(failure)});
4604
+ return failureOutcome(failure, error, {
4605
+ bootstrapRequired: true,
4606
+ hydrationBarrier: false,
4607
+ rebaseCompleted: true,
4608
+ identityRejectedCount
4609
+ });
4610
+ };
4611
+
4612
+ const isAuthoritativeFailureBaseline = (value, minimumRevision, verifiedRevision = null) => {
4613
+ const remote = rtlEnvelopePayload(value);
4614
+ const revision = Number(remote?.revision ?? remote?.remoteRevision);
4615
+ const minimum = Number(minimumRevision);
4616
+ const verified = Number(verifiedRevision);
4617
+ return Boolean(
4618
+ remote &&
4619
+ rtlDurableIsObject(remote.state ?? remote.data) &&
4620
+ Number.isSafeInteger(revision) &&
4621
+ revision >= (Number.isFinite(minimum) ? minimum : 0) &&
4622
+ (verifiedRevision === null || verifiedRevision === undefined ||
4623
+ (Number.isSafeInteger(verified) && revision === verified))
4624
+ );
4625
+ };
4626
+
4627
+ const settleApplyFailure = async ({
4628
+ value,
4629
+ captured,
4630
+ context,
4631
+ ready,
4632
+ timestamp,
4633
+ identityRejectedCount
4634
+ }) => {
4635
+ const failure = normalizeRecordTimeLabelCloudFailure(value);
4636
+ const error = toRecordTimeLabelCloudFailureError(failure);
4637
+ const responseRevisionInfo = rtlExplicitRevisionInfo(value);
4638
+
4639
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION) {
4640
+ // A stale response is never an acknowledgement for the captured
4641
+ // workspace. In particular, do not persist retry metadata or notify
4642
+ // listeners that may already be observing a newer session.
4643
+ return failureOutcome(failure, error, {
4644
+ stale: true,
4645
+ reason: 'stale_session',
4646
+ identityRejectedCount
4647
+ });
4648
+ }
4649
+
4650
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
4651
+ rememberAuthTransitionFailure(captured, failure);
4652
+ throw error;
4653
+ }
4654
+
4655
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
4656
+ return rebaseAfterBootstrapRequired({
4657
+ failure,
4658
+ error,
4659
+ captured,
4660
+ context,
4661
+ timestamp,
4662
+ identityRejectedCount
4663
+ });
4664
+ }
4665
+
4666
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT) {
4667
+ const candidate = clone(workspace);
4668
+ const readyIds = new Set(ready.map((operation) => operation.id));
4669
+ candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
4670
+ readyIds.has(operation.id)
4671
+ ? makeRetryOperation(operation, failure, failure.retryAfterMs, timestamp)
4672
+ : operation
4673
+ ));
4674
+ candidate.syncMeta = {
4675
+ ...candidate.syncMeta,
4676
+ lastSyncAttemptAt: timestamp,
4677
+ lastSyncError: failure.message || failure.reason || failure.code || 'retryable',
4678
+ lastSyncFailure: clone(failure)
4679
+ };
4680
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4681
+ workspace = candidate;
4682
+ notify({
4683
+ type: 'sync_retry',
4684
+ reason: failure.code || failure.reason || 'retryable',
4685
+ failure: clone(failure)
4686
+ });
4687
+ const retryAt = Math.min(...candidate.pendingOperations
4688
+ .filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
4689
+ .map((operation) => Number(operation.nextRetryAt)));
4690
+ return failureOutcome(failure, error, {
4691
+ retryable: true,
4692
+ retryAfterMs: failure.retryAfterMs,
4693
+ retryAt: Number.isFinite(retryAt) ? retryAt : null,
4694
+ pendingCount: workspace.pendingOperations.length,
4695
+ identityRejectedCount
4696
+ });
4697
+ }
4698
+
4699
+ // Only an explicit request/operation-id conflict is eligible for the
4700
+ // existing rejection-rebase path. A bulk operation is deliberately
4701
+ // excluded: quarantining one entry from a bulk job would acknowledge a
4702
+ // request whose server-side transaction did not run.
4703
+ const isBulkBatch = ready.some((operation) => (
4704
+ typeof RTL_BULK_OPERATION_TYPES !== 'undefined' &&
4705
+ RTL_BULK_OPERATION_TYPES.has(operation?.type)
4706
+ ));
4707
+ const isOperationConflict = isRecordTimeLabelOperationConflictFailure(value);
4708
+ if (
4709
+ failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL &&
4710
+ isOperationConflict &&
4711
+ !isBulkBatch
4712
+ ) {
4713
+ // A conflict rebase is only safe when every revision alias in the
4714
+ // response agrees. Reject malformed/conflicting revision metadata
4715
+ // before calling bootstrap or changing the ready outbox.
4716
+ if (!responseRevisionInfo.valid) {
4717
+ const protocolError = rtlInvalidRevisionError();
4718
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', protocolError);
4719
+ return persistTerminalFailureBlock({
4720
+ failure,
4721
+ error: protocolError,
4722
+ captured,
4723
+ timestamp,
4724
+ identityRejectedCount,
4725
+ extra: {protocolError: true}
4726
+ });
4727
+ }
4728
+ let freshBaseline = null;
4729
+ const currentRevisionValue = Number(workspace.remoteBaseline.revision);
4730
+ const currentRevision = Number.isSafeInteger(currentRevisionValue) && currentRevisionValue >= 0
4731
+ ? currentRevisionValue
4732
+ : 0;
4733
+ const requiredRevision = Math.max(
4734
+ currentRevision,
4735
+ responseRevisionInfo.revision ?? 0
4736
+ );
4737
+ if (typeof cloud?.bootstrap === 'function') {
4738
+ try {
4739
+ freshBaseline = requireBootstrapRevision(
4740
+ normalizeBootstrapResponse(await cloud.bootstrap(
4741
+ context,
4742
+ bootstrapAttemptOptions('rejection-rebase')
4743
+ )),
4744
+ requiredRevision
4745
+ );
4746
+ } catch (bootstrapError) {
4747
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4748
+ rethrowClassifiedCloudFailure(captured, bootstrapError);
4749
+ }
4750
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4751
+ } else if (isAuthoritativeFailureBaseline(
4752
+ value,
4753
+ requiredRevision,
4754
+ responseRevisionInfo.revision
4755
+ )) {
4756
+ freshBaseline = rtlEnvelopePayload(value);
4757
+ }
4758
+
4759
+ // Preserve 1A1's fail-closed contract when no fresh/authoritative
4760
+ // baseline is available: nothing, including outbox metadata, changes.
4761
+ if (!freshBaseline) {
4762
+ return persistTerminalFailureBlock({
4763
+ failure,
4764
+ error,
4765
+ captured,
4766
+ timestamp,
4767
+ identityRejectedCount
4768
+ });
4769
+ }
4770
+
4771
+ const candidate = clone(workspace);
4772
+ applyRemoteBaseline(candidate, freshBaseline);
4773
+ const readyIds = new Set(ready.map((operation) => operation.id));
4774
+ const rejectionResponse = rtlStripSessionTokens({
4775
+ ...((value && typeof value === 'object') ? value : {}),
4776
+ failure: clone(failure)
4777
+ });
4778
+ const nextRejected = {...candidate.rejectedOperations};
4779
+ const rejectedResults = [];
4780
+ const completedOperations = [];
4781
+ ready.forEach((operation) => {
4782
+ const reason = failure.reason || failure.code || 'operation_id_conflict';
4783
+ Object.defineProperty(nextRejected, operation.id, {
4784
+ value: {
4785
+ id: operation.id,
4786
+ operation: clone(operation),
4787
+ status: 'rejected',
4788
+ reason,
4789
+ rejectedAt: timestamp,
4790
+ response: rejectionResponse
4791
+ },
4792
+ enumerable: true,
4793
+ configurable: true,
4794
+ writable: true
4795
+ });
4796
+ rejectedResults.push({
4797
+ id: operation.id,
4798
+ operationId: operation.id,
4799
+ status: 'rejected',
4800
+ applied: false,
4801
+ reason,
4802
+ code: failure.code,
4803
+ class: failure.class,
4804
+ statusCode: failure.status
4805
+ });
4806
+ completedOperations.push({operation, status: 'rejected'});
4807
+ });
4808
+ candidate.pendingOperations = candidate.pendingOperations.filter((operation) => (
4809
+ !readyIds.has(operation.id)
4810
+ ));
4811
+ candidate.rejectedOperations = nextRejected;
4812
+ candidate.syncMeta = {
4813
+ ...candidate.syncMeta,
4814
+ lastSyncedAt: timestamp,
4815
+ lastSyncAttemptAt: timestamp,
4816
+ lastSyncError: null,
4817
+ lastSyncFailure: clone(failure),
4818
+ lastAcknowledgedRevision: Math.max(
4819
+ rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
4820
+ Number(candidate.remoteBaseline.revision)
4821
+ )
4822
+ };
4823
+ rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
4824
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4825
+ workspace = candidate;
4826
+ notify({
4827
+ type: 'synced',
4828
+ operations: clone(rejectedResults),
4829
+ failure: clone(failure)
4830
+ });
4831
+ return {
4832
+ success: true,
4833
+ failure: clone(failure),
4834
+ error,
4835
+ class: failure.class,
4836
+ status: failure.status,
4837
+ code: failure.code,
4838
+ rejectedCount: ready.length,
4839
+ syncedCount: 0,
4840
+ pendingCount: workspace.pendingOperations.length,
4841
+ identityRejectedCount
4842
+ };
4843
+ }
4844
+
4845
+ // Generic terminal failures are diagnostics only. In particular, do
4846
+ // not remove an entire ready batch merely because an old gateway returned
4847
+ // a non-retryable envelope. Block the FIFO explicitly so callers cannot
4848
+ // accidentally resend an unlocatable terminal batch forever.
4849
+ logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
4850
+ return persistTerminalFailureBlock({
4851
+ failure,
4852
+ error,
4853
+ captured,
4854
+ timestamp,
4855
+ identityRejectedCount
4856
+ });
4857
+ };
4858
+
4859
+ const processRemote = async (remoteValue, captured) => {
4860
+ if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
4861
+ const candidate = clone(workspace);
4862
+ const remote = rtlEnvelopePayload(remoteValue);
4863
+ const remoteRevision = Number(remote?.revision);
4864
+ if (!Number.isSafeInteger(remoteRevision) || remoteRevision < 0) {
4865
+ const failure = normalizeRecordTimeLabelCloudFailure({
4866
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
4867
+ code: 'invalid_remote_revision',
4868
+ reason: 'invalid_remote_revision',
4869
+ message: 'invalid_remote_revision'
4870
+ });
4871
+ return {
4872
+ success: false,
4873
+ protocolError: true,
4874
+ reason: 'invalid_remote_revision',
4875
+ failure
4876
+ };
4877
+ }
4878
+ if (remoteRevision <= candidate.remoteBaseline.revision) {
4879
+ return {success: true, ignored: true};
4880
+ }
4881
+ let baselineValue = remoteValue;
4882
+ const remoteState = remote?.state ?? remote?.data;
4883
+ const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
4884
+ if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
4885
+ if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
4886
+ const failure = normalizeRecordTimeLabelCloudFailure({
4887
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4888
+ code: 'revision_gap',
4889
+ reason: 'revision_gap',
4890
+ message: 'revision_gap'
4891
+ });
4892
+ return {
4893
+ success: false,
4894
+ reason: 'revision_gap',
4895
+ bootstrapRequired: true,
4896
+ failure,
4897
+ error: toRecordTimeLabelCloudFailureError(failure)
4898
+ };
4899
+ }
4900
+ const context = rtlSessionContext(captured, candidate, client);
4901
+ baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
4902
+ if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
4903
+ }
4904
+ const applied = applyRemoteBaseline(candidate, baselineValue);
4905
+ if (applied.stale || !applied.changed) return {success: true, ignored: true};
4906
+ if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
4907
+ workspace = candidate;
4908
+ notify({type: 'remote_merged'});
4909
+ return getSnapshot();
4910
+ };
4911
+
4912
+ const createRemoteReadyState = () => {
4913
+ const generation = ++remoteReadyGeneration;
4914
+ let resolvePromise;
4915
+ let rejectPromise;
4916
+ const state = {
4917
+ generation,
4918
+ settled: false,
4919
+ promise: new Promise((resolve, reject) => {
4920
+ resolvePromise = resolve;
4921
+ rejectPromise = reject;
4922
+ }),
4923
+ resolve(value = {}) {
4924
+ if (state.settled) return;
4925
+ state.settled = true;
4926
+ resolvePromise({
4927
+ success: true,
4928
+ ...(value && typeof value === 'object' ? value : {})
4929
+ });
4930
+ },
4931
+ reject(error) {
4932
+ if (state.settled) return;
4933
+ state.settled = true;
4934
+ const failure = normalizeRecordTimeLabelCloudFailure(error);
4935
+ let canonicalError = error;
4936
+ if (error instanceof Error) {
4937
+ Object.assign(error, {
4938
+ schemaVersion: failure.schemaVersion,
4939
+ class: failure.class,
4940
+ code: failure.code,
4941
+ reason: failure.reason,
4942
+ status: failure.status,
4943
+ retryable: failure.retryable,
4944
+ retryAfterMs: failure.retryAfterMs,
4945
+ bootstrapRequired: failure.bootstrapRequired,
4946
+ requestId: failure.requestId
4947
+ });
4948
+ if (!error.failure) {
4949
+ Object.defineProperty(error, 'failure', {
4950
+ value: failure,
4951
+ enumerable: false,
4952
+ configurable: true
4953
+ });
4954
+ }
4955
+ } else {
4956
+ canonicalError = toRecordTimeLabelCloudFailureError(failure);
4957
+ }
4958
+ rejectPromise(canonicalError);
4959
+ }
4960
+ };
4961
+ // Readiness is an opt-in host boundary. Keep a rejection observable to a
4962
+ // waiter without turning clients that have not adopted it yet into an
4963
+ // unhandled-rejection source.
4964
+ state.promise.catch(() => null);
4965
+ return state;
4966
+ };
4967
+
4968
+ const staleRemoteReady = (state, reason = 'stale_session') => {
4969
+ if (!state || state.settled) return;
4970
+ const failure = normalizeRecordTimeLabelCloudFailure({
4971
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
4972
+ code: reason,
4973
+ reason
4974
+ });
4975
+ state.resolve({success: false, stale: true, reason, failure});
4976
+ };
4977
+
4978
+ const isCurrentRemoteReady = (state) => (
4979
+ !destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
4980
+ );
4981
+
4982
+ const stopCloudSubscription = ({settleReady = true} = {}) => {
4983
+ if (typeof unsubscribeCloud === 'function') {
4984
+ try { unsubscribeCloud(); } catch (error) {
4985
+ logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
4986
+ }
4987
+ }
4988
+ unsubscribeCloud = null;
4989
+ subscriptionContext = null;
4990
+ if (settleReady) staleRemoteReady(remoteReadyState);
4991
+ };
4992
+
4993
+ const resetForSessionIdentity = (captured) => {
4994
+ stopCloudSubscription();
4995
+ const sameOwner = Boolean(
4996
+ rtlNormalizeUid(workspace.ownerUid) &&
4997
+ rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(captured?.uid)
4998
+ );
4999
+ const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
5000
+ workspace = sameOwner && epochIncreased
5001
+ ? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
5002
+ : rtlEmptyDurableWorkspace({
5003
+ ownerUid: captured?.uid,
5004
+ workspaceEpoch: captured?.workspaceEpoch
5005
+ });
5006
+ initialized = false;
5007
+ hydrationRequired = true;
5008
+ notify({type: 'session_changed'});
5009
+ };
5010
+
5011
+ const startCloudSubscription = (captured, candidate) => {
5012
+ stopCloudSubscription();
5013
+ const readyState = createRemoteReadyState();
5014
+ remoteReadyState = readyState;
5015
+ if (!initialized || hydrationRequired) {
5016
+ readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
5017
+ return;
4032
5018
  }
4033
5019
  if (!captured?.uid) {
4034
5020
  readyState.resolve({skipped: true, reason: 'anonymous'});
4035
5021
  return;
4036
5022
  }
4037
5023
  if (typeof cloud?.subscribe !== 'function') {
4038
- readyState.resolve({skipped: true, reason: 'no_subscription'});
5024
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5025
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5026
+ code: 'remote_provider_missing',
5027
+ reason: 'remote_provider_missing',
5028
+ message: 'remote_provider_missing'
5029
+ }));
4039
5030
  return;
4040
5031
  }
4041
5032
  const context = rtlSessionContext(captured, candidate, client);
@@ -4043,24 +5034,100 @@ export const createRecordTimeLabelSyncEngine = ({
4043
5034
  let subscriptionReturned = false;
4044
5035
  let adapterReadyExpected = false;
4045
5036
  let firstCallbackResult = null;
5037
+ let firstRootObservation = null;
5038
+ let latestRootObservationRevision = null;
5039
+ let adapterCatchUpProof = null;
5040
+ const settleReadyIfProven = () => {
5041
+ if (!isCurrentRemoteReady(readyState) || readyState.settled ||
5042
+ !firstRootObservation || (adapterReadyExpected && !adapterCatchUpProof)) {
5043
+ return;
5044
+ }
5045
+ const baselineRevision = Number.isSafeInteger(workspace.remoteBaseline.revision)
5046
+ ? workspace.remoteBaseline.revision
5047
+ : 0;
5048
+ const proof = adapterCatchUpProof || {};
5049
+ const revision = Number.isSafeInteger(Number(proof.revision))
5050
+ ? Number(proof.revision)
5051
+ : baselineRevision;
5052
+ const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
5053
+ ? Number(proof.caughtUpToRevision)
5054
+ : revision;
5055
+ if (revision < 0 || caughtUpToRevision < 0) {
5056
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5057
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5058
+ code: 'invalid_remote_ready_revision',
5059
+ reason: 'invalid_remote_ready_revision',
5060
+ message: 'invalid_remote_ready_revision'
5061
+ }));
5062
+ return;
5063
+ }
5064
+ const observedRevision = Number.isSafeInteger(latestRootObservationRevision)
5065
+ ? latestRootObservationRevision
5066
+ : baselineRevision;
5067
+ const requiredRevision = Math.max(baselineRevision, observedRevision);
5068
+ if (revision < requiredRevision || caughtUpToRevision < requiredRevision) {
5069
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5070
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5071
+ code: 'stale_remote_ready_revision',
5072
+ reason: 'stale_remote_ready_revision',
5073
+ message: 'stale_remote_ready_revision'
5074
+ }));
5075
+ return;
5076
+ }
5077
+ // A proof for a revision the root listener has not processed yet must
5078
+ // remain behind the barrier. A later root observation retries settling.
5079
+ if (revision > requiredRevision || caughtUpToRevision > requiredRevision) return;
5080
+ readyState.resolve({
5081
+ sessionKey: proof.sessionKey ??
5082
+ context.sessionKey ??
5083
+ captured.sessionToken ??
5084
+ captured.uid ??
5085
+ null,
5086
+ revision,
5087
+ caughtUpToRevision
5088
+ });
5089
+ };
5090
+ const handleRootObservation = async (result, observedRevision) => {
5091
+ if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
5092
+ if (result?.stale === true || result?.reason === 'stale_session') return;
5093
+ if (result?.success === false) {
5094
+ readyState.reject(toRecordTimeLabelCloudFailureError(
5095
+ result.failure || {
5096
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5097
+ code: result.reason || 'remote_initial_observation_failed',
5098
+ reason: result.reason || 'remote_initial_observation_failed'
5099
+ }
5100
+ ));
5101
+ return;
5102
+ }
5103
+ firstRootObservation = result || {success: true};
5104
+ if (Number.isSafeInteger(observedRevision) && observedRevision >= 0) {
5105
+ latestRootObservationRevision = Math.max(
5106
+ latestRootObservationRevision ?? 0,
5107
+ observedRevision
5108
+ );
5109
+ }
5110
+ if (!adapterReadyExpected) {
5111
+ adapterCatchUpProof = {
5112
+ success: true,
5113
+ sessionKey: captured.sessionToken ?? captured.uid ?? null,
5114
+ revision: workspace.remoteBaseline.revision,
5115
+ caughtUpToRevision: workspace.remoteBaseline.revision
5116
+ };
5117
+ }
5118
+ settleReadyIfProven();
5119
+ };
4046
5120
  const onRemote = (remoteValue) => {
4047
5121
  if (destroyed || !isCurrentRemoteReady(readyState)) {
4048
5122
  return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
4049
5123
  }
5124
+ const observedRevisionValue = Number(rtlEnvelopePayload(remoteValue)?.revision);
5125
+ const observedRevision = Number.isSafeInteger(observedRevisionValue) && observedRevisionValue >= 0
5126
+ ? observedRevisionValue
5127
+ : null;
4050
5128
  const callbackResult = enqueue(() => processRemote(remoteValue, captured));
4051
5129
  if (!firstCallbackResult) firstCallbackResult = callbackResult;
4052
- callbackResult.then((result) => {
4053
- if (
4054
- subscriptionReturned &&
4055
- !adapterReadyExpected &&
4056
- isCurrentRemoteReady(readyState)
4057
- ) {
4058
- readyState.resolve({
4059
- revision: workspace.remoteBaseline.revision,
4060
- result
4061
- });
4062
- }
4063
- }).catch((error) => {
5130
+ callbackResult.then((result) => handleRootObservation(result, observedRevision)).catch((error) => {
4064
5131
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
4065
5132
  readyState.reject(error);
4066
5133
  }
@@ -4076,29 +5143,52 @@ export const createRecordTimeLabelSyncEngine = ({
4076
5143
  logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
4077
5144
  return;
4078
5145
  }
4079
- unsubscribeCloud = typeof disposer === 'function' ? disposer : null;
5146
+ unsubscribeCloud = typeof disposer === 'function'
5147
+ ? disposer
5148
+ : (typeof disposer?.unsubscribe === 'function' ? () => disposer.unsubscribe() : null);
5149
+ const onSubscriptionError = (error) => {
5150
+ if (!isCurrentRemoteReady(readyState)) return;
5151
+ stopCloudSubscription({settleReady: false});
5152
+ const failure = normalizeRecordTimeLabelCloudFailure(error);
5153
+ readyState.reject(toRecordTimeLabelCloudFailureError(failure));
5154
+ notify({type: 'remote_error', failure: clone(failure)});
5155
+ };
5156
+ try {
5157
+ if (typeof disposer?.setErrorHandler === 'function') {
5158
+ disposer.setErrorHandler(onSubscriptionError);
5159
+ } else if (typeof disposer?.onError === 'function') {
5160
+ disposer.onError(onSubscriptionError);
5161
+ }
5162
+ } catch (error) {
5163
+ onSubscriptionError(error);
5164
+ return;
5165
+ }
4080
5166
  adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
4081
5167
  subscriptionReturned = true;
4082
5168
  if (adapterReadyExpected) {
4083
5169
  Promise.resolve(disposer.ready).then((result) => {
4084
5170
  if (!isCurrentRemoteReady(readyState)) return;
4085
- readyState.resolve({
4086
- revision: workspace.remoteBaseline.revision,
4087
- result
4088
- });
5171
+ if (result?.success === false) {
5172
+ readyState.reject(toRecordTimeLabelCloudFailureError(
5173
+ result.failure || {
5174
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5175
+ code: result.reason || 'remote_initial_observation_failed',
5176
+ reason: result.reason || 'remote_initial_observation_failed'
5177
+ }
5178
+ ));
5179
+ } else {
5180
+ adapterCatchUpProof = result && typeof result === 'object'
5181
+ ? {...result, success: true}
5182
+ : {success: true};
5183
+ settleReadyIfProven();
5184
+ }
4089
5185
  }).catch((error) => {
4090
5186
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
4091
5187
  readyState.reject(error);
4092
5188
  }
4093
5189
  });
4094
5190
  } else if (firstCallbackResult) {
4095
- firstCallbackResult.then((result) => {
4096
- if (!isCurrentRemoteReady(readyState)) return;
4097
- readyState.resolve({
4098
- revision: workspace.remoteBaseline.revision,
4099
- result
4100
- });
4101
- }).catch(() => null);
5191
+ firstCallbackResult.then(() => settleReadyIfProven()).catch(() => null);
4102
5192
  }
4103
5193
  };
4104
5194
 
@@ -4114,10 +5204,8 @@ export const createRecordTimeLabelSyncEngine = ({
4114
5204
  const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
4115
5205
  const loadedBaseline = loaded?.remoteBaseline;
4116
5206
  const committedRevision = Number(loadedBaseline?.revision);
4117
- const hasValidCommittedBaseline = rtlDurableIsObject(loadedBaseline) &&
4118
- rtlDurableIsObject(loadedBaseline.state) &&
4119
- Number.isFinite(committedRevision) &&
4120
- committedRevision > 0;
5207
+ const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
5208
+ const persistedHydrationRequired = loaded?.syncMeta?.hydrationRequired === true;
4121
5209
  let candidate = normalizeLoadedWorkspace(loaded, captured);
4122
5210
  const identityChecked = rtlQuarantineOperations(
4123
5211
  candidate,
@@ -4129,12 +5217,22 @@ export const createRecordTimeLabelSyncEngine = ({
4129
5217
  const context = rtlSessionContext(captured, candidate, client);
4130
5218
 
4131
5219
  const canReuseCommitted = initialBaseline === 'reuse-committed' &&
5220
+ !persistedHydrationRequired &&
4132
5221
  captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
4133
5222
  hasValidCommittedBaseline &&
4134
5223
  candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
4135
5224
  candidate.remoteBaseline.revision === committedRevision &&
4136
5225
  (!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
5226
+ let obtainedFreshBaseline = canReuseCommitted;
4137
5227
  if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
5228
+ // Do not carry untrusted resume state into a fresh hydration attempt.
5229
+ // Only the bootstrap response may establish the next cursor/marker.
5230
+ candidate.remoteBaseline.changeCursor = null;
5231
+ candidate.syncMeta = {...candidate.syncMeta};
5232
+ ['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding', 'changeCursorBinding']
5233
+ .forEach((key) => {
5234
+ delete candidate.syncMeta[key];
5235
+ });
4138
5236
  let bootstrap;
4139
5237
  try {
4140
5238
  bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
@@ -4143,14 +5241,41 @@ export const createRecordTimeLabelSyncEngine = ({
4143
5241
  ));
4144
5242
  } catch (error) {
4145
5243
  if (!(await isCurrent(captured))) return getSnapshot();
5244
+ if (persistedHydrationRequired) {
5245
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5246
+ await persist(candidate, captured);
5247
+ }
4146
5248
  rethrowClassifiedCloudFailure(captured, error);
4147
5249
  }
4148
5250
  if (!(await isCurrent(captured))) return getSnapshot();
4149
- applyRemoteBaseline(candidate, bootstrap);
5251
+ const applied = applyRemoteBaseline(candidate, bootstrap);
5252
+ if (applied.stale) {
5253
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5254
+ if (!(await persist(candidate, captured))) return getSnapshot();
5255
+ workspace = candidate;
5256
+ initialized = true;
5257
+ hydrationRequired = true;
5258
+ notify({type: 'initialized'});
5259
+ return getSnapshot();
5260
+ }
5261
+ delete candidate.syncMeta.terminalFailureBlock;
5262
+ clearPersistedHydrationBarrier(candidate);
5263
+ obtainedFreshBaseline = true;
5264
+ }
5265
+
5266
+ if (persistedHydrationRequired && !obtainedFreshBaseline) {
5267
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5268
+ if (!(await persist(candidate, captured))) return getSnapshot();
5269
+ workspace = candidate;
5270
+ initialized = true;
5271
+ hydrationRequired = true;
5272
+ notify({type: 'initialized'});
5273
+ return getSnapshot();
4150
5274
  }
4151
5275
 
4152
5276
  // Always persist the normalized durable shape before subscribing. This
4153
5277
  // also makes legacy migration atomic from the engine's point of view.
5278
+ clearPersistedHydrationBarrier(candidate);
4154
5279
  if (!(await persist(candidate, captured))) return getSnapshot();
4155
5280
  workspace = candidate;
4156
5281
  initialized = true;
@@ -4168,14 +5293,7 @@ export const createRecordTimeLabelSyncEngine = ({
4168
5293
  startCloudSubscription(current, workspace);
4169
5294
  return {success: true, tokenRefreshed: true};
4170
5295
  }
4171
- stopCloudSubscription();
4172
- workspace = rtlEmptyDurableWorkspace({
4173
- ownerUid: current.uid,
4174
- workspaceEpoch: current.workspaceEpoch
4175
- });
4176
- initialized = false;
4177
- hydrationRequired = true;
4178
- notify({type: 'session_changed'});
5296
+ resetForSessionIdentity(current);
4179
5297
  return {success: true, sessionChanged: true};
4180
5298
  }).catch((error) => {
4181
5299
  logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
@@ -4191,7 +5309,12 @@ export const createRecordTimeLabelSyncEngine = ({
4191
5309
  return {success: false, code: 'recordtimelabel_hydration_required'};
4192
5310
  }
4193
5311
  const captured = capture();
5312
+ clearAuthTransitionLatchIfSessionChanged(captured);
4194
5313
  if (!(await isCurrent(captured))) return getSnapshot();
5314
+ if (!workspaceMatchesSession(captured)) {
5315
+ resetForSessionIdentity(captured);
5316
+ return {success: false, code: 'recordtimelabel_hydration_required'};
5317
+ }
4195
5318
  const input = Array.isArray(operations)
4196
5319
  ? operations
4197
5320
  : (operations && typeof operations === 'object' ? [operations] : []);
@@ -4272,11 +5395,25 @@ export const createRecordTimeLabelSyncEngine = ({
4272
5395
  };
4273
5396
 
4274
5397
  const syncInternal = async (reason) => {
5398
+ const captured = capture();
5399
+ throwIfAuthTransitionLatched(captured);
4275
5400
  if (!initialized || hydrationRequired) {
4276
5401
  return {success: false, code: 'recordtimelabel_hydration_required'};
4277
5402
  }
4278
- const captured = capture();
4279
5403
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
5404
+ if (!workspaceMatchesSession(captured)) {
5405
+ resetForSessionIdentity(captured);
5406
+ return {success: false, code: 'recordtimelabel_hydration_required'};
5407
+ }
5408
+ const blockedFailure = workspace.syncMeta?.terminalFailureBlock;
5409
+ if (blockedFailure) {
5410
+ const failure = normalizeRecordTimeLabelCloudFailure(blockedFailure);
5411
+ return failureOutcome(
5412
+ failure,
5413
+ toRecordTimeLabelCloudFailureError(failure),
5414
+ {protocolError: true, blocked: true, identityRejectedCount: 0}
5415
+ );
5416
+ }
4280
5417
  const timestamp = now();
4281
5418
  const identityChecked = rtlQuarantineOperations(
4282
5419
  workspace,
@@ -4328,7 +5465,22 @@ export const createRecordTimeLabelSyncEngine = ({
4328
5465
  };
4329
5466
  }
4330
5467
  if (typeof cloud?.applyOperations !== 'function') {
4331
- return {success: false, reason: 'missing_cloud_apply_operations'};
5468
+ const failure = normalizeRecordTimeLabelCloudFailure({
5469
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5470
+ code: 'missing_cloud_apply_operations',
5471
+ reason: 'missing_cloud_apply_operations',
5472
+ message: 'missing_cloud_apply_operations'
5473
+ });
5474
+ return {
5475
+ success: false,
5476
+ failure,
5477
+ error: toRecordTimeLabelCloudFailureError(failure),
5478
+ class: failure.class,
5479
+ code: failure.code,
5480
+ reason: failure.reason,
5481
+ pendingCount: workspace.pendingOperations.length,
5482
+ identityRejectedCount
5483
+ };
4332
5484
  }
4333
5485
  const context = rtlSessionContext(captured, workspace, client);
4334
5486
  const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
@@ -4350,59 +5502,79 @@ export const createRecordTimeLabelSyncEngine = ({
4350
5502
  }, context);
4351
5503
  } catch (error) {
4352
5504
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4353
- if (!rtlRetryableEnvelopeError(error)) {
4354
- logger?.error?.('[RecordTimeLabelCore] durable sync failed', error);
4355
- return {success: false, error};
4356
- }
4357
- response = {
4358
- success: false,
4359
- error,
4360
- retryAfterMs: rtlRetryAfterMs(error)
4361
- };
5505
+ return settleApplyFailure({
5506
+ value: error,
5507
+ captured,
5508
+ context,
5509
+ ready,
5510
+ timestamp,
5511
+ identityRejectedCount
5512
+ });
4362
5513
  }
4363
5514
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4364
5515
 
4365
- if (!rtlEnvelopeSuccess(response)) {
4366
- if (!rtlRetryableEnvelopeError(response)) {
4367
- const error = response?.error instanceof Error
4368
- ? response.error
4369
- : new Error(response?.error?.message || response?.message || response?.code || 'sync_envelope_failed');
4370
- logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
4371
- return {success: false, error};
5516
+ const hasCanonicalFailure = Boolean(
5517
+ response &&
5518
+ typeof response === 'object' &&
5519
+ Object.prototype.hasOwnProperty.call(response, 'failure') &&
5520
+ response.failure != null
5521
+ );
5522
+ if (hasCanonicalFailure || !rtlEnvelopeSuccess(response)) {
5523
+ // An explicit operationResults array remains a supported legacy
5524
+ // response shape, but a canonical `failure` is authoritative even if a
5525
+ // gateway accidentally includes an empty/partial result array beside it.
5526
+ if (hasCanonicalFailure || !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults')) {
5527
+ return settleApplyFailure({
5528
+ value: response,
5529
+ captured,
5530
+ context,
5531
+ ready,
5532
+ timestamp,
5533
+ identityRejectedCount
5534
+ });
4372
5535
  }
4373
- const retryAfterMs = rtlRetryAfterMs(response);
4374
- const candidate = clone(workspace);
4375
- const byId = new Set(ready.map((operation) => operation.id));
4376
- candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
4377
- byId.has(operation.id)
4378
- ? makeRetryOperation(operation, response, retryAfterMs, timestamp)
4379
- : operation
4380
- ));
4381
- candidate.syncMeta = {
4382
- ...candidate.syncMeta,
4383
- lastSyncAttemptAt: timestamp,
4384
- lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
4385
- };
4386
- if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4387
- workspace = candidate;
4388
- notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
4389
- const retryAt = Math.min(...candidate.pendingOperations
4390
- .filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
4391
- .map((operation) => Number(operation.nextRetryAt)));
4392
- return {
4393
- success: false,
4394
- retryable: true,
4395
- retryAfterMs,
4396
- retryAt: Number.isFinite(retryAt) ? retryAt : null,
4397
- pendingCount: workspace.pendingOperations.length,
4398
- identityRejectedCount
4399
- };
4400
5536
  }
4401
5537
 
5538
+ const responseRevisionInfo = rtlExplicitRevisionInfo(response);
5539
+ if (!responseRevisionInfo.valid) {
5540
+ const protocolError = rtlInvalidRevisionError();
5541
+ const failure = normalizeRecordTimeLabelCloudFailure({
5542
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5543
+ code: protocolError.code,
5544
+ reason: protocolError.code,
5545
+ message: protocolError.message
5546
+ });
5547
+ const error = toRecordTimeLabelCloudFailureError(failure);
5548
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
5549
+ return persistTerminalFailureBlock({
5550
+ failure,
5551
+ error,
5552
+ captured,
5553
+ timestamp,
5554
+ identityRejectedCount,
5555
+ extra: {protocolError: true}
5556
+ });
5557
+ }
5558
+ const responseBaseline = rtlEnvelopePayload(response);
5559
+ const responseRevision = responseRevisionInfo.revision;
4402
5560
  const parsed = normalizeOperationResults(response, wireOperations);
4403
5561
  if (parsed.error) {
4404
- logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
4405
- return {success: false, error: parsed.error, protocolError: true};
5562
+ const failure = normalizeRecordTimeLabelCloudFailure({
5563
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5564
+ code: parsed.error.code || 'protocol_error',
5565
+ reason: parsed.error.reason || parsed.error.code || 'protocol_error',
5566
+ message: parsed.error.message || parsed.error.code || 'protocol_error'
5567
+ });
5568
+ const error = toRecordTimeLabelCloudFailureError(failure);
5569
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
5570
+ return persistTerminalFailureBlock({
5571
+ failure,
5572
+ error,
5573
+ captured,
5574
+ timestamp,
5575
+ identityRejectedCount,
5576
+ extra: {protocolError: true}
5577
+ });
4406
5578
  }
4407
5579
  let sawRetryable = false;
4408
5580
  for (const result of parsed.results) {
@@ -4412,7 +5584,20 @@ export const createRecordTimeLabelSyncEngine = ({
4412
5584
  const error = new Error('sync_protocol_fifo_retry_barrier_violation');
4413
5585
  error.code = 'sync_protocol_fifo_retry_barrier_violation';
4414
5586
  logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
4415
- return {success: false, error, protocolError: true};
5587
+ const failure = normalizeRecordTimeLabelCloudFailure({
5588
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5589
+ code: error.code,
5590
+ reason: error.code,
5591
+ message: error.message
5592
+ });
5593
+ return persistTerminalFailureBlock({
5594
+ failure,
5595
+ error,
5596
+ captured,
5597
+ timestamp,
5598
+ identityRejectedCount,
5599
+ extra: {protocolError: true}
5600
+ });
4416
5601
  }
4417
5602
  }
4418
5603
  const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
@@ -4440,50 +5625,124 @@ export const createRecordTimeLabelSyncEngine = ({
4440
5625
  nextPending.push(makeRetryOperation(operation, result, null, timestamp));
4441
5626
  } else if (result.status === 'rejected') {
4442
5627
  completedOperations.push({operation, status: result.status});
4443
- nextRejected[operation.id] = {
5628
+ Object.defineProperty(nextRejected, operation.id, {
5629
+ value: {
4444
5630
  id: operation.id,
4445
5631
  operation: clone(operation),
4446
5632
  status: 'rejected',
4447
5633
  reason: result.reason ?? result.error ?? result.code ?? 'rejected',
4448
5634
  rejectedAt: timestamp,
4449
5635
  response: rtlStripSessionTokens(result)
4450
- };
5636
+ },
5637
+ enumerable: true,
5638
+ configurable: true,
5639
+ writable: true
5640
+ });
4451
5641
  }
4452
5642
  }
4453
- appliedOperations.forEach((operation) => {
4454
- candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
4455
- applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
4456
- );
4457
- });
4458
- const responseRevision = Number(response.revision ?? response.remoteRevision);
4459
- const responseBaseline = rtlEnvelopePayload(response);
4460
5643
  const responseState = responseBaseline?.state ?? responseBaseline?.data;
4461
- if (rtlDurableIsObject(responseState)) {
4462
- applyRemoteBaseline(candidate, responseBaseline);
5644
+ const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
5645
+ Number.isSafeInteger(responseRevision)
5646
+ ? {...responseBaseline, revision: responseRevision}
5647
+ : responseBaseline;
5648
+ const currentBaselineRevision = Number(candidate.remoteBaseline.revision);
5649
+ const hasAllAppliedResults = parsed.results.length > 0 &&
5650
+ parsed.results.every((result) => result.status === 'applied');
5651
+ const responseIsStale = Number.isFinite(responseRevision) &&
5652
+ responseRevision <= currentBaselineRevision;
5653
+ const responseIsExactNext = Number.isFinite(responseRevision) &&
5654
+ responseRevision === currentBaselineRevision + 1;
5655
+ const responseIsForwardGap = Number.isFinite(responseRevision) &&
5656
+ responseRevision > currentBaselineRevision + 1;
5657
+
5658
+ const hasAuthoritativeFullBaseline = !responseIsStale &&
5659
+ rtlDurableIsObject(responseState) &&
5660
+ Number.isSafeInteger(responseRevision) &&
5661
+ responseRevision > currentBaselineRevision;
5662
+ if (
5663
+ !responseIsStale &&
5664
+ rejectedCount > 0 &&
5665
+ typeof cloud?.bootstrap !== 'function' &&
5666
+ !hasAuthoritativeFullBaseline
5667
+ ) {
5668
+ const failure = normalizeRecordTimeLabelCloudFailure({
5669
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
5670
+ code: 'recordtimelabel_bootstrap_required',
5671
+ reason: 'recordtimelabel_bootstrap_required',
5672
+ message: 'recordtimelabel_bootstrap_required'
5673
+ });
5674
+ return {
5675
+ success: false,
5676
+ bootstrapRequired: true,
5677
+ failure,
5678
+ error: toRecordTimeLabelCloudFailureError(failure),
5679
+ pendingCount: workspace.pendingOperations.length,
5680
+ identityRejectedCount
5681
+ };
5682
+ }
5683
+
5684
+ // A receipt replay can arrive after realtime has already promoted the
5685
+ // authoritative state. ACK completion is still durable, but replaying
5686
+ // its operations would apply settings/order changes a second time.
5687
+ let requiresAuthoritativeRecovery = (responseIsForwardGap && rejectedCount === 0) ||
5688
+ (responseIsExactNext && !hasAllAppliedResults && rejectedCount === 0);
5689
+ if (
5690
+ responseIsForwardGap &&
5691
+ rejectedCount === 0 &&
5692
+ typeof cloud?.bootstrap !== 'function' &&
5693
+ typeof cloud?.catchUp !== 'function'
5694
+ ) {
5695
+ const failure = normalizeRecordTimeLabelCloudFailure({
5696
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
5697
+ code: 'recordtimelabel_bootstrap_required',
5698
+ reason: 'recordtimelabel_bootstrap_required',
5699
+ message: 'recordtimelabel_bootstrap_required'
5700
+ });
5701
+ return {
5702
+ success: false,
5703
+ bootstrapRequired: true,
5704
+ failure,
5705
+ error: toRecordTimeLabelCloudFailureError(failure),
5706
+ pendingCount: workspace.pendingOperations.length,
5707
+ identityRejectedCount
5708
+ };
5709
+ }
5710
+ if (requiresAuthoritativeRecovery) {
5711
+ const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
5712
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
5713
+ applyRemoteBaseline(candidate, freshBaseline);
5714
+ } else if (!responseIsStale && rtlDurableIsObject(responseState)) {
5715
+ // A same-transaction full baseline is authoritative at the exact next
5716
+ // revision. Older receipt payloads are intentionally ignored above.
5717
+ applyRemoteBaseline(candidate, responseBaselineForApply);
4463
5718
  } else if (
5719
+ !responseIsStale &&
5720
+ responseIsExactNext &&
4464
5721
  rejectedCount === 0 &&
4465
5722
  !sawRetryable &&
4466
- appliedOperations.length === completedOperations.length &&
4467
- appliedOperations.length > 0 &&
4468
- Number.isFinite(responseRevision) &&
4469
- responseRevision === candidate.remoteBaseline.revision + 1
5723
+ hasAllAppliedResults
4470
5724
  ) {
4471
- // The v2 gateway ACK is intentionally compact and omits state. Promote
4472
- // only an all-applied transaction at exactly the next revision; any
4473
- // larger jump still needs an authoritative bootstrap baseline.
5725
+ // The compact v2 ACK omits state. Promote only a complete all-applied
5726
+ // transaction at exactly the next revision; a gap must be recovered.
5727
+ candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
5728
+ appliedOperations.reduce(
5729
+ (state, operation) => applyRecordTimeLabelOperation(state, operation),
5730
+ candidate.remoteBaseline.state
5731
+ )
5732
+ );
4474
5733
  candidate.remoteBaseline.revision = responseRevision;
4475
5734
  if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
4476
5735
  candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
4477
5736
  }
4478
- } else if (
4479
- rejectedCount === 0 &&
4480
- Number.isFinite(responseRevision) &&
4481
- responseRevision > candidate.remoteBaseline.revision + 1 &&
4482
- typeof cloud?.bootstrap === 'function'
4483
- ) {
4484
- const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
4485
- if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4486
- applyRemoteBaseline(candidate, freshBaseline);
5737
+ } else if (!responseIsStale && !Number.isFinite(responseRevision)) {
5738
+ // Legacy envelopes without a revision have no stale-receipt ordering
5739
+ // signal. Preserve their historical local promotion compatibility.
5740
+ candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
5741
+ appliedOperations.reduce(
5742
+ (state, operation) => applyRecordTimeLabelOperation(state, operation),
5743
+ candidate.remoteBaseline.state
5744
+ )
5745
+ );
4487
5746
  }
4488
5747
  candidate.pendingOperations = nextPending;
4489
5748
  candidate.rejectedOperations = nextRejected;
@@ -4654,7 +5913,14 @@ export const createRecordTimeLabelSyncEngine = ({
4654
5913
  const engine = {
4655
5914
  init() {
4656
5915
  return enqueue(async () => {
4657
- if (initialized && !hydrationRequired) return getSnapshot();
5916
+ const captured = capture();
5917
+ clearAuthTransitionLatchIfSessionChanged(captured);
5918
+ if (initialized && !hydrationRequired && workspaceMatchesSession(captured)) {
5919
+ return getSnapshot();
5920
+ }
5921
+ if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
5922
+ resetForSessionIdentity(captured);
5923
+ }
4658
5924
  return initialize();
4659
5925
  });
4660
5926
  },
@@ -4677,10 +5943,16 @@ export const createRecordTimeLabelSyncEngine = ({
4677
5943
 
4678
5944
  waitForRemoteReady() {
4679
5945
  if (destroyed) {
5946
+ const failure = normalizeRecordTimeLabelCloudFailure({
5947
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
5948
+ code: 'stale_session',
5949
+ reason: 'stale_session'
5950
+ });
4680
5951
  return Promise.resolve({
4681
5952
  success: false,
4682
5953
  stale: true,
4683
5954
  reason: 'stale_session',
5955
+ failure,
4684
5956
  generation: remoteReadyState.generation
4685
5957
  });
4686
5958
  }
@@ -4884,20 +6156,45 @@ const RTL_BULK_OPERATION_TYPES = new Set([
4884
6156
  ]);
4885
6157
  const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
4886
6158
 
4887
- const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
4888
- const rtlOperationRecordId = (operation = {}) => normalizeId(
4889
- operation?.payload?.recordId || operation?.payload?.id || operation?.payload?.record?.id
4890
- );
4891
- const rtlOperationFolderId = (operation = {}) => normalizeId(
4892
- operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
4893
- );
4894
- const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
4895
- operation?.payload?.trashEntryId || operation?.payload?.id
6159
+ export const isRecordTimeLabelSafeDocumentId = rtlIsSafeDocumentId;
6160
+ const rtlOwn = (object, key) => Boolean(
6161
+ object && Object.prototype.hasOwnProperty.call(object, key)
4896
6162
  );
6163
+
6164
+ const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
6165
+ const rtlFirstPresent = (object, keys) => {
6166
+ for (const key of keys) {
6167
+ if (Object.prototype.hasOwnProperty.call(object || {}, key)) return object[key];
6168
+ }
6169
+ return undefined;
6170
+ };
6171
+ const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payload, [
6172
+ 'recordId', 'id'
6173
+ ]) ?? operation?.payload?.record?.id ?? null;
6174
+ const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
6175
+ 'targetFolderId', 'folderId', 'id'
6176
+ ]) ?? operation?.payload?.folder?.id ?? operation?.payload?.record?.folderId ?? null;
6177
+ const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
6178
+ 'trashEntryId', 'id'
6179
+ ]) ?? null;
4897
6180
  const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
4898
6181
  encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
4899
6182
  );
6183
+ const rtlGetLifecycleTombstone = (documents, kind, entityId) => {
6184
+ const logicalId = `${kind}:${normalizeId(entityId)}`;
6185
+ const aliases = lifecycleTombstoneKeyAliases(logicalId);
6186
+ const collection = documents?.lifecycleTombstones || {};
6187
+ for (const alias of aliases) {
6188
+ if (rtlOwn(collection, alias)) return collection[alias];
6189
+ }
6190
+ const rootField = kind === 'folder' ? 'deletedFolderTombstones' : 'deletedRecordTombstones';
6191
+ const rootCollection = documents?.root?.[rootField] || {};
6192
+ return rtlOwn(rootCollection, normalizeId(entityId))
6193
+ ? rootCollection[normalizeId(entityId)]
6194
+ : null;
6195
+ };
4900
6196
  const rtlCloneDocuments = (documents = {}) => ({
6197
+ ...clone(documents || {}),
4901
6198
  root: clone(documents.root || null),
4902
6199
  records: clone(documents.records || {}),
4903
6200
  folders: clone(documents.folders || {}),
@@ -4906,6 +6203,24 @@ const rtlCloneDocuments = (documents = {}) => ({
4906
6203
  ops: {}
4907
6204
  });
4908
6205
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
6206
+ const rtlIsSafeOpaqueGroupId = (value) => (
6207
+ typeof value === 'string' && value.length > 0 &&
6208
+ new TextEncoder().encode(value).byteLength <= RECORD_TIMELABEL_SAFE_ID_MAX_BYTES &&
6209
+ !/[\u0000-\u001F\u007F-\u009F]/u.test(value)
6210
+ );
6211
+ const rtlHasUnsafeOrderId = (order, {opaqueGroup = false} = {}) => toArray(order).some((id) => (
6212
+ opaqueGroup ? !rtlIsSafeOpaqueGroupId(id) : !rtlIsSafeDocumentId(id)
6213
+ ));
6214
+ const rtlMergeOpaqueOrder = (...orders) => {
6215
+ const seen = new Set();
6216
+ const result = [];
6217
+ orders.flatMap((order) => toArray(order)).forEach((id) => {
6218
+ if (!rtlIsSafeOpaqueGroupId(id) || seen.has(id)) return;
6219
+ seen.add(id);
6220
+ result.push(id);
6221
+ });
6222
+ return result;
6223
+ };
4909
6224
  /**
4910
6225
  * Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
4911
6226
  * so the planner must not drop ids on a prefix guess either.
@@ -4929,12 +6244,13 @@ const rtlHasInvalidPayloadValue = (value, depth = 0) => {
4929
6244
  };
4930
6245
 
4931
6246
  const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
4932
- const rtlValidateIdList = (value, index, field, errors) => {
6247
+ const rtlValidateIdList = (value, index, field, errors, {opaqueGroup = false} = {}) => {
4933
6248
  if (!Array.isArray(value)) {
4934
6249
  errors.push(`operation_${index}_invalid_${field}`);
4935
6250
  return;
4936
6251
  }
4937
- if (value.some((id) => !normalizeId(id) || normalizeId(id).length > 512)) {
6252
+ const isValid = opaqueGroup ? rtlIsSafeOpaqueGroupId : rtlIsSafeDocumentId;
6253
+ if (value.some((id) => !isValid(id))) {
4938
6254
  errors.push(`operation_${index}_invalid_${field}`);
4939
6255
  }
4940
6256
  };
@@ -4954,8 +6270,13 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
4954
6270
  break;
4955
6271
  case OPERATION_TYPES.RECORD_MOVE:
4956
6272
  if (!recordId) errors.push(`operation_${index}_missing_record_id`);
4957
- if (!normalizeId(payload.targetFolderId || payload.folderId)) {
4958
- errors.push(`operation_${index}_missing_target_folder_id`);
6273
+ {
6274
+ const targetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6275
+ if (targetFolderId === undefined || targetFolderId === null) {
6276
+ errors.push(`operation_${index}_missing_target_folder_id`);
6277
+ } else if (!rtlIsSafeDocumentId(targetFolderId)) {
6278
+ errors.push(`operation_${index}_invalid_target_folder_id`);
6279
+ }
4959
6280
  }
4960
6281
  break;
4961
6282
  case OPERATION_TYPES.RECORD_DELETE:
@@ -5003,14 +6324,21 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
5003
6324
  rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
5004
6325
  break;
5005
6326
  case OPERATION_TYPES.GROUP_REORDER:
5006
- rtlValidateIdList(payload.groupOrder || payload.order, index, 'group_order', errors);
6327
+ rtlValidateIdList(
6328
+ payload.groupOrder || payload.order,
6329
+ index,
6330
+ 'group_order',
6331
+ errors,
6332
+ {opaqueGroup: true}
6333
+ );
5007
6334
  break;
5008
6335
  case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
5009
6336
  rtlValidateIdList(
5010
6337
  payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
5011
6338
  index,
5012
6339
  'expanded_groups',
5013
- errors
6340
+ errors,
6341
+ {opaqueGroup: true}
5014
6342
  );
5015
6343
  break;
5016
6344
  case OPERATION_TYPES.SETTINGS_UPDATE:
@@ -5029,9 +6357,10 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
5029
6357
  export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
5030
6358
  const errors = [];
5031
6359
  const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
5032
- toArray(body?.client?.capabilities).includes(
5033
- RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
5034
- );
6360
+ toArray(body?.client?.capabilities).some((capability) => (
6361
+ capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE ||
6362
+ capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1
6363
+ ));
5035
6364
  let requestBytes = 0;
5036
6365
  try {
5037
6366
  requestBytes = rtlByteLength(JSON.stringify(body));
@@ -5042,7 +6371,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
5042
6371
  if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
5043
6372
  errors.push('unsupported_protocol_version');
5044
6373
  }
5045
- if (!/^[A-Za-z0-9._:-]{8,160}$/.test(normalizeId(body.requestId))) {
6374
+ if (!/^[A-Za-z0-9._:-]{8,160}$/.test(body.requestId) ||
6375
+ !rtlIsSafeDocumentId(body.requestId)) {
5046
6376
  errors.push('invalid_request_id');
5047
6377
  }
5048
6378
  if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
@@ -5056,8 +6386,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
5056
6386
  }
5057
6387
  const operationIds = new Set();
5058
6388
  operations.forEach((operation, index) => {
5059
- const id = normalizeId(operation?.id);
5060
- if (!id || id.length > 200) errors.push(`operation_${index}_invalid_id`);
6389
+ const id = operation?.id;
6390
+ if (!rtlIsSafeDocumentId(id) || id.length > 200) errors.push(`operation_${index}_invalid_id`);
5061
6391
  if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
5062
6392
  operationIds.add(id);
5063
6393
  if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
@@ -5083,12 +6413,16 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
5083
6413
  if (!toFiniteTimestamp(operation?.createdAt)) {
5084
6414
  errors.push(`operation_${index}_invalid_created_at`);
5085
6415
  }
5086
- if (rtlOperationRecordId(operation).length > 512) {
6416
+ if (rtlOperationRecordId(operation) && !rtlIsSafeDocumentId(rtlOperationRecordId(operation))) {
5087
6417
  errors.push(`operation_${index}_invalid_record_id`);
5088
6418
  }
5089
- if (rtlOperationFolderId(operation).length > 512) {
6419
+ if (rtlOperationFolderId(operation) && !rtlIsSafeDocumentId(rtlOperationFolderId(operation))) {
5090
6420
  errors.push(`operation_${index}_invalid_folder_id`);
5091
6421
  }
6422
+ const trashEntryId = rtlOperationTrashEntryId(operation);
6423
+ if (trashEntryId && !rtlIsSafeDocumentId(trashEntryId)) {
6424
+ errors.push(`operation_${index}_invalid_trash_entry_id`);
6425
+ }
5092
6426
  });
5093
6427
 
5094
6428
  return {ok: errors.length === 0, errors, requestBytes, operations};
@@ -5124,13 +6458,35 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
5124
6458
  if (recordId) {
5125
6459
  recordIds.add(recordId);
5126
6460
  lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
6461
+ trashEntryIds.add(normalizeId(payload.trashEntryId) || `record:${recordId}`);
5127
6462
  }
5128
6463
  break;
5129
6464
  }
5130
6465
  case OPERATION_TYPES.RECORD_RESTORE:
6466
+ case OPERATION_TYPES.FOLDER_RESTORE:
5131
6467
  case OPERATION_TYPES.TRASH_PURGE: {
5132
6468
  const trashEntryId = rtlOperationTrashEntryId(operation);
5133
6469
  if (trashEntryId) trashEntryIds.add(trashEntryId);
6470
+ const explicitEntityId = normalizeId(
6471
+ payload.recordId || payload.folderId || payload.entityId
6472
+ );
6473
+ const inferredKind = operation.type === OPERATION_TYPES.FOLDER_RESTORE
6474
+ ? 'folder'
6475
+ : operation.type === OPERATION_TYPES.RECORD_RESTORE
6476
+ ? 'record'
6477
+ : null;
6478
+ let decodedTrashEntryId = trashEntryId;
6479
+ try { decodedTrashEntryId = decodeURIComponent(trashEntryId || ''); } catch { /* keep raw */ }
6480
+ const inferredEntityId = explicitEntityId || (
6481
+ inferredKind && decodedTrashEntryId?.startsWith(`${inferredKind}:`)
6482
+ ? normalizeId(decodedTrashEntryId.slice(inferredKind.length + 1))
6483
+ : ''
6484
+ );
6485
+ if (inferredKind && inferredEntityId) {
6486
+ lifecycleTombstoneIds.add(
6487
+ rtlLifecycleTombstoneDocumentId(inferredKind, inferredEntityId)
6488
+ );
6489
+ }
5134
6490
  break;
5135
6491
  }
5136
6492
  case OPERATION_TYPES.RECORD_REORDER:
@@ -5219,7 +6575,8 @@ const rtlApplyOperationToPartialDocuments = ({
5219
6575
 
5220
6576
  const rtlCanRestoreLocalFolder = (root, folder) => {
5221
6577
  if (!folder?.id) return false;
5222
- const tombstone = root?.deletedFolderTombstones?.[folder.id];
6578
+ const tombstones = root?.deletedFolderTombstones;
6579
+ const tombstone = rtlOwn(tombstones, folder.id) ? tombstones[folder.id] : null;
5223
6580
  return !tombstone;
5224
6581
  };
5225
6582
 
@@ -5230,8 +6587,9 @@ const rtlBuildLocalFolderDocument = (folder, now) => {
5230
6587
  }, {now}).folders[folder.id] || null;
5231
6588
  };
5232
6589
 
5233
- const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
5234
- if (folders[folderId]) return folders[folderId];
6590
+ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowContext = false}) => {
6591
+ if (rtlOwn(folders, folderId)) return folders[folderId];
6592
+ if (!allowContext) return null;
5235
6593
  const localFolder = toArray(localState?.folders)
5236
6594
  .find((folder) => folder?.id === folderId);
5237
6595
  if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
@@ -5246,7 +6604,19 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
5246
6604
 
5247
6605
  const rtlSetRoot = (target, source) => {
5248
6606
  Object.keys(target).forEach((key) => delete target[key]);
5249
- Object.assign(target, clone(source || {}));
6607
+ const next = clone(source || {});
6608
+ // `Object.assign` invokes the legacy `__proto__` setter on a normal target.
6609
+ // Define each own key as data instead, so malformed legacy/root documents
6610
+ // cannot mutate the candidate's prototype while the planner is rebuilding
6611
+ // its immutable document map.
6612
+ Reflect.ownKeys(next).forEach((key) => {
6613
+ Object.defineProperty(target, key, {
6614
+ value: next[key],
6615
+ enumerable: true,
6616
+ configurable: true,
6617
+ writable: true
6618
+ });
6619
+ });
5250
6620
  };
5251
6621
 
5252
6622
  /**
@@ -5257,22 +6627,48 @@ export const planFirestoreV2OperationChanges = ({
5257
6627
  operations,
5258
6628
  localState = {},
5259
6629
  now = Date.now(),
5260
- requireLifecycleGeneration = true
6630
+ requireLifecycleGeneration = true,
6631
+ plannerMode = RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT
5261
6632
  } = {}) => {
6633
+ const selectedPlannerMode = plannerMode === RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
6634
+ ? RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
6635
+ : RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
6636
+ const allowContext = selectedPlannerMode === RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
5262
6637
  const previousDocuments = rtlCloneDocuments(documents);
5263
6638
  const nextDocuments = rtlCloneDocuments(documents);
5264
6639
  const preservedExpandedGroups = previousDocuments.root &&
5265
6640
  Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
5266
6641
  ? clone(previousDocuments.root.expandedGroups)
5267
6642
  : undefined;
5268
- const normalizedLocalState = normalizeState(localState || {});
6643
+ // The deterministic planner deliberately does not inspect mutable local
6644
+ // context. Keep the old context-assisted behaviour behind an explicit
6645
+ // legacy mode so rolling clients can continue to use it while gateways
6646
+ // migrate to the stricter input contract.
6647
+ const normalizedLocalState = allowContext ? normalizeState(localState || {}) : {};
5269
6648
  const operationResults = [];
5270
6649
  let changed = false;
5271
6650
  if (!nextDocuments.root) {
5272
- return {requiresMigration: true, previousDocuments, nextDocuments, operationResults};
6651
+ return {
6652
+ requiresMigration: true,
6653
+ plannerMode: selectedPlannerMode,
6654
+ previousDocuments,
6655
+ nextDocuments,
6656
+ operationResults
6657
+ };
5273
6658
  }
5274
6659
 
5275
6660
  for (const operation of toArray(operations).filter(Boolean)) {
6661
+ const operationId = operation?.id;
6662
+ if (!rtlIsSafeDocumentId(operationId) || operationId.length > 200) {
6663
+ operationResults.push({
6664
+ id: operationId ?? null,
6665
+ type: operation?.type ?? null,
6666
+ applied: false,
6667
+ status: 'rejected',
6668
+ reason: 'invalid_document_id'
6669
+ });
6670
+ continue;
6671
+ }
5276
6672
  if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
5277
6673
  operationResults.push({
5278
6674
  id: operation.id || null,
@@ -5287,21 +6683,33 @@ export const planFirestoreV2OperationChanges = ({
5287
6683
  const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
5288
6684
  const recordId = rtlOperationRecordId(operation);
5289
6685
  const folderId = rtlOperationFolderId(operation);
6686
+ const trashEntryId = rtlOperationTrashEntryId(operation);
5290
6687
  let applied = null;
5291
6688
  let reason = null;
5292
6689
  let lifecycleTombstoneId = null;
5293
6690
 
5294
- switch (operation.type) {
6691
+ // Never use prototype-inherited values as document snapshots. Apart
6692
+ // from being incorrect for IDs such as "constructor", doing so would
6693
+ // allow user-controlled Firestore IDs to reach unsafe map writes.
6694
+ if ((recordId && !rtlIsSafeDocumentId(recordId)) ||
6695
+ (folderId && !rtlIsSafeDocumentId(folderId)) ||
6696
+ (trashEntryId && !rtlIsSafeDocumentId(trashEntryId))) {
6697
+ reason = 'invalid_document_id';
6698
+ }
6699
+
6700
+ switch (reason ? null : operation.type) {
5295
6701
  case OPERATION_TYPES.RECORD_CREATE: {
5296
6702
  if (!recordId) { reason = 'missing_record_id'; break; }
5297
- if (nextDocuments.records[recordId]) { reason = 'already_exists'; break; }
6703
+ if (rtlOwn(nextDocuments.records, recordId)) { reason = 'already_exists'; break; }
5298
6704
  const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
5299
6705
  applied = rtlApplyOperationToPartialDocuments({
5300
6706
  ...nextDocuments,
5301
6707
  operation: {...operation, payload: {...payload, folderId: targetFolderId}},
5302
6708
  now: operationNow
5303
6709
  });
5304
- const recordDocument = applied.documents.records[recordId];
6710
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6711
+ ? applied.documents.records[recordId]
6712
+ : null;
5305
6713
  if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
5306
6714
  const candidateRoot = clone(applied.documents.root);
5307
6715
  const candidateFolders = clone(nextDocuments.folders);
@@ -5310,7 +6718,8 @@ export const planFirestoreV2OperationChanges = ({
5310
6718
  folders: candidateFolders,
5311
6719
  localState: normalizedLocalState,
5312
6720
  folderId: targetFolderId,
5313
- now: operationNow
6721
+ now: operationNow,
6722
+ allowContext
5314
6723
  });
5315
6724
  if (!targetFolder) {
5316
6725
  reason = 'folder_not_found';
@@ -5324,13 +6733,17 @@ export const planFirestoreV2OperationChanges = ({
5324
6733
  break;
5325
6734
  }
5326
6735
  case OPERATION_TYPES.RECORD_UPDATE: {
5327
- const existing = nextDocuments.records[recordId];
6736
+ const existing = rtlOwn(nextDocuments.records, recordId)
6737
+ ? nextDocuments.records[recordId]
6738
+ : null;
5328
6739
  if (!recordId || !existing) {
5329
6740
  reason = recordId ? 'record_not_found' : 'missing_record_id';
5330
6741
  break;
5331
6742
  }
5332
6743
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5333
- const recordDocument = applied.documents.records[recordId];
6744
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6745
+ ? applied.documents.records[recordId]
6746
+ : null;
5334
6747
  if (!recordDocument) { reason = 'record_not_found'; break; }
5335
6748
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5336
6749
  nextDocuments.records[recordId] = recordDocument;
@@ -5338,19 +6751,32 @@ export const planFirestoreV2OperationChanges = ({
5338
6751
  break;
5339
6752
  }
5340
6753
  case OPERATION_TYPES.RECORD_MOVE: {
5341
- const existing = nextDocuments.records[recordId];
6754
+ const existing = rtlOwn(nextDocuments.records, recordId)
6755
+ ? nextDocuments.records[recordId]
6756
+ : null;
5342
6757
  if (!recordId || !existing) {
5343
6758
  reason = recordId ? 'record_not_found' : 'missing_record_id';
5344
6759
  break;
5345
6760
  }
6761
+ const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6762
+ if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
6763
+ reason = 'missing_target_folder_id';
6764
+ break;
6765
+ }
6766
+ if (!rtlIsSafeDocumentId(rawTargetFolderId)) {
6767
+ reason = 'invalid_document_id';
6768
+ break;
6769
+ }
5346
6770
  const sourceFolderId = safeFolderId(existing.folderId);
5347
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
6771
+ const targetFolderId = rawTargetFolderId;
5348
6772
  applied = rtlApplyOperationToPartialDocuments({
5349
6773
  ...nextDocuments,
5350
6774
  operation: {...operation, payload: {...payload, targetFolderId}},
5351
6775
  now: operationNow
5352
6776
  });
5353
- const recordDocument = applied.documents.records[recordId];
6777
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6778
+ ? applied.documents.records[recordId]
6779
+ : null;
5354
6780
  if (!recordDocument) { reason = 'record_not_found'; break; }
5355
6781
  const candidateRoot = clone(applied.documents.root);
5356
6782
  const candidateFolders = clone(nextDocuments.folders);
@@ -5359,10 +6785,11 @@ export const planFirestoreV2OperationChanges = ({
5359
6785
  folders: candidateFolders,
5360
6786
  localState: normalizedLocalState,
5361
6787
  folderId: targetFolderId,
5362
- now: operationNow
6788
+ now: operationNow,
6789
+ allowContext
5363
6790
  });
5364
6791
  if (!targetFolder) { reason = 'folder_not_found'; break; }
5365
- if (candidateFolders[sourceFolderId]) {
6792
+ if (rtlOwn(candidateFolders, sourceFolderId)) {
5366
6793
  candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
5367
6794
  candidateFolders[sourceFolderId].recordOrder
5368
6795
  ).filter((id) => id !== recordId);
@@ -5379,8 +6806,14 @@ export const planFirestoreV2OperationChanges = ({
5379
6806
  }
5380
6807
  case OPERATION_TYPES.RECORD_REORDER: {
5381
6808
  const targetFolderId = safeFolderId(payload.folderId);
5382
- const targetFolder = nextDocuments.folders[targetFolderId];
6809
+ const targetFolder = rtlOwn(nextDocuments.folders, targetFolderId)
6810
+ ? nextDocuments.folders[targetFolderId]
6811
+ : null;
5383
6812
  const requested = rtlPayloadOrder(operation, ['recordIds']);
6813
+ if (rtlHasUnsafeOrderId(requested)) {
6814
+ reason = 'invalid_document_id';
6815
+ break;
6816
+ }
5384
6817
  if (!targetFolder || requested.length === 0) {
5385
6818
  reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
5386
6819
  break;
@@ -5394,14 +6827,50 @@ export const planFirestoreV2OperationChanges = ({
5394
6827
  case OPERATION_TYPES.RECORD_DELETE: {
5395
6828
  if (!recordId) { reason = 'missing_record_id'; break; }
5396
6829
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
5397
- const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
6830
+ const existingRecord = rtlOwn(nextDocuments.records, recordId)
6831
+ ? nextDocuments.records[recordId]
6832
+ : null;
6833
+ const operationTrashEntryId = trashEntryId || `record:${recordId}`;
6834
+ const existingTrashEntry = rtlOwn(nextDocuments.trash, operationTrashEntryId)
6835
+ ? nextDocuments.trash[operationTrashEntryId]
6836
+ : null;
6837
+ const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
6838
+ if (!existingRecord) {
6839
+ const hasTrash = Boolean(existingTrashEntry);
6840
+ const hasTombstone = Boolean(existingTombstone);
6841
+ const trashGeneration = Number(existingTrashEntry?.lifecycleGeneration || 0);
6842
+ const tombstoneGeneration = Number(existingTombstone?.lifecycleGeneration || 0);
6843
+ const validTrashGeneration = Number.isSafeInteger(trashGeneration) && trashGeneration > 0;
6844
+ const validTombstoneGeneration = Number.isSafeInteger(tombstoneGeneration) &&
6845
+ tombstoneGeneration > 0;
6846
+ if (hasTrash && hasTombstone) {
6847
+ reason = validTrashGeneration && validTombstoneGeneration &&
6848
+ trashGeneration === tombstoneGeneration
6849
+ ? 'already_deleted'
6850
+ : 'lifecycle_conflict';
6851
+ } else if (hasTombstone) {
6852
+ reason = validTombstoneGeneration ? 'already_deleted' : 'lifecycle_conflict';
6853
+ } else if (hasTrash) {
6854
+ reason = 'lifecycle_conflict';
6855
+ } else {
6856
+ reason = 'record_not_found';
6857
+ }
6858
+ break;
6859
+ }
6860
+ if (existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
6861
+ Number(existingRecord.lifecycleGeneration || 0)) {
6862
+ reason = 'already_deleted';
6863
+ break;
6864
+ }
6865
+ const sourceFolderId = safeFolderId(existingRecord.folderId);
5398
6866
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5399
6867
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5400
- const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
5401
- const trashDocument = applied.documents.trash?.[trashEntryId];
5402
- if (trashDocument) nextDocuments.trash[trashEntryId] = trashDocument;
6868
+ const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
6869
+ ? applied.documents.trash[operationTrashEntryId]
6870
+ : null;
6871
+ if (trashDocument) nextDocuments.trash[operationTrashEntryId] = trashDocument;
5403
6872
  delete nextDocuments.records[recordId];
5404
- if (nextDocuments.folders[sourceFolderId]) {
6873
+ if (rtlOwn(nextDocuments.folders, sourceFolderId)) {
5405
6874
  nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
5406
6875
  nextDocuments.folders[sourceFolderId].recordOrder
5407
6876
  ).filter((id) => id !== recordId);
@@ -5410,8 +6879,10 @@ export const planFirestoreV2OperationChanges = ({
5410
6879
  break;
5411
6880
  }
5412
6881
  case OPERATION_TYPES.RECORD_RESTORE: {
5413
- const trashEntryId = rtlOperationTrashEntryId(operation);
5414
- const trashEntry = nextDocuments.trash[trashEntryId];
6882
+ const restoreTrashEntryId = trashEntryId;
6883
+ const trashEntry = rtlOwn(nextDocuments.trash, restoreTrashEntryId)
6884
+ ? nextDocuments.trash[restoreTrashEntryId]
6885
+ : null;
5415
6886
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
5416
6887
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
5417
6888
  if (requireLifecycleGeneration !== false && expectedGeneration === null) {
@@ -5427,11 +6898,23 @@ export const planFirestoreV2OperationChanges = ({
5427
6898
  break;
5428
6899
  }
5429
6900
  const restoredRecordId = normalizeId(trashEntry.entityId);
5430
- if (!restoredRecordId || nextDocuments.records[restoredRecordId]) {
6901
+ if (!restoredRecordId || rtlOwn(nextDocuments.records, restoredRecordId)) {
5431
6902
  reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
5432
6903
  break;
5433
6904
  }
5434
6905
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
6906
+ if (requireLifecycleGeneration !== false) {
6907
+ const lifecycleTombstone = rtlGetLifecycleTombstone(
6908
+ nextDocuments,
6909
+ 'record',
6910
+ restoredRecordId
6911
+ );
6912
+ if (!lifecycleTombstone ||
6913
+ Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
6914
+ reason = 'lifecycle_conflict';
6915
+ break;
6916
+ }
6917
+ }
5435
6918
  // Explicit legacy compatibility is planner-local. It does not change the
5436
6919
  // original operation, its wire bytes, or the strict durable reducer contract.
5437
6920
  const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
@@ -5442,11 +6925,16 @@ export const planFirestoreV2OperationChanges = ({
5442
6925
  operation: reducerOperation,
5443
6926
  now: operationNow
5444
6927
  });
5445
- const restoredRecord = applied.documents.records[restoredRecordId];
6928
+ const restoredRecord = rtlOwn(applied.documents.records, restoredRecordId)
6929
+ ? applied.documents.records[restoredRecordId]
6930
+ : null;
5446
6931
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
5447
6932
  const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
5448
- const targetFolder = nextDocuments.folders[targetFolderId] ||
5449
- nextDocuments.folders[DEFAULT_FOLDER_ID];
6933
+ const targetFolder = (rtlOwn(nextDocuments.folders, targetFolderId)
6934
+ ? nextDocuments.folders[targetFolderId]
6935
+ : null) || (rtlOwn(nextDocuments.folders, DEFAULT_FOLDER_ID)
6936
+ ? nextDocuments.folders[DEFAULT_FOLDER_ID]
6937
+ : null);
5450
6938
  if (!targetFolder) { reason = 'folder_not_found'; break; }
5451
6939
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5452
6940
  nextDocuments.records[restoredRecordId] = restoredRecord;
@@ -5462,8 +6950,10 @@ export const planFirestoreV2OperationChanges = ({
5462
6950
  break;
5463
6951
  }
5464
6952
  case OPERATION_TYPES.TRASH_PURGE: {
5465
- const trashEntryId = rtlOperationTrashEntryId(operation);
5466
- const trashEntry = nextDocuments.trash[trashEntryId];
6953
+ const purgeTrashEntryId = trashEntryId;
6954
+ const trashEntry = rtlOwn(nextDocuments.trash, purgeTrashEntryId)
6955
+ ? nextDocuments.trash[purgeTrashEntryId]
6956
+ : null;
5467
6957
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
5468
6958
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
5469
6959
  if (requireLifecycleGeneration !== false && expectedGeneration === null) {
@@ -5474,20 +6964,24 @@ export const planFirestoreV2OperationChanges = ({
5474
6964
  reason = 'lifecycle_conflict';
5475
6965
  break;
5476
6966
  }
5477
- delete nextDocuments.trash[trashEntryId];
6967
+ delete nextDocuments.trash[purgeTrashEntryId];
5478
6968
  changed = true;
5479
6969
  break;
5480
6970
  }
5481
6971
  case OPERATION_TYPES.FOLDER_CREATE:
5482
6972
  case OPERATION_TYPES.FOLDER_UPDATE: {
5483
6973
  if (!folderId) { reason = 'missing_folder_id'; break; }
5484
- if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders[folderId]) {
6974
+ if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !rtlOwn(nextDocuments.folders, folderId)) {
5485
6975
  reason = 'folder_not_found';
5486
6976
  break;
5487
6977
  }
5488
- const previousOrder = nextDocuments.folders[folderId]?.recordOrder || [];
6978
+ const previousOrder = rtlOwn(nextDocuments.folders, folderId)
6979
+ ? nextDocuments.folders[folderId]?.recordOrder || []
6980
+ : [];
5489
6981
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5490
- const folderDocument = applied.documents.folders[folderId];
6982
+ const folderDocument = rtlOwn(applied.documents.folders, folderId)
6983
+ ? applied.documents.folders[folderId]
6984
+ : null;
5491
6985
  if (!folderDocument) { reason = 'folder_not_found'; break; }
5492
6986
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5493
6987
  nextDocuments.folders[folderId] = {
@@ -5507,27 +7001,37 @@ export const planFirestoreV2OperationChanges = ({
5507
7001
  case OPERATION_TYPES.FOLDER_REORDER:
5508
7002
  case OPERATION_TYPES.GROUP_REORDER:
5509
7003
  case OPERATION_TYPES.SETTINGS_UPDATE: {
7004
+ if (operation.type === OPERATION_TYPES.FOLDER_REORDER &&
7005
+ rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['folderOrder', 'order']))) {
7006
+ reason = 'invalid_document_id';
7007
+ break;
7008
+ }
7009
+ if (operation.type === OPERATION_TYPES.GROUP_REORDER &&
7010
+ rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['groupOrder', 'order']), {opaqueGroup: true})) {
7011
+ reason = 'invalid_document_id';
7012
+ break;
7013
+ }
5510
7014
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5511
7015
  const appliedRoot = applied.documents.root;
5512
7016
  if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
5513
7017
  appliedRoot.folderOrder = rtlMergeOrder(
5514
7018
  rtlPayloadOrder(operation, ['folderOrder', 'order']),
5515
7019
  nextDocuments.root.folderOrder,
5516
- normalizedLocalState.folderOrder
7020
+ ...(allowContext ? [normalizedLocalState.folderOrder] : [])
5517
7021
  );
5518
7022
  } else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
5519
- appliedRoot.groupOrder = rtlNormalizeGroupOrder(rtlMergeOrder(
7023
+ appliedRoot.groupOrder = rtlMergeOpaqueOrder(
5520
7024
  rtlPayloadOrder(operation, ['groupOrder', 'order']),
5521
7025
  nextDocuments.root.groupOrder,
5522
- normalizedLocalState.groupOrder
5523
- ));
7026
+ ...(allowContext ? [normalizedLocalState.groupOrder] : [])
7027
+ );
5524
7028
  }
5525
7029
  rtlSetRoot(nextDocuments.root, appliedRoot);
5526
7030
  changed = true;
5527
7031
  break;
5528
7032
  }
5529
7033
  default:
5530
- reason = 'unsupported_operation';
7034
+ if (!reason) reason = 'unsupported_operation';
5531
7035
  }
5532
7036
  if (applied && !reason && lifecycleTombstoneId) {
5533
7037
  const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
@@ -5541,6 +7045,7 @@ export const planFirestoreV2OperationChanges = ({
5541
7045
  id: operation.id || null,
5542
7046
  type: operation.type || null,
5543
7047
  applied: !reason,
7048
+ ...(reason === 'already_deleted' ? {status: 'noop'} : {}),
5544
7049
  reason
5545
7050
  });
5546
7051
  }
@@ -5562,6 +7067,7 @@ export const planFirestoreV2OperationChanges = ({
5562
7067
  });
5563
7068
  return {
5564
7069
  requiresMigration: false,
7070
+ plannerMode: selectedPlannerMode,
5565
7071
  previousDocuments,
5566
7072
  nextDocuments,
5567
7073
  changes,
@@ -5569,6 +7075,19 @@ export const planFirestoreV2OperationChanges = ({
5569
7075
  };
5570
7076
  };
5571
7077
 
7078
+ // Dedicated API boundary for gateways: deterministic planning cannot inspect
7079
+ // mutable host/local context and always uses the immutable-ID contract.
7080
+ export const planRecordTimeLabelDeterministicOperationChanges = (options = {}) => (
7081
+ planFirestoreV2OperationChanges({
7082
+ ...options,
7083
+ plannerMode: RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC,
7084
+ localState: {}
7085
+ })
7086
+ );
7087
+ // Canonical name used by the Firestore gateway capability probe.
7088
+ export const planFirestoreV2DeterministicOperationChanges =
7089
+ planRecordTimeLabelDeterministicOperationChanges;
7090
+
5572
7091
  /**
5573
7092
  * Counts all billable writes before a transaction reserves quota.
5574
7093
  */
@@ -5759,10 +7278,15 @@ export default {
5759
7278
  isValidChannelName,
5760
7279
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
5761
7280
  RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
7281
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
5762
7282
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
7283
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
5763
7284
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
5764
7285
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
7286
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
7287
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
5765
7288
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
7289
+ isRecordTimeLabelOperationConflictFailure,
5766
7290
  normalizeRecordTimeLabelCloudFailure,
5767
7291
  toRecordTimeLabelCloudFailureError,
5768
7292
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
@@ -5774,6 +7298,11 @@ export default {
5774
7298
  RTL_MAX_REQUEST_BYTES,
5775
7299
  RTL_MAX_TARGET_WRITES,
5776
7300
  RTL_TRASH_RETENTION_MS,
7301
+ RECORD_TIMELABEL_PLANNER_MODES,
7302
+ RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
7303
+ isRecordTimeLabelSafeDocumentId,
7304
+ normalizeRecordTimeLabelImmutableId,
7305
+ normalizeRecordTimeLabelPlannerId,
5777
7306
  OPERATION_TYPES,
5778
7307
  RECORD_TIMELABEL_SYNC_MODES,
5779
7308
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
@@ -5812,6 +7341,8 @@ export default {
5812
7341
  extendFirestoreV2OperationReadPlanWithRecords,
5813
7342
  extendFirestoreV2OperationReadPlanWithTrash,
5814
7343
  planFirestoreV2OperationChanges,
7344
+ planRecordTimeLabelDeterministicOperationChanges,
7345
+ planFirestoreV2DeterministicOperationChanges,
5815
7346
  estimateFirestoreV2WriteUnits,
5816
7347
  normalizeRecordTimeLabelOperationResults,
5817
7348
  normalizeRecordTimeLabelEnvelopeResponse,
@@ -5822,6 +7353,7 @@ export default {
5822
7353
  flushPendingOperations,
5823
7354
  mergeRemoteStateIntoLocal,
5824
7355
  applyOperation,
7356
+ applyExplicitRecordUpdate,
5825
7357
  applyRecordTimeLabelOperation,
5826
7358
  createOperation,
5827
7359
  createRecordTimeLabelSyncEngine,