@recordtimelabel/core 0.6.2 → 0.6.4

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.4';
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 = normalizeId(payload.targetFolderId || payload.folderId);
1045
+ if (!recordId || !rtlIsSafeDocumentId(recordId) ||
1046
+ (rawTargetFolderId && !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
+ baseline.revision <= 0) {
3293
+ return false;
3294
+ }
3295
+ if (!rtlResumeIdentityMatches(
3296
+ baseline.changeCursor,
3297
+ captured,
3298
+ ownerUid,
3299
+ loaded.workspaceEpoch
3300
+ )) {
3301
+ return false;
3302
+ }
3303
+ if (loaded.syncMeta !== undefined &&
3304
+ !rtlDurableIsObject(loaded.syncMeta)) return false;
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,12 +3338,26 @@ 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;
3360
+ if (rtlIsCredentialKey(key)) return;
3044
3361
  result[key] = rtlStripSessionTokens(entry);
3045
3362
  });
3046
3363
  return result;
@@ -3056,35 +3373,6 @@ const rtlNormalizeOperation = (operation = {}, {
3056
3373
  const input = operation && typeof operation === 'object' ? operation : {};
3057
3374
  const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
3058
3375
  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
3376
 
3089
3377
  const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
3090
3378
  const resolvedNow = Number.isFinite(operationNow)
@@ -3099,7 +3387,9 @@ const rtlNormalizeOperation = (operation = {}, {
3099
3387
 
3100
3388
  return {
3101
3389
  ...rtlStripSessionTokens(input),
3102
- id: normalizeId(input.id) || generatedId,
3390
+ // Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
3391
+ // invalid whitespace or hostile ID instead of receiving a silently rewritten one.
3392
+ id: Object.prototype.hasOwnProperty.call(input, 'id') ? input.id : generatedId,
3103
3393
  type: typeof input.type === 'string' ? input.type.trim() : input.type,
3104
3394
  payload: normalizedPayload,
3105
3395
  clientId: resolvedClientId,
@@ -3132,10 +3422,12 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
3132
3422
  const baseline = rtlDurableIsObject(value) ? value : {};
3133
3423
  return {
3134
3424
  state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
3135
- revision: Math.max(0, rtlToFiniteNumber(baseline.revision, 0)),
3425
+ revision: rtlRawNonNegativeSafeInteger(baseline.revision) ? baseline.revision : 0,
3136
3426
  changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
3137
3427
  ? null
3138
- : String(baseline.changeCursor)
3428
+ : rtlDurableIsObject(baseline.changeCursor)
3429
+ ? clone(baseline.changeCursor)
3430
+ : String(baseline.changeCursor)
3139
3431
  };
3140
3432
  };
3141
3433
 
@@ -3212,6 +3504,29 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3212
3504
  };
3213
3505
 
3214
3506
  const rtlOperationIdentityReason = (operation, workspace) => {
3507
+ const candidateIds = [
3508
+ operation?.id,
3509
+ operation?.payload?.recordId,
3510
+ operation?.payload?.folderId,
3511
+ operation?.payload?.targetFolderId,
3512
+ operation?.payload?.trashEntryId,
3513
+ operation?.payload?.record?.id,
3514
+ operation?.payload?.folder?.id,
3515
+ ...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
3516
+ ...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
3517
+ ];
3518
+ if (candidateIds.some((value) => (
3519
+ value !== undefined && value !== null &&
3520
+ !rtlIsSafeDocumentId(value)
3521
+ ))) {
3522
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
3523
+ }
3524
+ const groupOrder = Array.isArray(operation?.payload?.groupOrder)
3525
+ ? operation.payload.groupOrder
3526
+ : [];
3527
+ if (groupOrder.some((value) => !rtlIsSafeOpaqueGroupId(value))) {
3528
+ return RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.INVALID_DOCUMENT_ID;
3529
+ }
3215
3530
  const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
3216
3531
  const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
3217
3532
  if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
@@ -3369,6 +3684,11 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3369
3684
  Object.prototype.hasOwnProperty.call(current, 'sessionToken')
3370
3685
  ? current.sessionToken
3371
3686
  : null;
3687
+ // Durable resume proofs use an explicit, non-credential auth binding. The
3688
+ // transport/session token remains an in-memory callback fence only.
3689
+ const authSessionBinding = current && typeof current === 'object'
3690
+ ? (current.authSessionBinding ?? current.authSessionId ?? current.authSessionKey ?? null)
3691
+ : null;
3372
3692
  const uid = rtlNormalizeUid(current && typeof current === 'object'
3373
3693
  ? (current.uid ?? current.ownerUid)
3374
3694
  : null);
@@ -3380,6 +3700,9 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3380
3700
  current,
3381
3701
  hasSession: typeof session?.current === 'function',
3382
3702
  sessionToken,
3703
+ authSessionBinding: typeof authSessionBinding === 'string' && authSessionBinding.trim()
3704
+ ? authSessionBinding
3705
+ : null,
3383
3706
  uid,
3384
3707
  workspaceEpoch: hasEpoch
3385
3708
  ? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
@@ -3405,18 +3728,54 @@ const rtlSessionContext = (captured, workspace, client) => ({
3405
3728
  });
3406
3729
 
3407
3730
  const rtlEnvelopePayload = (value) => {
3731
+ const normalizeRevisionAlias = (candidate) => {
3732
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
3733
+ return candidate;
3734
+ }
3735
+ if (
3736
+ !Object.prototype.hasOwnProperty.call(candidate, 'revision') &&
3737
+ Object.prototype.hasOwnProperty.call(candidate, 'remoteRevision')
3738
+ ) {
3739
+ return {...candidate, revision: candidate.remoteRevision};
3740
+ }
3741
+ return candidate;
3742
+ };
3408
3743
  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;
3744
+ if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
3745
+ if (value.baseline) return normalizeRevisionAlias(value.baseline);
3746
+ // A canonical failure may carry an authoritative baseline for an
3747
+ // operation-id conflict. It must win over compatibility state fields
3748
+ // left beside `failure`; otherwise a stale outer state can be applied
3749
+ // while the canonical failure is being handled.
3750
+ if (value.failure && typeof value.failure === 'object') {
3751
+ const nested = rtlEnvelopePayload(value.failure);
3752
+ if (nested) return nested;
3753
+ }
3754
+ if (
3755
+ value.state !== undefined ||
3756
+ value.data !== undefined ||
3757
+ value.revision !== undefined ||
3758
+ value.remoteRevision !== undefined ||
3759
+ value.changeCursor !== undefined
3760
+ ) {
3761
+ return normalizeRevisionAlias(value);
3762
+ }
3763
+ if (value.snapshot && typeof value.snapshot === 'object') {
3764
+ return normalizeRevisionAlias(value.snapshot);
3765
+ }
3766
+ if (value.error && typeof value.error === 'object') {
3767
+ const nested = rtlEnvelopePayload(value.error);
3768
+ if (nested) return nested;
3413
3769
  }
3414
- if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
3415
3770
  // Adapters migrating from the v1 cloud port may still return a raw
3416
3771
  // normalized state. Treat the presence of state-owned keys as an implicit
3417
3772
  // baseline with the persisted revision.
3418
3773
  if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
3419
- return {state: value, revision: value.revision, changeCursor: value.changeCursor};
3774
+ return {
3775
+ state: value,
3776
+ revision: value.revision ?? value.remoteRevision,
3777
+ changeCursor: value.changeCursor
3778
+ };
3420
3779
  }
3421
3780
  }
3422
3781
  return null;
@@ -3424,44 +3783,63 @@ const rtlEnvelopePayload = (value) => {
3424
3783
 
3425
3784
  const rtlEnvelopeSuccess = (value) => {
3426
3785
  if (!value || typeof value !== 'object') return false;
3427
- if (value.success === false || value.ok === false || value.error || value.code === 'error') return false;
3786
+ if (
3787
+ value.success === false ||
3788
+ value.ok === false ||
3789
+ value.error ||
3790
+ value.failure ||
3791
+ value.code === 'error'
3792
+ ) return false;
3428
3793
  return value.success === true || value.ok === true || (
3429
3794
  value.success === undefined && value.ok === undefined && !value.error
3430
3795
  );
3431
3796
  };
3432
3797
 
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
- );
3798
+ const rtlExplicitRevisionInfo = (value) => {
3799
+ const revisions = [];
3800
+ let invalid = false;
3801
+ const visited = new Set();
3802
+ const add = (source, key) => {
3803
+ if (!source || typeof source !== 'object' ||
3804
+ !Object.prototype.hasOwnProperty.call(source, key)) return;
3805
+ const raw = source[key];
3806
+ const numeric = typeof raw === 'number' ||
3807
+ (typeof raw === 'string' && raw.trim() !== '')
3808
+ ? Number(raw)
3809
+ : NaN;
3810
+ if (!Number.isSafeInteger(numeric) || numeric < 0) {
3811
+ invalid = true;
3812
+ return;
3813
+ }
3814
+ revisions.push(numeric);
3815
+ };
3816
+ const visit = (source, depth = 0) => {
3817
+ if (!source || typeof source !== 'object' || visited.has(source) || depth > 8) return;
3818
+ visited.add(source);
3819
+ add(source, 'revision');
3820
+ add(source, 'remoteRevision');
3821
+ [
3822
+ source.remoteBaseline,
3823
+ source.baseline,
3824
+ source.snapshot,
3825
+ source.failure,
3826
+ source.error
3827
+ ].forEach((nested) => visit(nested, depth + 1));
3828
+ };
3829
+ visit(value);
3830
+ const unique = [...new Set(revisions)];
3831
+ if (unique.length > 1) invalid = true;
3832
+ return {
3833
+ present: revisions.length > 0 || invalid,
3834
+ valid: !invalid,
3835
+ revision: unique[0] ?? null
3836
+ };
3457
3837
  };
3458
3838
 
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;
3839
+ const rtlInvalidRevisionError = () => {
3840
+ const error = new Error('sync_protocol_invalid_revision');
3841
+ error.code = 'sync_protocol_invalid_revision';
3842
+ return error;
3465
3843
  };
3466
3844
 
3467
3845
  const rtlResultOperationId = (result = {}) => normalizeId(
@@ -3569,6 +3947,12 @@ export const createRecordTimeLabelSyncEngine = ({
3569
3947
 
3570
3948
  const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
3571
3949
 
3950
+ const workspaceMatchesSession = (captured) => {
3951
+ if (!captured?.hasSession) return true;
3952
+ return rtlNormalizeUid(workspace.ownerUid) === captured.uid &&
3953
+ Number(workspace.workspaceEpoch) === Number(captured.workspaceEpoch);
3954
+ };
3955
+
3572
3956
  const isCurrent = async (captured) => {
3573
3957
  if (destroyed || !captured) return false;
3574
3958
  if (typeof session?.isCurrent === 'function') {
@@ -3595,11 +3979,23 @@ export const createRecordTimeLabelSyncEngine = ({
3595
3979
 
3596
3980
  const persist = async (candidate, captured) => {
3597
3981
  if (!(await isCurrent(captured))) return false;
3982
+ const cursor = candidate?.remoteBaseline?.changeCursor;
3983
+ if (cursor !== null && cursor !== undefined) {
3984
+ const durableCursor = rtlDurableIsObject(cursor) ? clone(cursor) : {value: String(cursor)};
3985
+ candidate.remoteBaseline.changeCursor = {
3986
+ ...durableCursor,
3987
+ ownerUid: captured?.uid ?? null,
3988
+ uid: captured?.uid ?? null,
3989
+ workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
3990
+ authSessionBinding: captured?.authSessionBinding ?? null
3991
+ };
3992
+ }
3993
+ const durableCandidate = rtlStripSessionTokens(candidate);
3598
3994
  // The second argument is an optional adapter-side fence. The public
3599
3995
  // StoragePort remains compatible with save(workspace); adapters that can
3600
3996
  // enforce an atomic session/epoch check should consume this context before
3601
3997
  // mutating durable storage.
3602
- await storage.save(clone(candidate), {
3998
+ await storage.save(clone(durableCandidate), {
3603
3999
  sessionToken: captured?.sessionToken ?? null,
3604
4000
  uid: captured?.uid ?? null,
3605
4001
  ownerUid: captured?.uid ?? null,
@@ -3618,7 +4014,9 @@ export const createRecordTimeLabelSyncEngine = ({
3618
4014
  }
3619
4015
  const nextBaseline = rtlNormalizeRemoteBaseline({
3620
4016
  state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
3621
- revision: Number.isFinite(revision) ? revision : candidate.remoteBaseline.revision,
4017
+ revision: Number.isSafeInteger(revision) && revision >= 0
4018
+ ? revision
4019
+ : candidate.remoteBaseline.revision,
3622
4020
  changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
3623
4021
  ? remote.changeCursor
3624
4022
  : candidate.remoteBaseline.changeCursor
@@ -3638,18 +4036,18 @@ export const createRecordTimeLabelSyncEngine = ({
3638
4036
 
3639
4037
  const normalizeBootstrapResponse = (response) => {
3640
4038
  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
- });
4039
+ const source = response && typeof response === 'object' ? response : {
4040
+ message: 'recordtimelabel_bootstrap_failed',
4041
+ code: 'recordtimelabel_bootstrap_failed',
4042
+ reason: 'recordtimelabel_bootstrap_failed'
4043
+ };
4044
+ throw toRecordTimeLabelCloudFailureError(source);
3648
4045
  }
3649
4046
  const remote = rtlEnvelopePayload(response);
3650
4047
  const revision = Number(remote?.revision);
3651
4048
  const state = remote?.state ?? remote?.data;
3652
- if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
4049
+ if (!remote || !rtlDurableIsObject(state) ||
4050
+ !Number.isSafeInteger(revision) || revision < 0) {
3653
4051
  throw toRecordTimeLabelCloudFailureError({
3654
4052
  code: 'recordtimelabel_invalid_bootstrap_response',
3655
4053
  reason: 'recordtimelabel_invalid_bootstrap_response',
@@ -3703,7 +4101,7 @@ export const createRecordTimeLabelSyncEngine = ({
3703
4101
  const rethrowClassifiedCloudFailure = (captured, error) => {
3704
4102
  const failure = normalizeRecordTimeLabelCloudFailure(error);
3705
4103
  rememberAuthTransitionFailure(captured, failure);
3706
- if (error?.class === failure.class) throw error;
4104
+ if (error instanceof Error && error?.class === failure.class) throw error;
3707
4105
  throw toRecordTimeLabelCloudFailureError(failure);
3708
4106
  };
3709
4107
 
@@ -3714,6 +4112,12 @@ export const createRecordTimeLabelSyncEngine = ({
3714
4112
  error.code = 'recordtimelabel_bootstrap_revision_behind_required';
3715
4113
  error.bootstrapRevision = Number(baseline?.revision);
3716
4114
  error.minimumRevision = minimum;
4115
+ error.failure = normalizeRecordTimeLabelCloudFailure({
4116
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4117
+ code: error.code,
4118
+ reason: error.code,
4119
+ message: error.message
4120
+ });
3717
4121
  throw error;
3718
4122
  }
3719
4123
  return baseline;
@@ -3939,13 +4343,366 @@ export const createRecordTimeLabelSyncEngine = ({
3939
4343
  nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
3940
4344
  });
3941
4345
 
4346
+ const failureOutcome = (failure, error, extra = {}) => ({
4347
+ success: false,
4348
+ failure: clone(failure),
4349
+ error,
4350
+ class: failure.class,
4351
+ status: failure.status,
4352
+ code: failure.code,
4353
+ reason: failure.reason,
4354
+ retryable: failure.retryable,
4355
+ retryAfterMs: failure.retryAfterMs,
4356
+ bootstrapRequired: failure.bootstrapRequired,
4357
+ pendingCount: workspace.pendingOperations.length,
4358
+ ...extra
4359
+ });
4360
+
4361
+ const persistTerminalFailureBlock = async ({
4362
+ failure,
4363
+ error,
4364
+ captured,
4365
+ timestamp,
4366
+ identityRejectedCount,
4367
+ extra = {}
4368
+ }) => {
4369
+ const candidate = clone(workspace);
4370
+ candidate.syncMeta = {
4371
+ ...candidate.syncMeta,
4372
+ lastSyncAttemptAt: timestamp,
4373
+ lastSyncError: failure.message || failure.code || 'terminal',
4374
+ lastSyncFailure: clone(failure),
4375
+ terminalFailureBlock: clone(failure)
4376
+ };
4377
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4378
+ workspace = candidate;
4379
+ return failureOutcome(failure, error, {
4380
+ protocolError: true,
4381
+ blocked: true,
4382
+ identityRejectedCount,
4383
+ ...extra
4384
+ });
4385
+ };
4386
+
4387
+ const rebaseAfterBootstrapRequired = async ({
4388
+ failure,
4389
+ error,
4390
+ captured,
4391
+ context,
4392
+ timestamp,
4393
+ identityRejectedCount
4394
+ }) => {
4395
+ hydrationRequired = true;
4396
+ if (typeof cloud?.bootstrap !== 'function') {
4397
+ return failureOutcome(failure, error, {
4398
+ bootstrapRequired: true,
4399
+ hydrationBarrier: true,
4400
+ identityRejectedCount
4401
+ });
4402
+ }
4403
+ let baseline;
4404
+ try {
4405
+ baseline = normalizeBootstrapResponse(await cloud.bootstrap(
4406
+ context,
4407
+ bootstrapAttemptOptions('apply-failure-rebase')
4408
+ ));
4409
+ } catch (bootstrapError) {
4410
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4411
+ rethrowClassifiedCloudFailure(captured, bootstrapError);
4412
+ }
4413
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4414
+ const candidate = clone(workspace);
4415
+ const applied = applyRemoteBaseline(candidate, baseline);
4416
+ if (applied.stale) {
4417
+ const staleFailure = normalizeRecordTimeLabelCloudFailure({
4418
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4419
+ code: 'bootstrap_revision_behind_required',
4420
+ reason: 'bootstrap_revision_behind_required',
4421
+ message: 'bootstrap_revision_behind_required'
4422
+ });
4423
+ return failureOutcome(staleFailure, toRecordTimeLabelCloudFailureError(staleFailure), {
4424
+ bootstrapRequired: true,
4425
+ hydrationBarrier: true,
4426
+ identityRejectedCount
4427
+ });
4428
+ }
4429
+ candidate.syncMeta = {...candidate.syncMeta};
4430
+ delete candidate.syncMeta.terminalFailureBlock;
4431
+ candidate.syncMeta.lastSyncFailure = clone(failure);
4432
+ candidate.syncMeta.lastSyncAttemptAt = timestamp;
4433
+ candidate.syncMeta.lastSyncError = null;
4434
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4435
+ workspace = candidate;
4436
+ hydrationRequired = false;
4437
+ notify({type: 'hydration_rebased', failure: clone(failure)});
4438
+ return failureOutcome(failure, error, {
4439
+ bootstrapRequired: true,
4440
+ hydrationBarrier: false,
4441
+ rebaseCompleted: true,
4442
+ identityRejectedCount
4443
+ });
4444
+ };
4445
+
4446
+ const isAuthoritativeFailureBaseline = (value, minimumRevision, verifiedRevision = null) => {
4447
+ const remote = rtlEnvelopePayload(value);
4448
+ const revision = Number(remote?.revision ?? remote?.remoteRevision);
4449
+ const minimum = Number(minimumRevision);
4450
+ const verified = Number(verifiedRevision);
4451
+ return Boolean(
4452
+ remote &&
4453
+ rtlDurableIsObject(remote.state ?? remote.data) &&
4454
+ Number.isSafeInteger(revision) &&
4455
+ revision >= (Number.isFinite(minimum) ? minimum : 0) &&
4456
+ (verifiedRevision === null || verifiedRevision === undefined ||
4457
+ (Number.isSafeInteger(verified) && revision === verified))
4458
+ );
4459
+ };
4460
+
4461
+ const settleApplyFailure = async ({
4462
+ value,
4463
+ captured,
4464
+ context,
4465
+ ready,
4466
+ timestamp,
4467
+ identityRejectedCount
4468
+ }) => {
4469
+ const failure = normalizeRecordTimeLabelCloudFailure(value);
4470
+ const error = toRecordTimeLabelCloudFailureError(failure);
4471
+ const responseRevisionInfo = rtlExplicitRevisionInfo(value);
4472
+
4473
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION) {
4474
+ // A stale response is never an acknowledgement for the captured
4475
+ // workspace. In particular, do not persist retry metadata or notify
4476
+ // listeners that may already be observing a newer session.
4477
+ return failureOutcome(failure, error, {
4478
+ stale: true,
4479
+ reason: 'stale_session',
4480
+ identityRejectedCount
4481
+ });
4482
+ }
4483
+
4484
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
4485
+ rememberAuthTransitionFailure(captured, failure);
4486
+ throw error;
4487
+ }
4488
+
4489
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
4490
+ return rebaseAfterBootstrapRequired({
4491
+ failure,
4492
+ error,
4493
+ captured,
4494
+ context,
4495
+ timestamp,
4496
+ identityRejectedCount
4497
+ });
4498
+ }
4499
+
4500
+ if (failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT) {
4501
+ const candidate = clone(workspace);
4502
+ const readyIds = new Set(ready.map((operation) => operation.id));
4503
+ candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
4504
+ readyIds.has(operation.id)
4505
+ ? makeRetryOperation(operation, failure, failure.retryAfterMs, timestamp)
4506
+ : operation
4507
+ ));
4508
+ candidate.syncMeta = {
4509
+ ...candidate.syncMeta,
4510
+ lastSyncAttemptAt: timestamp,
4511
+ lastSyncError: failure.message || failure.reason || failure.code || 'retryable',
4512
+ lastSyncFailure: clone(failure)
4513
+ };
4514
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4515
+ workspace = candidate;
4516
+ notify({
4517
+ type: 'sync_retry',
4518
+ reason: failure.code || failure.reason || 'retryable',
4519
+ failure: clone(failure)
4520
+ });
4521
+ const retryAt = Math.min(...candidate.pendingOperations
4522
+ .filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
4523
+ .map((operation) => Number(operation.nextRetryAt)));
4524
+ return failureOutcome(failure, error, {
4525
+ retryable: true,
4526
+ retryAfterMs: failure.retryAfterMs,
4527
+ retryAt: Number.isFinite(retryAt) ? retryAt : null,
4528
+ pendingCount: workspace.pendingOperations.length,
4529
+ identityRejectedCount
4530
+ });
4531
+ }
4532
+
4533
+ // Only an explicit request/operation-id conflict is eligible for the
4534
+ // existing rejection-rebase path. A bulk operation is deliberately
4535
+ // excluded: quarantining one entry from a bulk job would acknowledge a
4536
+ // request whose server-side transaction did not run.
4537
+ const isBulkBatch = ready.some((operation) => (
4538
+ typeof RTL_BULK_OPERATION_TYPES !== 'undefined' &&
4539
+ RTL_BULK_OPERATION_TYPES.has(operation?.type)
4540
+ ));
4541
+ const isOperationConflict = isRecordTimeLabelOperationConflictFailure(value);
4542
+ if (
4543
+ failure.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL &&
4544
+ isOperationConflict &&
4545
+ !isBulkBatch
4546
+ ) {
4547
+ // A conflict rebase is only safe when every revision alias in the
4548
+ // response agrees. Reject malformed/conflicting revision metadata
4549
+ // before calling bootstrap or changing the ready outbox.
4550
+ if (!responseRevisionInfo.valid) {
4551
+ const protocolError = rtlInvalidRevisionError();
4552
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', protocolError);
4553
+ return persistTerminalFailureBlock({
4554
+ failure,
4555
+ error: protocolError,
4556
+ captured,
4557
+ timestamp,
4558
+ identityRejectedCount,
4559
+ extra: {protocolError: true}
4560
+ });
4561
+ }
4562
+ let freshBaseline = null;
4563
+ const currentRevisionValue = Number(workspace.remoteBaseline.revision);
4564
+ const currentRevision = Number.isSafeInteger(currentRevisionValue) && currentRevisionValue >= 0
4565
+ ? currentRevisionValue
4566
+ : 0;
4567
+ const requiredRevision = Math.max(
4568
+ currentRevision,
4569
+ responseRevisionInfo.revision ?? 0
4570
+ );
4571
+ if (typeof cloud?.bootstrap === 'function') {
4572
+ try {
4573
+ freshBaseline = requireBootstrapRevision(
4574
+ normalizeBootstrapResponse(await cloud.bootstrap(
4575
+ context,
4576
+ bootstrapAttemptOptions('rejection-rebase')
4577
+ )),
4578
+ requiredRevision
4579
+ );
4580
+ } catch (bootstrapError) {
4581
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4582
+ rethrowClassifiedCloudFailure(captured, bootstrapError);
4583
+ }
4584
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4585
+ } else if (isAuthoritativeFailureBaseline(
4586
+ value,
4587
+ requiredRevision,
4588
+ responseRevisionInfo.revision
4589
+ )) {
4590
+ freshBaseline = rtlEnvelopePayload(value);
4591
+ }
4592
+
4593
+ // Preserve 1A1's fail-closed contract when no fresh/authoritative
4594
+ // baseline is available: nothing, including outbox metadata, changes.
4595
+ if (!freshBaseline) {
4596
+ return persistTerminalFailureBlock({
4597
+ failure,
4598
+ error,
4599
+ captured,
4600
+ timestamp,
4601
+ identityRejectedCount
4602
+ });
4603
+ }
4604
+
4605
+ const candidate = clone(workspace);
4606
+ applyRemoteBaseline(candidate, freshBaseline);
4607
+ const readyIds = new Set(ready.map((operation) => operation.id));
4608
+ const rejectionResponse = rtlStripSessionTokens({
4609
+ ...((value && typeof value === 'object') ? value : {}),
4610
+ failure: clone(failure)
4611
+ });
4612
+ const nextRejected = {...candidate.rejectedOperations};
4613
+ const rejectedResults = [];
4614
+ const completedOperations = [];
4615
+ ready.forEach((operation) => {
4616
+ const reason = failure.reason || failure.code || 'operation_id_conflict';
4617
+ nextRejected[operation.id] = {
4618
+ id: operation.id,
4619
+ operation: clone(operation),
4620
+ status: 'rejected',
4621
+ reason,
4622
+ rejectedAt: timestamp,
4623
+ response: rejectionResponse
4624
+ };
4625
+ rejectedResults.push({
4626
+ id: operation.id,
4627
+ operationId: operation.id,
4628
+ status: 'rejected',
4629
+ applied: false,
4630
+ reason,
4631
+ code: failure.code,
4632
+ class: failure.class,
4633
+ statusCode: failure.status
4634
+ });
4635
+ completedOperations.push({operation, status: 'rejected'});
4636
+ });
4637
+ candidate.pendingOperations = candidate.pendingOperations.filter((operation) => (
4638
+ !readyIds.has(operation.id)
4639
+ ));
4640
+ candidate.rejectedOperations = nextRejected;
4641
+ candidate.syncMeta = {
4642
+ ...candidate.syncMeta,
4643
+ lastSyncedAt: timestamp,
4644
+ lastSyncAttemptAt: timestamp,
4645
+ lastSyncError: null,
4646
+ lastSyncFailure: clone(failure),
4647
+ lastAcknowledgedRevision: Math.max(
4648
+ rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
4649
+ Number(candidate.remoteBaseline.revision)
4650
+ )
4651
+ };
4652
+ rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
4653
+ if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4654
+ workspace = candidate;
4655
+ notify({
4656
+ type: 'synced',
4657
+ operations: clone(rejectedResults),
4658
+ failure: clone(failure)
4659
+ });
4660
+ return {
4661
+ success: true,
4662
+ failure: clone(failure),
4663
+ error,
4664
+ class: failure.class,
4665
+ status: failure.status,
4666
+ code: failure.code,
4667
+ rejectedCount: ready.length,
4668
+ syncedCount: 0,
4669
+ pendingCount: workspace.pendingOperations.length,
4670
+ identityRejectedCount
4671
+ };
4672
+ }
4673
+
4674
+ // Generic terminal failures are diagnostics only. In particular, do
4675
+ // not remove an entire ready batch merely because an old gateway returned
4676
+ // a non-retryable envelope. Block the FIFO explicitly so callers cannot
4677
+ // accidentally resend an unlocatable terminal batch forever.
4678
+ logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
4679
+ return persistTerminalFailureBlock({
4680
+ failure,
4681
+ error,
4682
+ captured,
4683
+ timestamp,
4684
+ identityRejectedCount
4685
+ });
4686
+ };
4687
+
3942
4688
  const processRemote = async (remoteValue, captured) => {
3943
4689
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3944
4690
  const candidate = clone(workspace);
3945
4691
  const remote = rtlEnvelopePayload(remoteValue);
3946
4692
  const remoteRevision = Number(remote?.revision);
3947
- if (!Number.isFinite(remoteRevision)) {
3948
- return {success: false, protocolError: true, reason: 'invalid_remote_revision'};
4693
+ if (!Number.isSafeInteger(remoteRevision) || remoteRevision < 0) {
4694
+ const failure = normalizeRecordTimeLabelCloudFailure({
4695
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
4696
+ code: 'invalid_remote_revision',
4697
+ reason: 'invalid_remote_revision',
4698
+ message: 'invalid_remote_revision'
4699
+ });
4700
+ return {
4701
+ success: false,
4702
+ protocolError: true,
4703
+ reason: 'invalid_remote_revision',
4704
+ failure
4705
+ };
3949
4706
  }
3950
4707
  if (remoteRevision <= candidate.remoteBaseline.revision) {
3951
4708
  return {success: true, ignored: true};
@@ -3955,7 +4712,19 @@ export const createRecordTimeLabelSyncEngine = ({
3955
4712
  const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
3956
4713
  if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
3957
4714
  if (typeof cloud?.bootstrap !== 'function') {
3958
- return {success: false, reason: 'revision_gap', bootstrapRequired: true};
4715
+ const failure = normalizeRecordTimeLabelCloudFailure({
4716
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4717
+ code: 'revision_gap',
4718
+ reason: 'revision_gap',
4719
+ message: 'revision_gap'
4720
+ });
4721
+ return {
4722
+ success: false,
4723
+ reason: 'revision_gap',
4724
+ bootstrapRequired: true,
4725
+ failure,
4726
+ error: toRecordTimeLabelCloudFailureError(failure)
4727
+ };
3959
4728
  }
3960
4729
  const context = rtlSessionContext(captured, candidate, client);
3961
4730
  baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
@@ -3985,14 +4754,37 @@ export const createRecordTimeLabelSyncEngine = ({
3985
4754
  state.settled = true;
3986
4755
  resolvePromise({
3987
4756
  success: true,
3988
- ...(value && typeof value === 'object' ? value : {}),
3989
- generation
4757
+ ...(value && typeof value === 'object' ? value : {})
3990
4758
  });
3991
4759
  },
3992
4760
  reject(error) {
3993
4761
  if (state.settled) return;
3994
4762
  state.settled = true;
3995
- rejectPromise(error);
4763
+ const failure = normalizeRecordTimeLabelCloudFailure(error);
4764
+ let canonicalError = error;
4765
+ if (error instanceof Error) {
4766
+ Object.assign(error, {
4767
+ schemaVersion: failure.schemaVersion,
4768
+ class: failure.class,
4769
+ code: failure.code,
4770
+ reason: failure.reason,
4771
+ status: failure.status,
4772
+ retryable: failure.retryable,
4773
+ retryAfterMs: failure.retryAfterMs,
4774
+ bootstrapRequired: failure.bootstrapRequired,
4775
+ requestId: failure.requestId
4776
+ });
4777
+ if (!error.failure) {
4778
+ Object.defineProperty(error, 'failure', {
4779
+ value: failure,
4780
+ enumerable: false,
4781
+ configurable: true
4782
+ });
4783
+ }
4784
+ } else {
4785
+ canonicalError = toRecordTimeLabelCloudFailureError(failure);
4786
+ }
4787
+ rejectPromise(canonicalError);
3996
4788
  }
3997
4789
  };
3998
4790
  // Readiness is an opt-in host boundary. Keep a rejection observable to a
@@ -4004,7 +4796,12 @@ export const createRecordTimeLabelSyncEngine = ({
4004
4796
 
4005
4797
  const staleRemoteReady = (state, reason = 'stale_session') => {
4006
4798
  if (!state || state.settled) return;
4007
- state.resolve({success: false, stale: true, reason});
4799
+ const failure = normalizeRecordTimeLabelCloudFailure({
4800
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
4801
+ code: reason,
4802
+ reason
4803
+ });
4804
+ state.resolve({success: false, stale: true, reason, failure});
4008
4805
  };
4009
4806
 
4010
4807
  const isCurrentRemoteReady = (state) => (
@@ -4022,6 +4819,17 @@ export const createRecordTimeLabelSyncEngine = ({
4022
4819
  if (settleReady) staleRemoteReady(remoteReadyState);
4023
4820
  };
4024
4821
 
4822
+ const resetForSessionIdentity = (captured) => {
4823
+ stopCloudSubscription();
4824
+ workspace = rtlEmptyDurableWorkspace({
4825
+ ownerUid: captured?.uid,
4826
+ workspaceEpoch: captured?.workspaceEpoch
4827
+ });
4828
+ initialized = false;
4829
+ hydrationRequired = true;
4830
+ notify({type: 'session_changed'});
4831
+ };
4832
+
4025
4833
  const startCloudSubscription = (captured, candidate) => {
4026
4834
  stopCloudSubscription();
4027
4835
  const readyState = createRemoteReadyState();
@@ -4035,7 +4843,12 @@ export const createRecordTimeLabelSyncEngine = ({
4035
4843
  return;
4036
4844
  }
4037
4845
  if (typeof cloud?.subscribe !== 'function') {
4038
- readyState.resolve({skipped: true, reason: 'no_subscription'});
4846
+ readyState.reject(toRecordTimeLabelCloudFailureError({
4847
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
4848
+ code: 'remote_provider_missing',
4849
+ reason: 'remote_provider_missing',
4850
+ message: 'remote_provider_missing'
4851
+ }));
4039
4852
  return;
4040
4853
  }
4041
4854
  const context = rtlSessionContext(captured, candidate, client);
@@ -4043,24 +4856,64 @@ export const createRecordTimeLabelSyncEngine = ({
4043
4856
  let subscriptionReturned = false;
4044
4857
  let adapterReadyExpected = false;
4045
4858
  let firstCallbackResult = null;
4859
+ let firstRootObservation = null;
4860
+ let adapterCatchUpProof = null;
4861
+ const settleReadyIfProven = () => {
4862
+ if (!isCurrentRemoteReady(readyState) || readyState.settled ||
4863
+ !firstRootObservation || (adapterReadyExpected && !adapterCatchUpProof)) {
4864
+ return;
4865
+ }
4866
+ const baselineRevision = Number.isSafeInteger(workspace.remoteBaseline.revision)
4867
+ ? workspace.remoteBaseline.revision
4868
+ : 0;
4869
+ const proof = adapterCatchUpProof || {};
4870
+ const revision = Number.isSafeInteger(Number(proof.revision))
4871
+ ? Number(proof.revision)
4872
+ : baselineRevision;
4873
+ const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
4874
+ ? Number(proof.caughtUpToRevision)
4875
+ : revision;
4876
+ readyState.resolve({
4877
+ sessionKey: proof.sessionKey ??
4878
+ context.sessionKey ??
4879
+ captured.sessionToken ??
4880
+ captured.uid ??
4881
+ null,
4882
+ revision,
4883
+ caughtUpToRevision
4884
+ });
4885
+ };
4886
+ const handleRootObservation = async (result) => {
4887
+ if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
4888
+ if (result?.stale === true || result?.reason === 'stale_session') return;
4889
+ if (result?.success === false) {
4890
+ readyState.reject(toRecordTimeLabelCloudFailureError(
4891
+ result.failure || {
4892
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
4893
+ code: result.reason || 'remote_initial_observation_failed',
4894
+ reason: result.reason || 'remote_initial_observation_failed'
4895
+ }
4896
+ ));
4897
+ return;
4898
+ }
4899
+ firstRootObservation = result || {success: true};
4900
+ if (!adapterReadyExpected) {
4901
+ adapterCatchUpProof = {
4902
+ success: true,
4903
+ sessionKey: captured.sessionToken ?? captured.uid ?? null,
4904
+ revision: workspace.remoteBaseline.revision,
4905
+ caughtUpToRevision: workspace.remoteBaseline.revision
4906
+ };
4907
+ }
4908
+ settleReadyIfProven();
4909
+ };
4046
4910
  const onRemote = (remoteValue) => {
4047
4911
  if (destroyed || !isCurrentRemoteReady(readyState)) {
4048
4912
  return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
4049
4913
  }
4050
4914
  const callbackResult = enqueue(() => processRemote(remoteValue, captured));
4051
4915
  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) => {
4916
+ callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
4064
4917
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
4065
4918
  readyState.reject(error);
4066
4919
  }
@@ -4076,29 +4929,52 @@ export const createRecordTimeLabelSyncEngine = ({
4076
4929
  logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
4077
4930
  return;
4078
4931
  }
4079
- unsubscribeCloud = typeof disposer === 'function' ? disposer : null;
4932
+ unsubscribeCloud = typeof disposer === 'function'
4933
+ ? disposer
4934
+ : (typeof disposer?.unsubscribe === 'function' ? disposer.unsubscribe : null);
4935
+ const onSubscriptionError = (error) => {
4936
+ if (!isCurrentRemoteReady(readyState)) return;
4937
+ stopCloudSubscription({settleReady: false});
4938
+ const failure = normalizeRecordTimeLabelCloudFailure(error);
4939
+ readyState.reject(toRecordTimeLabelCloudFailureError(failure));
4940
+ notify({type: 'remote_error', failure: clone(failure)});
4941
+ };
4942
+ try {
4943
+ if (typeof disposer?.setErrorHandler === 'function') {
4944
+ disposer.setErrorHandler(onSubscriptionError);
4945
+ } else if (typeof disposer?.onError === 'function') {
4946
+ disposer.onError(onSubscriptionError);
4947
+ }
4948
+ } catch (error) {
4949
+ onSubscriptionError(error);
4950
+ return;
4951
+ }
4080
4952
  adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
4081
4953
  subscriptionReturned = true;
4082
4954
  if (adapterReadyExpected) {
4083
4955
  Promise.resolve(disposer.ready).then((result) => {
4084
4956
  if (!isCurrentRemoteReady(readyState)) return;
4085
- readyState.resolve({
4086
- revision: workspace.remoteBaseline.revision,
4087
- result
4088
- });
4957
+ if (result?.success === false) {
4958
+ readyState.reject(toRecordTimeLabelCloudFailureError(
4959
+ result.failure || {
4960
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
4961
+ code: result.reason || 'remote_initial_observation_failed',
4962
+ reason: result.reason || 'remote_initial_observation_failed'
4963
+ }
4964
+ ));
4965
+ } else {
4966
+ adapterCatchUpProof = result && typeof result === 'object'
4967
+ ? {...result, success: true}
4968
+ : {success: true};
4969
+ settleReadyIfProven();
4970
+ }
4089
4971
  }).catch((error) => {
4090
4972
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
4091
4973
  readyState.reject(error);
4092
4974
  }
4093
4975
  });
4094
4976
  } 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);
4977
+ firstCallbackResult.then(() => settleReadyIfProven()).catch(() => null);
4102
4978
  }
4103
4979
  };
4104
4980
 
@@ -4114,10 +4990,7 @@ export const createRecordTimeLabelSyncEngine = ({
4114
4990
  const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
4115
4991
  const loadedBaseline = loaded?.remoteBaseline;
4116
4992
  const committedRevision = Number(loadedBaseline?.revision);
4117
- const hasValidCommittedBaseline = rtlDurableIsObject(loadedBaseline) &&
4118
- rtlDurableIsObject(loadedBaseline.state) &&
4119
- Number.isFinite(committedRevision) &&
4120
- committedRevision > 0;
4993
+ const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
4121
4994
  let candidate = normalizeLoadedWorkspace(loaded, captured);
4122
4995
  const identityChecked = rtlQuarantineOperations(
4123
4996
  candidate,
@@ -4135,6 +5008,13 @@ export const createRecordTimeLabelSyncEngine = ({
4135
5008
  candidate.remoteBaseline.revision === committedRevision &&
4136
5009
  (!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
4137
5010
  if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
5011
+ // Do not carry untrusted resume state into a fresh hydration attempt.
5012
+ // Only the bootstrap response may establish the next cursor/marker.
5013
+ candidate.remoteBaseline.changeCursor = null;
5014
+ candidate.syncMeta = {...candidate.syncMeta};
5015
+ ['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding'].forEach((key) => {
5016
+ delete candidate.syncMeta[key];
5017
+ });
4138
5018
  let bootstrap;
4139
5019
  try {
4140
5020
  bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
@@ -4147,6 +5027,7 @@ export const createRecordTimeLabelSyncEngine = ({
4147
5027
  }
4148
5028
  if (!(await isCurrent(captured))) return getSnapshot();
4149
5029
  applyRemoteBaseline(candidate, bootstrap);
5030
+ delete candidate.syncMeta.terminalFailureBlock;
4150
5031
  }
4151
5032
 
4152
5033
  // Always persist the normalized durable shape before subscribing. This
@@ -4191,7 +5072,12 @@ export const createRecordTimeLabelSyncEngine = ({
4191
5072
  return {success: false, code: 'recordtimelabel_hydration_required'};
4192
5073
  }
4193
5074
  const captured = capture();
5075
+ clearAuthTransitionLatchIfSessionChanged(captured);
4194
5076
  if (!(await isCurrent(captured))) return getSnapshot();
5077
+ if (!workspaceMatchesSession(captured)) {
5078
+ resetForSessionIdentity(captured);
5079
+ return {success: false, code: 'recordtimelabel_hydration_required'};
5080
+ }
4195
5081
  const input = Array.isArray(operations)
4196
5082
  ? operations
4197
5083
  : (operations && typeof operations === 'object' ? [operations] : []);
@@ -4272,11 +5158,25 @@ export const createRecordTimeLabelSyncEngine = ({
4272
5158
  };
4273
5159
 
4274
5160
  const syncInternal = async (reason) => {
5161
+ const captured = capture();
5162
+ throwIfAuthTransitionLatched(captured);
4275
5163
  if (!initialized || hydrationRequired) {
4276
5164
  return {success: false, code: 'recordtimelabel_hydration_required'};
4277
5165
  }
4278
- const captured = capture();
4279
5166
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
5167
+ if (!workspaceMatchesSession(captured)) {
5168
+ resetForSessionIdentity(captured);
5169
+ return {success: false, code: 'recordtimelabel_hydration_required'};
5170
+ }
5171
+ const blockedFailure = workspace.syncMeta?.terminalFailureBlock;
5172
+ if (blockedFailure) {
5173
+ const failure = normalizeRecordTimeLabelCloudFailure(blockedFailure);
5174
+ return failureOutcome(
5175
+ failure,
5176
+ toRecordTimeLabelCloudFailureError(failure),
5177
+ {protocolError: true, blocked: true, identityRejectedCount: 0}
5178
+ );
5179
+ }
4280
5180
  const timestamp = now();
4281
5181
  const identityChecked = rtlQuarantineOperations(
4282
5182
  workspace,
@@ -4328,7 +5228,22 @@ export const createRecordTimeLabelSyncEngine = ({
4328
5228
  };
4329
5229
  }
4330
5230
  if (typeof cloud?.applyOperations !== 'function') {
4331
- return {success: false, reason: 'missing_cloud_apply_operations'};
5231
+ const failure = normalizeRecordTimeLabelCloudFailure({
5232
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5233
+ code: 'missing_cloud_apply_operations',
5234
+ reason: 'missing_cloud_apply_operations',
5235
+ message: 'missing_cloud_apply_operations'
5236
+ });
5237
+ return {
5238
+ success: false,
5239
+ failure,
5240
+ error: toRecordTimeLabelCloudFailureError(failure),
5241
+ class: failure.class,
5242
+ code: failure.code,
5243
+ reason: failure.reason,
5244
+ pendingCount: workspace.pendingOperations.length,
5245
+ identityRejectedCount
5246
+ };
4332
5247
  }
4333
5248
  const context = rtlSessionContext(captured, workspace, client);
4334
5249
  const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
@@ -4350,59 +5265,79 @@ export const createRecordTimeLabelSyncEngine = ({
4350
5265
  }, context);
4351
5266
  } catch (error) {
4352
5267
  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
- };
5268
+ return settleApplyFailure({
5269
+ value: error,
5270
+ captured,
5271
+ context,
5272
+ ready,
5273
+ timestamp,
5274
+ identityRejectedCount
5275
+ });
4362
5276
  }
4363
5277
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4364
5278
 
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};
5279
+ const hasCanonicalFailure = Boolean(
5280
+ response &&
5281
+ typeof response === 'object' &&
5282
+ Object.prototype.hasOwnProperty.call(response, 'failure') &&
5283
+ response.failure != null
5284
+ );
5285
+ if (hasCanonicalFailure || !rtlEnvelopeSuccess(response)) {
5286
+ // An explicit operationResults array remains a supported legacy
5287
+ // response shape, but a canonical `failure` is authoritative even if a
5288
+ // gateway accidentally includes an empty/partial result array beside it.
5289
+ if (hasCanonicalFailure || !Object.prototype.hasOwnProperty.call(response || {}, 'operationResults')) {
5290
+ return settleApplyFailure({
5291
+ value: response,
5292
+ captured,
5293
+ context,
5294
+ ready,
5295
+ timestamp,
5296
+ identityRejectedCount
5297
+ });
4372
5298
  }
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
5299
  }
4401
5300
 
5301
+ const responseRevisionInfo = rtlExplicitRevisionInfo(response);
5302
+ if (!responseRevisionInfo.valid) {
5303
+ const protocolError = rtlInvalidRevisionError();
5304
+ const failure = normalizeRecordTimeLabelCloudFailure({
5305
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5306
+ code: protocolError.code,
5307
+ reason: protocolError.code,
5308
+ message: protocolError.message
5309
+ });
5310
+ const error = toRecordTimeLabelCloudFailureError(failure);
5311
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
5312
+ return persistTerminalFailureBlock({
5313
+ failure,
5314
+ error,
5315
+ captured,
5316
+ timestamp,
5317
+ identityRejectedCount,
5318
+ extra: {protocolError: true}
5319
+ });
5320
+ }
5321
+ const responseBaseline = rtlEnvelopePayload(response);
5322
+ const responseRevision = responseRevisionInfo.revision;
4402
5323
  const parsed = normalizeOperationResults(response, wireOperations);
4403
5324
  if (parsed.error) {
4404
- logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
4405
- return {success: false, error: parsed.error, protocolError: true};
5325
+ const failure = normalizeRecordTimeLabelCloudFailure({
5326
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5327
+ code: parsed.error.code || 'protocol_error',
5328
+ reason: parsed.error.reason || parsed.error.code || 'protocol_error',
5329
+ message: parsed.error.message || parsed.error.code || 'protocol_error'
5330
+ });
5331
+ const error = toRecordTimeLabelCloudFailureError(failure);
5332
+ logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
5333
+ return persistTerminalFailureBlock({
5334
+ failure,
5335
+ error,
5336
+ captured,
5337
+ timestamp,
5338
+ identityRejectedCount,
5339
+ extra: {protocolError: true}
5340
+ });
4406
5341
  }
4407
5342
  let sawRetryable = false;
4408
5343
  for (const result of parsed.results) {
@@ -4412,7 +5347,20 @@ export const createRecordTimeLabelSyncEngine = ({
4412
5347
  const error = new Error('sync_protocol_fifo_retry_barrier_violation');
4413
5348
  error.code = 'sync_protocol_fifo_retry_barrier_violation';
4414
5349
  logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
4415
- return {success: false, error, protocolError: true};
5350
+ const failure = normalizeRecordTimeLabelCloudFailure({
5351
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5352
+ code: error.code,
5353
+ reason: error.code,
5354
+ message: error.message
5355
+ });
5356
+ return persistTerminalFailureBlock({
5357
+ failure,
5358
+ error,
5359
+ captured,
5360
+ timestamp,
5361
+ identityRejectedCount,
5362
+ extra: {protocolError: true}
5363
+ });
4416
5364
  }
4417
5365
  }
4418
5366
  const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
@@ -4450,40 +5398,109 @@ export const createRecordTimeLabelSyncEngine = ({
4450
5398
  };
4451
5399
  }
4452
5400
  }
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
5401
  const responseState = responseBaseline?.state ?? responseBaseline?.data;
4461
- if (rtlDurableIsObject(responseState)) {
4462
- applyRemoteBaseline(candidate, responseBaseline);
5402
+ const responseBaselineForApply = rtlDurableIsObject(responseBaseline) &&
5403
+ Number.isSafeInteger(responseRevision)
5404
+ ? {...responseBaseline, revision: responseRevision}
5405
+ : responseBaseline;
5406
+ const currentBaselineRevision = Number(candidate.remoteBaseline.revision);
5407
+ const hasAllAppliedResults = parsed.results.length > 0 &&
5408
+ parsed.results.every((result) => result.status === 'applied');
5409
+ const responseIsStale = Number.isFinite(responseRevision) &&
5410
+ responseRevision <= currentBaselineRevision;
5411
+ const responseIsExactNext = Number.isFinite(responseRevision) &&
5412
+ responseRevision === currentBaselineRevision + 1;
5413
+ const responseIsForwardGap = Number.isFinite(responseRevision) &&
5414
+ responseRevision > currentBaselineRevision + 1;
5415
+
5416
+ const hasAuthoritativeFullBaseline = !responseIsStale &&
5417
+ rtlDurableIsObject(responseState) &&
5418
+ Number.isSafeInteger(responseRevision) &&
5419
+ responseRevision > currentBaselineRevision;
5420
+ if (
5421
+ !responseIsStale &&
5422
+ rejectedCount > 0 &&
5423
+ typeof cloud?.bootstrap !== 'function' &&
5424
+ !hasAuthoritativeFullBaseline
5425
+ ) {
5426
+ const failure = normalizeRecordTimeLabelCloudFailure({
5427
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
5428
+ code: 'recordtimelabel_bootstrap_required',
5429
+ reason: 'recordtimelabel_bootstrap_required',
5430
+ message: 'recordtimelabel_bootstrap_required'
5431
+ });
5432
+ return {
5433
+ success: false,
5434
+ bootstrapRequired: true,
5435
+ failure,
5436
+ error: toRecordTimeLabelCloudFailureError(failure),
5437
+ pendingCount: workspace.pendingOperations.length,
5438
+ identityRejectedCount
5439
+ };
5440
+ }
5441
+
5442
+ // A receipt replay can arrive after realtime has already promoted the
5443
+ // authoritative state. ACK completion is still durable, but replaying
5444
+ // its operations would apply settings/order changes a second time.
5445
+ let requiresAuthoritativeRecovery = (responseIsForwardGap && rejectedCount === 0) ||
5446
+ (responseIsExactNext && !hasAllAppliedResults && rejectedCount === 0);
5447
+ if (
5448
+ responseIsForwardGap &&
5449
+ rejectedCount === 0 &&
5450
+ typeof cloud?.bootstrap !== 'function' &&
5451
+ typeof cloud?.catchUp !== 'function'
5452
+ ) {
5453
+ const failure = normalizeRecordTimeLabelCloudFailure({
5454
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
5455
+ code: 'recordtimelabel_bootstrap_required',
5456
+ reason: 'recordtimelabel_bootstrap_required',
5457
+ message: 'recordtimelabel_bootstrap_required'
5458
+ });
5459
+ return {
5460
+ success: false,
5461
+ bootstrapRequired: true,
5462
+ failure,
5463
+ error: toRecordTimeLabelCloudFailureError(failure),
5464
+ pendingCount: workspace.pendingOperations.length,
5465
+ identityRejectedCount
5466
+ };
5467
+ }
5468
+ if (requiresAuthoritativeRecovery) {
5469
+ const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
5470
+ if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
5471
+ applyRemoteBaseline(candidate, freshBaseline);
5472
+ } else if (!responseIsStale && rtlDurableIsObject(responseState)) {
5473
+ // A same-transaction full baseline is authoritative at the exact next
5474
+ // revision. Older receipt payloads are intentionally ignored above.
5475
+ applyRemoteBaseline(candidate, responseBaselineForApply);
4463
5476
  } else if (
5477
+ !responseIsStale &&
5478
+ responseIsExactNext &&
4464
5479
  rejectedCount === 0 &&
4465
5480
  !sawRetryable &&
4466
- appliedOperations.length === completedOperations.length &&
4467
- appliedOperations.length > 0 &&
4468
- Number.isFinite(responseRevision) &&
4469
- responseRevision === candidate.remoteBaseline.revision + 1
5481
+ hasAllAppliedResults
4470
5482
  ) {
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.
5483
+ // The compact v2 ACK omits state. Promote only a complete all-applied
5484
+ // transaction at exactly the next revision; a gap must be recovered.
5485
+ candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
5486
+ appliedOperations.reduce(
5487
+ (state, operation) => applyRecordTimeLabelOperation(state, operation),
5488
+ candidate.remoteBaseline.state
5489
+ )
5490
+ );
4474
5491
  candidate.remoteBaseline.revision = responseRevision;
4475
5492
  if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
4476
5493
  candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
4477
5494
  }
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);
5495
+ } else if (!responseIsStale && !Number.isFinite(responseRevision)) {
5496
+ // Legacy envelopes without a revision have no stale-receipt ordering
5497
+ // signal. Preserve their historical local promotion compatibility.
5498
+ candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
5499
+ appliedOperations.reduce(
5500
+ (state, operation) => applyRecordTimeLabelOperation(state, operation),
5501
+ candidate.remoteBaseline.state
5502
+ )
5503
+ );
4487
5504
  }
4488
5505
  candidate.pendingOperations = nextPending;
4489
5506
  candidate.rejectedOperations = nextRejected;
@@ -4654,7 +5671,14 @@ export const createRecordTimeLabelSyncEngine = ({
4654
5671
  const engine = {
4655
5672
  init() {
4656
5673
  return enqueue(async () => {
4657
- if (initialized && !hydrationRequired) return getSnapshot();
5674
+ const captured = capture();
5675
+ clearAuthTransitionLatchIfSessionChanged(captured);
5676
+ if (initialized && !hydrationRequired && workspaceMatchesSession(captured)) {
5677
+ return getSnapshot();
5678
+ }
5679
+ if (initialized && !hydrationRequired && !workspaceMatchesSession(captured)) {
5680
+ resetForSessionIdentity(captured);
5681
+ }
4658
5682
  return initialize();
4659
5683
  });
4660
5684
  },
@@ -4677,10 +5701,16 @@ export const createRecordTimeLabelSyncEngine = ({
4677
5701
 
4678
5702
  waitForRemoteReady() {
4679
5703
  if (destroyed) {
5704
+ const failure = normalizeRecordTimeLabelCloudFailure({
5705
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION,
5706
+ code: 'stale_session',
5707
+ reason: 'stale_session'
5708
+ });
4680
5709
  return Promise.resolve({
4681
5710
  success: false,
4682
5711
  stale: true,
4683
5712
  reason: 'stale_session',
5713
+ failure,
4684
5714
  generation: remoteReadyState.generation
4685
5715
  });
4686
5716
  }
@@ -4884,20 +5914,45 @@ const RTL_BULK_OPERATION_TYPES = new Set([
4884
5914
  ]);
4885
5915
  const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
4886
5916
 
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
5917
+ export const isRecordTimeLabelSafeDocumentId = rtlIsSafeDocumentId;
5918
+ const rtlOwn = (object, key) => Boolean(
5919
+ object && Object.prototype.hasOwnProperty.call(object, key)
4896
5920
  );
5921
+
5922
+ const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
5923
+ const rtlFirstPresent = (object, keys) => {
5924
+ for (const key of keys) {
5925
+ if (Object.prototype.hasOwnProperty.call(object || {}, key)) return object[key];
5926
+ }
5927
+ return undefined;
5928
+ };
5929
+ const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5930
+ 'recordId', 'id'
5931
+ ]) ?? operation?.payload?.record?.id ?? null;
5932
+ const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5933
+ 'folderId', 'id'
5934
+ ]) ?? operation?.payload?.folder?.id ?? null;
5935
+ const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5936
+ 'trashEntryId', 'id'
5937
+ ]) ?? null;
4897
5938
  const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
4898
5939
  encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
4899
5940
  );
5941
+ const rtlGetLifecycleTombstone = (documents, kind, entityId) => {
5942
+ const logicalId = `${kind}:${normalizeId(entityId)}`;
5943
+ const aliases = lifecycleTombstoneKeyAliases(logicalId);
5944
+ const collection = documents?.lifecycleTombstones || {};
5945
+ for (const alias of aliases) {
5946
+ if (rtlOwn(collection, alias)) return collection[alias];
5947
+ }
5948
+ const rootField = kind === 'folder' ? 'deletedFolderTombstones' : 'deletedRecordTombstones';
5949
+ const rootCollection = documents?.root?.[rootField] || {};
5950
+ return rtlOwn(rootCollection, normalizeId(entityId))
5951
+ ? rootCollection[normalizeId(entityId)]
5952
+ : null;
5953
+ };
4900
5954
  const rtlCloneDocuments = (documents = {}) => ({
5955
+ ...clone(documents || {}),
4901
5956
  root: clone(documents.root || null),
4902
5957
  records: clone(documents.records || {}),
4903
5958
  folders: clone(documents.folders || {}),
@@ -4906,6 +5961,24 @@ const rtlCloneDocuments = (documents = {}) => ({
4906
5961
  ops: {}
4907
5962
  });
4908
5963
  const rtlMergeOrder = (...orders) => normalizeOrder(orders.flatMap((order) => toArray(order)));
5964
+ const rtlIsSafeOpaqueGroupId = (value) => (
5965
+ typeof value === 'string' && value.length > 0 &&
5966
+ new TextEncoder().encode(value).byteLength <= RECORD_TIMELABEL_SAFE_ID_MAX_BYTES &&
5967
+ !/[\u0000-\u001F\u007F-\u009F]/u.test(value)
5968
+ );
5969
+ const rtlHasUnsafeOrderId = (order, {opaqueGroup = false} = {}) => toArray(order).some((id) => (
5970
+ opaqueGroup ? !rtlIsSafeOpaqueGroupId(id) : !rtlIsSafeDocumentId(id)
5971
+ ));
5972
+ const rtlMergeOpaqueOrder = (...orders) => {
5973
+ const seen = new Set();
5974
+ const result = [];
5975
+ orders.flatMap((order) => toArray(order)).forEach((id) => {
5976
+ if (!rtlIsSafeOpaqueGroupId(id) || seen.has(id)) return;
5977
+ seen.add(id);
5978
+ result.push(id);
5979
+ });
5980
+ return result;
5981
+ };
4909
5982
  /**
4910
5983
  * Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
4911
5984
  * so the planner must not drop ids on a prefix guess either.
@@ -4934,7 +6007,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
4934
6007
  errors.push(`operation_${index}_invalid_${field}`);
4935
6008
  return;
4936
6009
  }
4937
- if (value.some((id) => !normalizeId(id) || normalizeId(id).length > 512)) {
6010
+ if (value.some((id) => !rtlIsSafeDocumentId(id))) {
4938
6011
  errors.push(`operation_${index}_invalid_${field}`);
4939
6012
  }
4940
6013
  };
@@ -5029,9 +6102,10 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
5029
6102
  export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
5030
6103
  const errors = [];
5031
6104
  const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
5032
- toArray(body?.client?.capabilities).includes(
5033
- RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
5034
- );
6105
+ toArray(body?.client?.capabilities).some((capability) => (
6106
+ capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE ||
6107
+ capability === RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1
6108
+ ));
5035
6109
  let requestBytes = 0;
5036
6110
  try {
5037
6111
  requestBytes = rtlByteLength(JSON.stringify(body));
@@ -5042,7 +6116,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
5042
6116
  if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
5043
6117
  errors.push('unsupported_protocol_version');
5044
6118
  }
5045
- if (!/^[A-Za-z0-9._:-]{8,160}$/.test(normalizeId(body.requestId))) {
6119
+ if (!/^[A-Za-z0-9._:-]{8,160}$/.test(body.requestId) ||
6120
+ !rtlIsSafeDocumentId(body.requestId)) {
5046
6121
  errors.push('invalid_request_id');
5047
6122
  }
5048
6123
  if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
@@ -5056,8 +6131,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
5056
6131
  }
5057
6132
  const operationIds = new Set();
5058
6133
  operations.forEach((operation, index) => {
5059
- const id = normalizeId(operation?.id);
5060
- if (!id || id.length > 200) errors.push(`operation_${index}_invalid_id`);
6134
+ const id = operation?.id;
6135
+ if (!rtlIsSafeDocumentId(id) || id.length > 200) errors.push(`operation_${index}_invalid_id`);
5061
6136
  if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
5062
6137
  operationIds.add(id);
5063
6138
  if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
@@ -5083,12 +6158,16 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
5083
6158
  if (!toFiniteTimestamp(operation?.createdAt)) {
5084
6159
  errors.push(`operation_${index}_invalid_created_at`);
5085
6160
  }
5086
- if (rtlOperationRecordId(operation).length > 512) {
6161
+ if (rtlOperationRecordId(operation) && !rtlIsSafeDocumentId(rtlOperationRecordId(operation))) {
5087
6162
  errors.push(`operation_${index}_invalid_record_id`);
5088
6163
  }
5089
- if (rtlOperationFolderId(operation).length > 512) {
6164
+ if (rtlOperationFolderId(operation) && !rtlIsSafeDocumentId(rtlOperationFolderId(operation))) {
5090
6165
  errors.push(`operation_${index}_invalid_folder_id`);
5091
6166
  }
6167
+ const trashEntryId = rtlOperationTrashEntryId(operation);
6168
+ if (trashEntryId && !rtlIsSafeDocumentId(trashEntryId)) {
6169
+ errors.push(`operation_${index}_invalid_trash_entry_id`);
6170
+ }
5092
6171
  });
5093
6172
 
5094
6173
  return {ok: errors.length === 0, errors, requestBytes, operations};
@@ -5124,13 +6203,35 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
5124
6203
  if (recordId) {
5125
6204
  recordIds.add(recordId);
5126
6205
  lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
6206
+ trashEntryIds.add(normalizeId(payload.trashEntryId) || `record:${recordId}`);
5127
6207
  }
5128
6208
  break;
5129
6209
  }
5130
6210
  case OPERATION_TYPES.RECORD_RESTORE:
6211
+ case OPERATION_TYPES.FOLDER_RESTORE:
5131
6212
  case OPERATION_TYPES.TRASH_PURGE: {
5132
6213
  const trashEntryId = rtlOperationTrashEntryId(operation);
5133
6214
  if (trashEntryId) trashEntryIds.add(trashEntryId);
6215
+ const explicitEntityId = normalizeId(
6216
+ payload.recordId || payload.folderId || payload.entityId
6217
+ );
6218
+ const inferredKind = operation.type === OPERATION_TYPES.FOLDER_RESTORE
6219
+ ? 'folder'
6220
+ : operation.type === OPERATION_TYPES.RECORD_RESTORE
6221
+ ? 'record'
6222
+ : null;
6223
+ let decodedTrashEntryId = trashEntryId;
6224
+ try { decodedTrashEntryId = decodeURIComponent(trashEntryId || ''); } catch { /* keep raw */ }
6225
+ const inferredEntityId = explicitEntityId || (
6226
+ inferredKind && decodedTrashEntryId?.startsWith(`${inferredKind}:`)
6227
+ ? normalizeId(decodedTrashEntryId.slice(inferredKind.length + 1))
6228
+ : ''
6229
+ );
6230
+ if (inferredKind && inferredEntityId) {
6231
+ lifecycleTombstoneIds.add(
6232
+ rtlLifecycleTombstoneDocumentId(inferredKind, inferredEntityId)
6233
+ );
6234
+ }
5134
6235
  break;
5135
6236
  }
5136
6237
  case OPERATION_TYPES.RECORD_REORDER:
@@ -5219,7 +6320,8 @@ const rtlApplyOperationToPartialDocuments = ({
5219
6320
 
5220
6321
  const rtlCanRestoreLocalFolder = (root, folder) => {
5221
6322
  if (!folder?.id) return false;
5222
- const tombstone = root?.deletedFolderTombstones?.[folder.id];
6323
+ const tombstones = root?.deletedFolderTombstones;
6324
+ const tombstone = rtlOwn(tombstones, folder.id) ? tombstones[folder.id] : null;
5223
6325
  return !tombstone;
5224
6326
  };
5225
6327
 
@@ -5230,8 +6332,9 @@ const rtlBuildLocalFolderDocument = (folder, now) => {
5230
6332
  }, {now}).folders[folder.id] || null;
5231
6333
  };
5232
6334
 
5233
- const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
5234
- if (folders[folderId]) return folders[folderId];
6335
+ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now, allowContext = false}) => {
6336
+ if (rtlOwn(folders, folderId)) return folders[folderId];
6337
+ if (!allowContext) return null;
5235
6338
  const localFolder = toArray(localState?.folders)
5236
6339
  .find((folder) => folder?.id === folderId);
5237
6340
  if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
@@ -5246,7 +6349,19 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
5246
6349
 
5247
6350
  const rtlSetRoot = (target, source) => {
5248
6351
  Object.keys(target).forEach((key) => delete target[key]);
5249
- Object.assign(target, clone(source || {}));
6352
+ const next = clone(source || {});
6353
+ // `Object.assign` invokes the legacy `__proto__` setter on a normal target.
6354
+ // Define each own key as data instead, so malformed legacy/root documents
6355
+ // cannot mutate the candidate's prototype while the planner is rebuilding
6356
+ // its immutable document map.
6357
+ Reflect.ownKeys(next).forEach((key) => {
6358
+ Object.defineProperty(target, key, {
6359
+ value: next[key],
6360
+ enumerable: true,
6361
+ configurable: true,
6362
+ writable: true
6363
+ });
6364
+ });
5250
6365
  };
5251
6366
 
5252
6367
  /**
@@ -5257,22 +6372,48 @@ export const planFirestoreV2OperationChanges = ({
5257
6372
  operations,
5258
6373
  localState = {},
5259
6374
  now = Date.now(),
5260
- requireLifecycleGeneration = true
6375
+ requireLifecycleGeneration = true,
6376
+ plannerMode = RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT
5261
6377
  } = {}) => {
6378
+ const selectedPlannerMode = plannerMode === RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
6379
+ ? RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC
6380
+ : RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
6381
+ const allowContext = selectedPlannerMode === RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT;
5262
6382
  const previousDocuments = rtlCloneDocuments(documents);
5263
6383
  const nextDocuments = rtlCloneDocuments(documents);
5264
6384
  const preservedExpandedGroups = previousDocuments.root &&
5265
6385
  Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
5266
6386
  ? clone(previousDocuments.root.expandedGroups)
5267
6387
  : undefined;
5268
- const normalizedLocalState = normalizeState(localState || {});
6388
+ // The deterministic planner deliberately does not inspect mutable local
6389
+ // context. Keep the old context-assisted behaviour behind an explicit
6390
+ // legacy mode so rolling clients can continue to use it while gateways
6391
+ // migrate to the stricter input contract.
6392
+ const normalizedLocalState = allowContext ? normalizeState(localState || {}) : {};
5269
6393
  const operationResults = [];
5270
6394
  let changed = false;
5271
6395
  if (!nextDocuments.root) {
5272
- return {requiresMigration: true, previousDocuments, nextDocuments, operationResults};
6396
+ return {
6397
+ requiresMigration: true,
6398
+ plannerMode: selectedPlannerMode,
6399
+ previousDocuments,
6400
+ nextDocuments,
6401
+ operationResults
6402
+ };
5273
6403
  }
5274
6404
 
5275
6405
  for (const operation of toArray(operations).filter(Boolean)) {
6406
+ const operationId = operation?.id;
6407
+ if (!rtlIsSafeDocumentId(operationId) || operationId.length > 200) {
6408
+ operationResults.push({
6409
+ id: operationId ?? null,
6410
+ type: operation?.type ?? null,
6411
+ applied: false,
6412
+ status: 'rejected',
6413
+ reason: 'invalid_document_id'
6414
+ });
6415
+ continue;
6416
+ }
5276
6417
  if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
5277
6418
  operationResults.push({
5278
6419
  id: operation.id || null,
@@ -5287,21 +6428,33 @@ export const planFirestoreV2OperationChanges = ({
5287
6428
  const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
5288
6429
  const recordId = rtlOperationRecordId(operation);
5289
6430
  const folderId = rtlOperationFolderId(operation);
6431
+ const trashEntryId = rtlOperationTrashEntryId(operation);
5290
6432
  let applied = null;
5291
6433
  let reason = null;
5292
6434
  let lifecycleTombstoneId = null;
5293
6435
 
5294
- switch (operation.type) {
6436
+ // Never use prototype-inherited values as document snapshots. Apart
6437
+ // from being incorrect for IDs such as "constructor", doing so would
6438
+ // allow user-controlled Firestore IDs to reach unsafe map writes.
6439
+ if ((recordId && !rtlIsSafeDocumentId(recordId)) ||
6440
+ (folderId && !rtlIsSafeDocumentId(folderId)) ||
6441
+ (trashEntryId && !rtlIsSafeDocumentId(trashEntryId))) {
6442
+ reason = 'invalid_document_id';
6443
+ }
6444
+
6445
+ switch (reason ? null : operation.type) {
5295
6446
  case OPERATION_TYPES.RECORD_CREATE: {
5296
6447
  if (!recordId) { reason = 'missing_record_id'; break; }
5297
- if (nextDocuments.records[recordId]) { reason = 'already_exists'; break; }
6448
+ if (rtlOwn(nextDocuments.records, recordId)) { reason = 'already_exists'; break; }
5298
6449
  const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
5299
6450
  applied = rtlApplyOperationToPartialDocuments({
5300
6451
  ...nextDocuments,
5301
6452
  operation: {...operation, payload: {...payload, folderId: targetFolderId}},
5302
6453
  now: operationNow
5303
6454
  });
5304
- const recordDocument = applied.documents.records[recordId];
6455
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6456
+ ? applied.documents.records[recordId]
6457
+ : null;
5305
6458
  if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
5306
6459
  const candidateRoot = clone(applied.documents.root);
5307
6460
  const candidateFolders = clone(nextDocuments.folders);
@@ -5310,7 +6463,8 @@ export const planFirestoreV2OperationChanges = ({
5310
6463
  folders: candidateFolders,
5311
6464
  localState: normalizedLocalState,
5312
6465
  folderId: targetFolderId,
5313
- now: operationNow
6466
+ now: operationNow,
6467
+ allowContext
5314
6468
  });
5315
6469
  if (!targetFolder) {
5316
6470
  reason = 'folder_not_found';
@@ -5324,13 +6478,17 @@ export const planFirestoreV2OperationChanges = ({
5324
6478
  break;
5325
6479
  }
5326
6480
  case OPERATION_TYPES.RECORD_UPDATE: {
5327
- const existing = nextDocuments.records[recordId];
6481
+ const existing = rtlOwn(nextDocuments.records, recordId)
6482
+ ? nextDocuments.records[recordId]
6483
+ : null;
5328
6484
  if (!recordId || !existing) {
5329
6485
  reason = recordId ? 'record_not_found' : 'missing_record_id';
5330
6486
  break;
5331
6487
  }
5332
6488
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5333
- const recordDocument = applied.documents.records[recordId];
6489
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6490
+ ? applied.documents.records[recordId]
6491
+ : null;
5334
6492
  if (!recordDocument) { reason = 'record_not_found'; break; }
5335
6493
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5336
6494
  nextDocuments.records[recordId] = recordDocument;
@@ -5338,7 +6496,9 @@ export const planFirestoreV2OperationChanges = ({
5338
6496
  break;
5339
6497
  }
5340
6498
  case OPERATION_TYPES.RECORD_MOVE: {
5341
- const existing = nextDocuments.records[recordId];
6499
+ const existing = rtlOwn(nextDocuments.records, recordId)
6500
+ ? nextDocuments.records[recordId]
6501
+ : null;
5342
6502
  if (!recordId || !existing) {
5343
6503
  reason = recordId ? 'record_not_found' : 'missing_record_id';
5344
6504
  break;
@@ -5350,7 +6510,9 @@ export const planFirestoreV2OperationChanges = ({
5350
6510
  operation: {...operation, payload: {...payload, targetFolderId}},
5351
6511
  now: operationNow
5352
6512
  });
5353
- const recordDocument = applied.documents.records[recordId];
6513
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6514
+ ? applied.documents.records[recordId]
6515
+ : null;
5354
6516
  if (!recordDocument) { reason = 'record_not_found'; break; }
5355
6517
  const candidateRoot = clone(applied.documents.root);
5356
6518
  const candidateFolders = clone(nextDocuments.folders);
@@ -5359,10 +6521,11 @@ export const planFirestoreV2OperationChanges = ({
5359
6521
  folders: candidateFolders,
5360
6522
  localState: normalizedLocalState,
5361
6523
  folderId: targetFolderId,
5362
- now: operationNow
6524
+ now: operationNow,
6525
+ allowContext
5363
6526
  });
5364
6527
  if (!targetFolder) { reason = 'folder_not_found'; break; }
5365
- if (candidateFolders[sourceFolderId]) {
6528
+ if (rtlOwn(candidateFolders, sourceFolderId)) {
5366
6529
  candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
5367
6530
  candidateFolders[sourceFolderId].recordOrder
5368
6531
  ).filter((id) => id !== recordId);
@@ -5379,8 +6542,14 @@ export const planFirestoreV2OperationChanges = ({
5379
6542
  }
5380
6543
  case OPERATION_TYPES.RECORD_REORDER: {
5381
6544
  const targetFolderId = safeFolderId(payload.folderId);
5382
- const targetFolder = nextDocuments.folders[targetFolderId];
6545
+ const targetFolder = rtlOwn(nextDocuments.folders, targetFolderId)
6546
+ ? nextDocuments.folders[targetFolderId]
6547
+ : null;
5383
6548
  const requested = rtlPayloadOrder(operation, ['recordIds']);
6549
+ if (rtlHasUnsafeOrderId(requested)) {
6550
+ reason = 'invalid_document_id';
6551
+ break;
6552
+ }
5384
6553
  if (!targetFolder || requested.length === 0) {
5385
6554
  reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
5386
6555
  break;
@@ -5394,14 +6563,50 @@ export const planFirestoreV2OperationChanges = ({
5394
6563
  case OPERATION_TYPES.RECORD_DELETE: {
5395
6564
  if (!recordId) { reason = 'missing_record_id'; break; }
5396
6565
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
5397
- const sourceFolderId = safeFolderId(nextDocuments.records[recordId]?.folderId);
6566
+ const existingRecord = rtlOwn(nextDocuments.records, recordId)
6567
+ ? nextDocuments.records[recordId]
6568
+ : null;
6569
+ const operationTrashEntryId = trashEntryId || `record:${recordId}`;
6570
+ const existingTrashEntry = rtlOwn(nextDocuments.trash, operationTrashEntryId)
6571
+ ? nextDocuments.trash[operationTrashEntryId]
6572
+ : null;
6573
+ const existingTombstone = rtlGetLifecycleTombstone(nextDocuments, 'record', recordId);
6574
+ if (!existingRecord) {
6575
+ const hasTrash = Boolean(existingTrashEntry);
6576
+ const hasTombstone = Boolean(existingTombstone);
6577
+ const trashGeneration = Number(existingTrashEntry?.lifecycleGeneration || 0);
6578
+ const tombstoneGeneration = Number(existingTombstone?.lifecycleGeneration || 0);
6579
+ const validTrashGeneration = Number.isSafeInteger(trashGeneration) && trashGeneration > 0;
6580
+ const validTombstoneGeneration = Number.isSafeInteger(tombstoneGeneration) &&
6581
+ tombstoneGeneration > 0;
6582
+ if (hasTrash && hasTombstone) {
6583
+ reason = validTrashGeneration && validTombstoneGeneration &&
6584
+ trashGeneration === tombstoneGeneration
6585
+ ? 'already_deleted'
6586
+ : 'lifecycle_conflict';
6587
+ } else if (hasTombstone) {
6588
+ reason = validTombstoneGeneration ? 'already_deleted' : 'lifecycle_conflict';
6589
+ } else if (hasTrash) {
6590
+ reason = 'lifecycle_conflict';
6591
+ } else {
6592
+ reason = 'record_not_found';
6593
+ }
6594
+ break;
6595
+ }
6596
+ if (existingTombstone && Number(existingTombstone.lifecycleGeneration || 0) >=
6597
+ Number(existingRecord.lifecycleGeneration || 0)) {
6598
+ reason = 'already_deleted';
6599
+ break;
6600
+ }
6601
+ const sourceFolderId = safeFolderId(existingRecord.folderId);
5398
6602
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5399
6603
  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;
6604
+ const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
6605
+ ? applied.documents.trash[operationTrashEntryId]
6606
+ : null;
6607
+ if (trashDocument) nextDocuments.trash[operationTrashEntryId] = trashDocument;
5403
6608
  delete nextDocuments.records[recordId];
5404
- if (nextDocuments.folders[sourceFolderId]) {
6609
+ if (rtlOwn(nextDocuments.folders, sourceFolderId)) {
5405
6610
  nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
5406
6611
  nextDocuments.folders[sourceFolderId].recordOrder
5407
6612
  ).filter((id) => id !== recordId);
@@ -5410,8 +6615,10 @@ export const planFirestoreV2OperationChanges = ({
5410
6615
  break;
5411
6616
  }
5412
6617
  case OPERATION_TYPES.RECORD_RESTORE: {
5413
- const trashEntryId = rtlOperationTrashEntryId(operation);
5414
- const trashEntry = nextDocuments.trash[trashEntryId];
6618
+ const restoreTrashEntryId = trashEntryId;
6619
+ const trashEntry = rtlOwn(nextDocuments.trash, restoreTrashEntryId)
6620
+ ? nextDocuments.trash[restoreTrashEntryId]
6621
+ : null;
5415
6622
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
5416
6623
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
5417
6624
  if (requireLifecycleGeneration !== false && expectedGeneration === null) {
@@ -5427,11 +6634,23 @@ export const planFirestoreV2OperationChanges = ({
5427
6634
  break;
5428
6635
  }
5429
6636
  const restoredRecordId = normalizeId(trashEntry.entityId);
5430
- if (!restoredRecordId || nextDocuments.records[restoredRecordId]) {
6637
+ if (!restoredRecordId || rtlOwn(nextDocuments.records, restoredRecordId)) {
5431
6638
  reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
5432
6639
  break;
5433
6640
  }
5434
6641
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', restoredRecordId);
6642
+ if (requireLifecycleGeneration !== false) {
6643
+ const lifecycleTombstone = rtlGetLifecycleTombstone(
6644
+ nextDocuments,
6645
+ 'record',
6646
+ restoredRecordId
6647
+ );
6648
+ if (!lifecycleTombstone ||
6649
+ Number(lifecycleTombstone.lifecycleGeneration) !== Number(trashEntry.lifecycleGeneration)) {
6650
+ reason = 'lifecycle_conflict';
6651
+ break;
6652
+ }
6653
+ }
5435
6654
  // Explicit legacy compatibility is planner-local. It does not change the
5436
6655
  // original operation, its wire bytes, or the strict durable reducer contract.
5437
6656
  const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
@@ -5442,11 +6661,16 @@ export const planFirestoreV2OperationChanges = ({
5442
6661
  operation: reducerOperation,
5443
6662
  now: operationNow
5444
6663
  });
5445
- const restoredRecord = applied.documents.records[restoredRecordId];
6664
+ const restoredRecord = rtlOwn(applied.documents.records, restoredRecordId)
6665
+ ? applied.documents.records[restoredRecordId]
6666
+ : null;
5446
6667
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
5447
6668
  const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
5448
- const targetFolder = nextDocuments.folders[targetFolderId] ||
5449
- nextDocuments.folders[DEFAULT_FOLDER_ID];
6669
+ const targetFolder = (rtlOwn(nextDocuments.folders, targetFolderId)
6670
+ ? nextDocuments.folders[targetFolderId]
6671
+ : null) || (rtlOwn(nextDocuments.folders, DEFAULT_FOLDER_ID)
6672
+ ? nextDocuments.folders[DEFAULT_FOLDER_ID]
6673
+ : null);
5450
6674
  if (!targetFolder) { reason = 'folder_not_found'; break; }
5451
6675
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5452
6676
  nextDocuments.records[restoredRecordId] = restoredRecord;
@@ -5462,8 +6686,10 @@ export const planFirestoreV2OperationChanges = ({
5462
6686
  break;
5463
6687
  }
5464
6688
  case OPERATION_TYPES.TRASH_PURGE: {
5465
- const trashEntryId = rtlOperationTrashEntryId(operation);
5466
- const trashEntry = nextDocuments.trash[trashEntryId];
6689
+ const purgeTrashEntryId = trashEntryId;
6690
+ const trashEntry = rtlOwn(nextDocuments.trash, purgeTrashEntryId)
6691
+ ? nextDocuments.trash[purgeTrashEntryId]
6692
+ : null;
5467
6693
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
5468
6694
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
5469
6695
  if (requireLifecycleGeneration !== false && expectedGeneration === null) {
@@ -5474,20 +6700,24 @@ export const planFirestoreV2OperationChanges = ({
5474
6700
  reason = 'lifecycle_conflict';
5475
6701
  break;
5476
6702
  }
5477
- delete nextDocuments.trash[trashEntryId];
6703
+ delete nextDocuments.trash[purgeTrashEntryId];
5478
6704
  changed = true;
5479
6705
  break;
5480
6706
  }
5481
6707
  case OPERATION_TYPES.FOLDER_CREATE:
5482
6708
  case OPERATION_TYPES.FOLDER_UPDATE: {
5483
6709
  if (!folderId) { reason = 'missing_folder_id'; break; }
5484
- if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders[folderId]) {
6710
+ if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !rtlOwn(nextDocuments.folders, folderId)) {
5485
6711
  reason = 'folder_not_found';
5486
6712
  break;
5487
6713
  }
5488
- const previousOrder = nextDocuments.folders[folderId]?.recordOrder || [];
6714
+ const previousOrder = rtlOwn(nextDocuments.folders, folderId)
6715
+ ? nextDocuments.folders[folderId]?.recordOrder || []
6716
+ : [];
5489
6717
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5490
- const folderDocument = applied.documents.folders[folderId];
6718
+ const folderDocument = rtlOwn(applied.documents.folders, folderId)
6719
+ ? applied.documents.folders[folderId]
6720
+ : null;
5491
6721
  if (!folderDocument) { reason = 'folder_not_found'; break; }
5492
6722
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5493
6723
  nextDocuments.folders[folderId] = {
@@ -5507,27 +6737,37 @@ export const planFirestoreV2OperationChanges = ({
5507
6737
  case OPERATION_TYPES.FOLDER_REORDER:
5508
6738
  case OPERATION_TYPES.GROUP_REORDER:
5509
6739
  case OPERATION_TYPES.SETTINGS_UPDATE: {
6740
+ if (operation.type === OPERATION_TYPES.FOLDER_REORDER &&
6741
+ rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['folderOrder', 'order']))) {
6742
+ reason = 'invalid_document_id';
6743
+ break;
6744
+ }
6745
+ if (operation.type === OPERATION_TYPES.GROUP_REORDER &&
6746
+ rtlHasUnsafeOrderId(rtlPayloadOrder(operation, ['groupOrder', 'order']), {opaqueGroup: true})) {
6747
+ reason = 'invalid_document_id';
6748
+ break;
6749
+ }
5510
6750
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5511
6751
  const appliedRoot = applied.documents.root;
5512
6752
  if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
5513
6753
  appliedRoot.folderOrder = rtlMergeOrder(
5514
6754
  rtlPayloadOrder(operation, ['folderOrder', 'order']),
5515
6755
  nextDocuments.root.folderOrder,
5516
- normalizedLocalState.folderOrder
6756
+ ...(allowContext ? [normalizedLocalState.folderOrder] : [])
5517
6757
  );
5518
6758
  } else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
5519
- appliedRoot.groupOrder = rtlNormalizeGroupOrder(rtlMergeOrder(
6759
+ appliedRoot.groupOrder = rtlMergeOpaqueOrder(
5520
6760
  rtlPayloadOrder(operation, ['groupOrder', 'order']),
5521
6761
  nextDocuments.root.groupOrder,
5522
- normalizedLocalState.groupOrder
5523
- ));
6762
+ ...(allowContext ? [normalizedLocalState.groupOrder] : [])
6763
+ );
5524
6764
  }
5525
6765
  rtlSetRoot(nextDocuments.root, appliedRoot);
5526
6766
  changed = true;
5527
6767
  break;
5528
6768
  }
5529
6769
  default:
5530
- reason = 'unsupported_operation';
6770
+ if (!reason) reason = 'unsupported_operation';
5531
6771
  }
5532
6772
  if (applied && !reason && lifecycleTombstoneId) {
5533
6773
  const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
@@ -5541,6 +6781,7 @@ export const planFirestoreV2OperationChanges = ({
5541
6781
  id: operation.id || null,
5542
6782
  type: operation.type || null,
5543
6783
  applied: !reason,
6784
+ ...(reason === 'already_deleted' ? {status: 'noop'} : {}),
5544
6785
  reason
5545
6786
  });
5546
6787
  }
@@ -5562,6 +6803,7 @@ export const planFirestoreV2OperationChanges = ({
5562
6803
  });
5563
6804
  return {
5564
6805
  requiresMigration: false,
6806
+ plannerMode: selectedPlannerMode,
5565
6807
  previousDocuments,
5566
6808
  nextDocuments,
5567
6809
  changes,
@@ -5569,6 +6811,19 @@ export const planFirestoreV2OperationChanges = ({
5569
6811
  };
5570
6812
  };
5571
6813
 
6814
+ // Dedicated API boundary for gateways: deterministic planning cannot inspect
6815
+ // mutable host/local context and always uses the immutable-ID contract.
6816
+ export const planRecordTimeLabelDeterministicOperationChanges = (options = {}) => (
6817
+ planFirestoreV2OperationChanges({
6818
+ ...options,
6819
+ plannerMode: RECORD_TIMELABEL_PLANNER_MODES.DETERMINISTIC,
6820
+ localState: {}
6821
+ })
6822
+ );
6823
+ // Canonical name used by the Firestore gateway capability probe.
6824
+ export const planFirestoreV2DeterministicOperationChanges =
6825
+ planRecordTimeLabelDeterministicOperationChanges;
6826
+
5572
6827
  /**
5573
6828
  * Counts all billable writes before a transaction reserves quota.
5574
6829
  */
@@ -5759,10 +7014,15 @@ export default {
5759
7014
  isValidChannelName,
5760
7015
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
5761
7016
  RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
7017
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
5762
7018
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
7019
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
5763
7020
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
5764
7021
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
7022
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
7023
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
5765
7024
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
7025
+ isRecordTimeLabelOperationConflictFailure,
5766
7026
  normalizeRecordTimeLabelCloudFailure,
5767
7027
  toRecordTimeLabelCloudFailureError,
5768
7028
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
@@ -5774,6 +7034,11 @@ export default {
5774
7034
  RTL_MAX_REQUEST_BYTES,
5775
7035
  RTL_MAX_TARGET_WRITES,
5776
7036
  RTL_TRASH_RETENTION_MS,
7037
+ RECORD_TIMELABEL_PLANNER_MODES,
7038
+ RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
7039
+ isRecordTimeLabelSafeDocumentId,
7040
+ normalizeRecordTimeLabelImmutableId,
7041
+ normalizeRecordTimeLabelPlannerId,
5777
7042
  OPERATION_TYPES,
5778
7043
  RECORD_TIMELABEL_SYNC_MODES,
5779
7044
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
@@ -5812,6 +7077,8 @@ export default {
5812
7077
  extendFirestoreV2OperationReadPlanWithRecords,
5813
7078
  extendFirestoreV2OperationReadPlanWithTrash,
5814
7079
  planFirestoreV2OperationChanges,
7080
+ planRecordTimeLabelDeterministicOperationChanges,
7081
+ planFirestoreV2DeterministicOperationChanges,
5815
7082
  estimateFirestoreV2WriteUnits,
5816
7083
  normalizeRecordTimeLabelOperationResults,
5817
7084
  normalizeRecordTimeLabelEnvelopeResponse,
@@ -5822,6 +7089,7 @@ export default {
5822
7089
  flushPendingOperations,
5823
7090
  mergeRemoteStateIntoLocal,
5824
7091
  applyOperation,
7092
+ applyExplicitRecordUpdate,
5825
7093
  applyRecordTimeLabelOperation,
5826
7094
  createOperation,
5827
7095
  createRecordTimeLabelSyncEngine,