@recordtimelabel/core 0.6.1 → 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,26 +1,48 @@
1
1
  import {
2
+ RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
3
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
2
4
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
5
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
3
6
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
4
7
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
8
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
9
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
10
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
5
11
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
6
12
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
7
13
  buildRecordTimeLabelRequestId,
8
14
  createRecordTimeLabelTransportFailureResults,
15
+ isRecordTimeLabelOperationConflictFailure,
16
+ normalizeRecordTimeLabelCloudFailure,
9
17
  normalizeRecordTimeLabelEnvelopeResponse,
10
18
  normalizeRecordTimeLabelOperationResults,
19
+ normalizeRecordTimeLabelImmutableId,
20
+ normalizeRecordTimeLabelPlannerId,
21
+ toRecordTimeLabelCloudFailureError,
11
22
  toRecordTimeLabelWireOperation
12
23
  } from './protocol.js';
13
24
 
14
25
  export {
26
+ RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
27
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
15
28
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
29
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
16
30
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
17
31
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
32
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
33
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
34
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
18
35
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
19
36
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
20
37
  buildRecordTimeLabelRequestId,
21
38
  createRecordTimeLabelTransportFailureResults,
39
+ isRecordTimeLabelOperationConflictFailure,
40
+ normalizeRecordTimeLabelCloudFailure,
22
41
  normalizeRecordTimeLabelEnvelopeResponse,
23
42
  normalizeRecordTimeLabelOperationResults,
43
+ normalizeRecordTimeLabelImmutableId,
44
+ normalizeRecordTimeLabelPlannerId,
45
+ toRecordTimeLabelCloudFailureError,
24
46
  toRecordTimeLabelWireOperation
25
47
  } from './protocol.js';
26
48
 
@@ -119,7 +141,7 @@ export {
119
141
  selectBestMatchingTwitchVod
120
142
  };
121
143
 
122
- export const RECORD_TIMELABEL_CORE_VERSION = '0.6.1';
144
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.4';
123
145
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
124
146
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
125
147
  'fifo-retry-fence',
@@ -128,7 +150,11 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
128
150
  'remote-subscription-readiness',
129
151
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
130
152
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
131
- RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
153
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
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
132
158
  ]);
133
159
  export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
134
160
  export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
@@ -136,6 +162,11 @@ export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
136
162
  export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
137
163
  export const RTL_MAX_TARGET_WRITES = 100;
138
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;
139
170
 
140
171
  export const OPERATION_TYPES = Object.freeze({
141
172
  RECORD_CREATE: 'record.create',
@@ -186,6 +217,7 @@ export const RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS = Object.freeze({
186
217
  OWNER_MISMATCH: 'operation_owner_mismatch',
187
218
  WORKSPACE_EPOCH_MISMATCH: 'operation_workspace_epoch_mismatch',
188
219
  ID_CONFLICT: 'operation_id_conflict',
220
+ INVALID_DOCUMENT_ID: 'invalid_document_id',
189
221
  LIFECYCLE_GENERATION_REQUIRED: 'lifecycle_generation_required',
190
222
  LIFECYCLE_CONFLICT: 'lifecycle_conflict'
191
223
  });
@@ -202,7 +234,20 @@ const clone = (value, seen = new WeakMap()) => {
202
234
  const copy = Array.isArray(value) ? [] : {};
203
235
  seen.set(value, copy);
204
236
  Reflect.ownKeys(value).forEach((key) => {
205
- 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
+ });
206
251
  });
207
252
  return copy;
208
253
  };
@@ -275,9 +320,21 @@ const normalizeId = (value) => {
275
320
  return String(value).trim();
276
321
  };
277
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
+
278
333
  const safeFolderId = (folderId) => {
279
334
  const normalized = normalizeId(folderId);
280
- 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
+ }
281
338
  return normalized;
282
339
  };
283
340
 
@@ -307,7 +364,7 @@ const normalizeTombstones = (value) => {
307
364
 
308
365
  return entries.reduce((result, [rawId, rawTombstone]) => {
309
366
  const id = normalizeId(rawId);
310
- if (!id) return result;
367
+ if (!id || !rtlIsSafeDocumentId(id)) return result;
311
368
  const tombstone = rawTombstone && typeof rawTombstone === 'object'
312
369
  ? { ...rawTombstone }
313
370
  : {};
@@ -332,7 +389,8 @@ const normalizeTrashEntries = (value) => {
332
389
  const id = normalizeId(rawId || rawEntry.id);
333
390
  const kind = normalizeId(rawEntry.kind);
334
391
  const entityId = normalizeId(rawEntry.entityId);
335
- 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;
336
394
  const deletedAt = toFiniteTimestamp(rawEntry.deletedAt);
337
395
  const purgeAt = toFiniteTimestamp(rawEntry.purgeAt) ||
338
396
  ((deletedAt || Date.now()) + RTL_TRASH_RETENTION_MS);
@@ -357,7 +415,7 @@ export const getActiveTrashEntries = (value, now = Date.now()) => Object.values(
357
415
  const mergeTombstones = (left = {}, right = {}) => {
358
416
  const merged = { ...left };
359
417
  Object.entries(right).forEach(([id, tombstone]) => {
360
- const current = merged[id];
418
+ const current = rtlOwn(merged, id) ? merged[id] : null;
361
419
  const currentGeneration = Number(current?.lifecycleGeneration || 0);
362
420
  const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
363
421
  if (!current || nextGeneration > currentGeneration ||
@@ -372,7 +430,7 @@ const mergeTombstones = (left = {}, right = {}) => {
372
430
  const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
373
431
  const merged = normalizeTrashEntries(remoteEntries);
374
432
  Object.entries(normalizeTrashEntries(localEntries)).forEach(([id, entry]) => {
375
- const current = merged[id];
433
+ const current = rtlOwn(merged, id) ? merged[id] : null;
376
434
  const currentVersion = Number(current?.lifecycleGeneration || 0);
377
435
  const nextVersion = Number(entry.lifecycleGeneration || 0);
378
436
  if (!current || nextVersion > currentVersion ||
@@ -508,10 +566,37 @@ export const mergeRecords = (left, right) => {
508
566
  }
509
567
  });
510
568
 
511
- if (left.hasVod || right.hasVod) merged.hasVod = true;
512
569
  return merged;
513
570
  };
514
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
+
515
600
  export const normalizeRecords = (records = {}, options = {}) => {
516
601
  const source = records && typeof records === 'object' ? records : {};
517
602
  const tombstones = normalizeTombstones(options.deletedRecordTombstones);
@@ -524,7 +609,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
524
609
  safeList.forEach((record, index) => {
525
610
  if (!record || typeof record !== 'object') return;
526
611
  const id = normalizeId(record.id) || normalizeId(record.recordId);
527
- if (!id) return;
612
+ if (!id || !rtlIsSafeDocumentId(id)) return;
528
613
 
529
614
  const targetFolderId = resolveRecordFolderId(folderId, record);
530
615
 
@@ -540,7 +625,7 @@ export const normalizeRecords = (records = {}, options = {}) => {
540
625
  const folderTombstone = folderTombstones[targetFolderId];
541
626
  if (folderTombstone) return;
542
627
 
543
- if (!normalized[targetFolderId]) normalized[targetFolderId] = [];
628
+ if (!rtlOwn(normalized, targetFolderId)) normalized[targetFolderId] = [];
544
629
 
545
630
  const existing = placements.get(id);
546
631
  if (!existing) {
@@ -589,7 +674,7 @@ const ensureFolders = (folders = []) => {
589
674
  toArray(folders).forEach((folder) => {
590
675
  if (!folder || typeof folder !== 'object') return;
591
676
  const id = normalizeId(folder.id);
592
- if (!id || seen.has(id)) return;
677
+ if (!id || !rtlIsSafeDocumentId(id) || seen.has(id)) return;
593
678
  seen.add(id);
594
679
  nextFolders.push({ ...folder, id });
595
680
  });
@@ -616,10 +701,10 @@ const ensureFolders = (folders = []) => {
616
701
  const ensureRecordFolders = (records, folders) => {
617
702
  const nextRecords = { ...records };
618
703
  folders.forEach((folder) => {
619
- if (!nextRecords[folder.id]) nextRecords[folder.id] = [];
704
+ if (!rtlOwn(nextRecords, folder.id)) nextRecords[folder.id] = [];
620
705
  });
621
- if (!nextRecords.all) nextRecords.all = [];
622
- 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] = [];
623
708
  nextRecords.all = [];
624
709
  return nextRecords;
625
710
  };
@@ -915,8 +1000,12 @@ export const applyOperation = (state = {}, operation = {}) => {
915
1000
  const record = payload.record && typeof payload.record === 'object' ? { ...payload.record } : null;
916
1001
  if (!record) return normalized;
917
1002
  const recordId = normalizeId(record.id || payload.recordId);
918
- if (!recordId) return normalized;
919
- 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;
920
1009
  if (tombstone) return normalized;
921
1010
 
922
1011
  const folderId = safeFolderId(payload.folderId || record.folderId);
@@ -937,24 +1026,25 @@ export const applyOperation = (state = {}, operation = {}) => {
937
1026
 
938
1027
  case OPERATION_TYPES.RECORD_UPDATE: {
939
1028
  const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
940
- if (!recordId) return normalized;
1029
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
941
1030
  const entry = findRecordEntry(nextState.records, recordId);
942
1031
  if (!entry) return normalized;
943
1032
  const patch = payload.patch || payload.record || {};
944
- const updatedRecord = mergeRecords(entry.record, {
945
- ...entry.record,
946
- ...patch,
947
- id: recordId,
948
- updatedAt: patch.updatedAt || operationTime
949
- });
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;
950
1038
  nextState.records[entry.folderId][entry.index] = updatedRecord;
951
1039
  break;
952
1040
  }
953
1041
 
954
1042
  case OPERATION_TYPES.RECORD_MOVE: {
955
1043
  const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
956
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
957
- 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);
958
1048
  const entry = findRecordEntry(nextState.records, recordId);
959
1049
  if (!entry) return normalized;
960
1050
  const movedRecord = {
@@ -972,7 +1062,9 @@ export const applyOperation = (state = {}, operation = {}) => {
972
1062
  }
973
1063
 
974
1064
  case OPERATION_TYPES.RECORD_REORDER: {
975
- const folderId = safeFolderId(payload.folderId);
1065
+ const rawFolderId = normalizeId(payload.folderId);
1066
+ if (rawFolderId && !rtlIsSafeDocumentId(rawFolderId)) return normalized;
1067
+ const folderId = safeFolderId(rawFolderId);
976
1068
  const existingFolderRecords = toArray(nextState.records?.[folderId]);
977
1069
  const payloadRecords = toArray(payload.records);
978
1070
  const recordsById = new Map();
@@ -998,6 +1090,7 @@ export const applyOperation = (state = {}, operation = {}) => {
998
1090
  ? payload.recordIds
999
1091
  : payloadRecords.map((record) => getRecordId(record))
1000
1092
  );
1093
+ if (orderedIds.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
1001
1094
  if (orderedIds.length === 0) return normalized;
1002
1095
 
1003
1096
  const seen = new Set();
@@ -1021,13 +1114,21 @@ export const applyOperation = (state = {}, operation = {}) => {
1021
1114
 
1022
1115
  case OPERATION_TYPES.RECORD_DELETE: {
1023
1116
  const recordId = normalizeId(payload.recordId || payload.id);
1024
- if (!recordId) return normalized;
1117
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return normalized;
1025
1118
  const existingEntry = findRecordEntry(nextState.records, recordId);
1026
1119
  const recordSnapshot = payload.record && typeof payload.record === 'object'
1027
1120
  ? {...payload.record, id: recordId}
1028
1121
  : existingEntry?.record;
1029
1122
  const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
1030
- 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
+ }
1031
1132
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
1032
1133
  const previousGeneration = Math.max(
1033
1134
  Number(recordSnapshot?.lifecycleGeneration || 0),
@@ -1062,16 +1163,29 @@ export const applyOperation = (state = {}, operation = {}) => {
1062
1163
  }
1063
1164
 
1064
1165
  case OPERATION_TYPES.RECORD_RESTORE: {
1166
+ const restoreRecordId = normalizeId(payload.recordId || payload.id);
1065
1167
  const trashEntryId = normalizeId(payload.trashEntryId) ||
1066
- `record:${normalizeId(payload.recordId || payload.id)}`;
1067
- 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;
1068
1173
  if (!trashEntry || trashEntry.kind !== 'record') return normalized;
1069
1174
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
1070
1175
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
1071
1176
  if (expectedGeneration === null ||
1072
1177
  expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
1073
1178
  const recordId = normalizeId(trashEntry.entityId);
1074
- 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
+ }
1075
1189
  const recordSnapshot = trashEntry.payload?.record;
1076
1190
  if (!recordSnapshot) return normalized;
1077
1191
  const originalFolderId = safeFolderId(trashEntry.originalFolderId);
@@ -1100,8 +1214,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1100
1214
  case OPERATION_TYPES.FOLDER_CREATE: {
1101
1215
  const folder = payload.folder && typeof payload.folder === 'object' ? { ...payload.folder } : null;
1102
1216
  const folderId = normalizeId(folder?.id || payload.folderId);
1103
- if (!folderId) return normalized;
1104
- 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;
1105
1220
  const existingIndex = nextState.folders.findIndex((item) => item.id === folderId);
1106
1221
  const nextFolder = {
1107
1222
  ...(folder || {}),
@@ -1119,7 +1234,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1119
1234
 
1120
1235
  case OPERATION_TYPES.FOLDER_UPDATE: {
1121
1236
  const folderId = normalizeId(payload.folderId || payload.id || payload.folder?.id);
1122
- if (!folderId) return normalized;
1237
+ if (!folderId || !rtlIsSafeDocumentId(folderId)) return normalized;
1123
1238
  const patch = payload.patch || payload.folder || {};
1124
1239
  nextState.folders = nextState.folders.map((folder) => (
1125
1240
  folder.id === folderId
@@ -1131,12 +1246,24 @@ export const applyOperation = (state = {}, operation = {}) => {
1131
1246
 
1132
1247
  case OPERATION_TYPES.FOLDER_DELETE: {
1133
1248
  const folderId = normalizeId(payload.folderId || payload.id);
1134
- if (!folderId || folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
1249
+ if (!folderId || !rtlIsSafeDocumentId(folderId) ||
1250
+ folderId === 'all' || folderId === DEFAULT_FOLDER_ID) return normalized;
1135
1251
  const folderSnapshot = payload.folder && typeof payload.folder === 'object'
1136
1252
  ? {...payload.folder, id: folderId}
1137
1253
  : nextState.folders.find((folder) => folder.id === folderId);
1138
1254
  const folderRecords = toArray(payload.records || nextState.records[folderId]).map((record) => ({...record}));
1139
- 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
+ }
1140
1267
  const deletedAt = toFiniteTimestamp(payload.deletedAt) || operationTime;
1141
1268
  const previousGeneration = Math.max(
1142
1269
  Number(folderSnapshot?.lifecycleGeneration || 0),
@@ -1145,6 +1272,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1145
1272
  const lifecycleGeneration = previousGeneration + 1;
1146
1273
  if (folderSnapshot) {
1147
1274
  const trashEntryId = normalizeId(payload.trashEntryId) || `folder:${folderId}`;
1275
+ if (!rtlIsSafeDocumentId(trashEntryId)) return normalized;
1148
1276
  nextState.trashEntries[trashEntryId] = {
1149
1277
  id: trashEntryId,
1150
1278
  kind: 'folder',
@@ -1178,7 +1306,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1178
1306
  };
1179
1307
  folderRecords.forEach((record) => {
1180
1308
  const recordId = getRecordId(record);
1181
- if (!recordId) return;
1309
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return;
1182
1310
  nextState.deletedRecordTombstones[recordId] = {
1183
1311
  id: recordId,
1184
1312
  folderId,
@@ -1192,16 +1320,28 @@ export const applyOperation = (state = {}, operation = {}) => {
1192
1320
  }
1193
1321
 
1194
1322
  case OPERATION_TYPES.FOLDER_RESTORE: {
1323
+ const restoreFolderId = normalizeId(payload.folderId || payload.id);
1195
1324
  const trashEntryId = normalizeId(payload.trashEntryId) ||
1196
- `folder:${normalizeId(payload.folderId || payload.id)}`;
1197
- 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;
1198
1330
  if (!trashEntry || trashEntry.kind !== 'folder') return normalized;
1199
1331
  if (toFiniteTimestamp(trashEntry.purgeAt) <= operationTime) return normalized;
1200
1332
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
1201
1333
  if (expectedGeneration === null ||
1202
1334
  expectedGeneration !== Number(trashEntry.lifecycleGeneration)) return normalized;
1203
1335
  const folderId = normalizeId(trashEntry.entityId);
1204
- 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
+ }
1205
1345
  const folderSnapshot = trashEntry.payload?.folder;
1206
1346
  if (!folderSnapshot) return normalized;
1207
1347
  const lifecycleGeneration = Number(trashEntry.lifecycleGeneration || 0) + 1;
@@ -1290,6 +1430,7 @@ export const applyOperation = (state = {}, operation = {}) => {
1290
1430
 
1291
1431
  case OPERATION_TYPES.FOLDER_REORDER:
1292
1432
  nextState.folderOrder = normalizeOrder(payload.folderOrder || payload.order);
1433
+ if (nextState.folderOrder.some((id) => !rtlIsSafeDocumentId(id))) return normalized;
1293
1434
  break;
1294
1435
 
1295
1436
  case OPERATION_TYPES.GROUP_REORDER:
@@ -1397,6 +1538,11 @@ export const mergeLocalRemote = ({
1397
1538
  });
1398
1539
  });
1399
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.
1400
1546
  toArray(pendingOps).forEach((operation) => {
1401
1547
  mergedState = applyOperation(mergedState, operation);
1402
1548
  });
@@ -1731,7 +1877,9 @@ const reorderV2RecordsByFolderRecordOrder = (records = {}, recordOrderByFolder =
1731
1877
 
1732
1878
  const cleanV2RecordDocument = (record = {}, folderId, now) => {
1733
1879
  const recordId = normalizeId(record.id);
1734
- if (!recordId) return null;
1880
+ if (!recordId || !rtlIsSafeDocumentId(recordId)) return null;
1881
+ const normalizedFolderId = normalizeId(folderId);
1882
+ if (normalizedFolderId && !rtlIsSafeDocumentId(normalizedFolderId)) return null;
1735
1883
  const cleanRecord = { ...record, id: recordId, folderId: safeFolderId(folderId) };
1736
1884
  delete cleanRecord.pendingSync;
1737
1885
  delete cleanRecord.syncAttempts;
@@ -1744,7 +1892,7 @@ const cleanV2RecordDocument = (record = {}, folderId, now) => {
1744
1892
 
1745
1893
  const cleanV2FolderDocument = (folder = {}, now) => {
1746
1894
  const folderId = normalizeId(folder.id);
1747
- if (!folderId) return null;
1895
+ if (!folderId || !rtlIsSafeDocumentId(folderId)) return null;
1748
1896
  const {
1749
1897
  schemaVersion,
1750
1898
  recordOrder,
@@ -1762,7 +1910,7 @@ const cleanV2FolderDocument = (folder = {}, now) => {
1762
1910
 
1763
1911
  const cleanV2OperationDocument = (operation = {}, now) => {
1764
1912
  const operationId = normalizeId(operation.id);
1765
- if (!operationId || !operation.type) return null;
1913
+ if (!operationId || !rtlIsSafeDocumentId(operationId) || !operation.type) return null;
1766
1914
  return {
1767
1915
  ...operation,
1768
1916
  id: operationId,
@@ -1774,7 +1922,8 @@ const cleanV2OperationDocument = (operation = {}, now) => {
1774
1922
  const cleanV2TrashDocument = (entry = {}, now) => {
1775
1923
  const normalized = normalizeTrashEntries({[entry?.id || '']: entry});
1776
1924
  const trashEntry = Object.values(normalized)[0];
1777
- if (!trashEntry) return null;
1925
+ if (!trashEntry || !rtlIsSafeDocumentId(trashEntry.id) ||
1926
+ !rtlIsSafeDocumentId(trashEntry.entityId)) return null;
1778
1927
  return {
1779
1928
  ...trashEntry,
1780
1929
  deletedAt: trashEntry.deletedAt || now,
@@ -1790,6 +1939,7 @@ const buildV2LifecycleTombstoneDocuments = (
1790
1939
  const documents = {};
1791
1940
  const append = (kind, tombstones) => {
1792
1941
  Object.entries(normalizeTombstones(tombstones)).forEach(([entityId, tombstone]) => {
1942
+ if (!rtlIsSafeDocumentId(entityId)) return;
1793
1943
  const id = `${kind}:${entityId}`;
1794
1944
  documents[encodeURIComponent(id)] = {
1795
1945
  id,
@@ -3001,6 +3151,168 @@ const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
3001
3151
 
3002
3152
  const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
3003
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
+
3004
3316
  const rtlToFiniteNumber = (value, fallback = 0) => {
3005
3317
  const number = Number(value);
3006
3318
  return Number.isFinite(number) ? number : fallback;
@@ -3026,12 +3338,26 @@ const rtlNormalizeWorkspaceEpoch = (value, fallback = 0) => {
3026
3338
 
3027
3339
  const rtlCloneOperation = (operation) => clone(operation && typeof operation === 'object' ? operation : {});
3028
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
+
3029
3355
  const rtlStripSessionTokens = (value) => {
3030
3356
  if (Array.isArray(value)) return value.map((entry) => rtlStripSessionTokens(entry));
3031
3357
  if (!rtlDurableIsObject(value)) return value;
3032
3358
  const result = {};
3033
3359
  Object.entries(value).forEach(([key, entry]) => {
3034
- if (key === 'sessionToken' || key === 'sessionTokenId' || key === 'authToken') return;
3360
+ if (rtlIsCredentialKey(key)) return;
3035
3361
  result[key] = rtlStripSessionTokens(entry);
3036
3362
  });
3037
3363
  return result;
@@ -3047,35 +3373,6 @@ const rtlNormalizeOperation = (operation = {}, {
3047
3373
  const input = operation && typeof operation === 'object' ? operation : {};
3048
3374
  const payload = rtlDurableIsObject(input.payload) ? clone(input.payload) : {};
3049
3375
  const normalizedPayload = payload;
3050
- const trimPayloadId = (key) => {
3051
- if (Object.prototype.hasOwnProperty.call(normalizedPayload, key)) {
3052
- normalizedPayload[key] = normalizeId(normalizedPayload[key]) || normalizedPayload[key];
3053
- }
3054
- };
3055
- [
3056
- 'recordId', 'folderId', 'targetFolderId', 'trashEntryId', 'batchId', 'id',
3057
- 'clientInstanceId', 'instanceId'
3058
- ].forEach(trimPayloadId);
3059
- ['recordIds', 'folderOrder', 'groupOrder', 'expandedGroups', 'groupIds', 'ids'].forEach((key) => {
3060
- if (Array.isArray(normalizedPayload[key])) {
3061
- normalizedPayload[key] = normalizeIdList(normalizedPayload[key]);
3062
- }
3063
- });
3064
- if (rtlDurableIsObject(normalizedPayload.record)) {
3065
- normalizedPayload.record = clone(normalizedPayload.record);
3066
- if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'id')) {
3067
- normalizedPayload.record.id = normalizeId(normalizedPayload.record.id) || normalizedPayload.record.id;
3068
- }
3069
- if (Object.prototype.hasOwnProperty.call(normalizedPayload.record, 'folderId')) {
3070
- normalizedPayload.record.folderId = normalizeId(normalizedPayload.record.folderId) || normalizedPayload.record.folderId;
3071
- }
3072
- }
3073
- if (rtlDurableIsObject(normalizedPayload.folder)) {
3074
- normalizedPayload.folder = clone(normalizedPayload.folder);
3075
- if (Object.prototype.hasOwnProperty.call(normalizedPayload.folder, 'id')) {
3076
- normalizedPayload.folder.id = normalizeId(normalizedPayload.folder.id) || normalizedPayload.folder.id;
3077
- }
3078
- }
3079
3376
 
3080
3377
  const operationNow = rtlToFiniteNumber(input.createdAt, NaN);
3081
3378
  const resolvedNow = Number.isFinite(operationNow)
@@ -3090,7 +3387,9 @@ const rtlNormalizeOperation = (operation = {}, {
3090
3387
 
3091
3388
  return {
3092
3389
  ...rtlStripSessionTokens(input),
3093
- 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,
3094
3393
  type: typeof input.type === 'string' ? input.type.trim() : input.type,
3095
3394
  payload: normalizedPayload,
3096
3395
  clientId: resolvedClientId,
@@ -3123,10 +3422,12 @@ const rtlNormalizeRemoteBaseline = (value = {}) => {
3123
3422
  const baseline = rtlDurableIsObject(value) ? value : {};
3124
3423
  return {
3125
3424
  state: normalizeRecordTimeLabelDomainState(baseline.state || baseline.data || {}),
3126
- revision: Math.max(0, rtlToFiniteNumber(baseline.revision, 0)),
3425
+ revision: rtlRawNonNegativeSafeInteger(baseline.revision) ? baseline.revision : 0,
3127
3426
  changeCursor: baseline.changeCursor === undefined || baseline.changeCursor === null
3128
3427
  ? null
3129
- : String(baseline.changeCursor)
3428
+ : rtlDurableIsObject(baseline.changeCursor)
3429
+ ? clone(baseline.changeCursor)
3430
+ : String(baseline.changeCursor)
3130
3431
  };
3131
3432
  };
3132
3433
 
@@ -3203,6 +3504,29 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3203
3504
  };
3204
3505
 
3205
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
+ }
3206
3530
  const operationOwnerUid = rtlNormalizeUid(operation?.ownerUid);
3207
3531
  const workspaceOwnerUid = rtlNormalizeUid(workspace?.ownerUid);
3208
3532
  if (operationOwnerUid && operationOwnerUid !== workspaceOwnerUid) {
@@ -3354,12 +3678,17 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3354
3678
  } catch {
3355
3679
  current = null;
3356
3680
  }
3357
- // A session port is intentionally small and adapters use both token and
3358
- // sessionToken spellings. Preserve the original token (including objects)
3359
- // for isCurrent, but never put it in a workspace blob.
3360
- const token = current && typeof current === 'object'
3361
- ? (current.sessionToken ?? current.token ?? current.id ?? current)
3362
- : current;
3681
+ // SessionPort.sessionToken is an opaque fence for equality only. Never treat
3682
+ // current.id or the whole session object as an authorization credential.
3683
+ const sessionToken = current && typeof current === 'object' &&
3684
+ Object.prototype.hasOwnProperty.call(current, 'sessionToken')
3685
+ ? current.sessionToken
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;
3363
3692
  const uid = rtlNormalizeUid(current && typeof current === 'object'
3364
3693
  ? (current.uid ?? current.ownerUid)
3365
3694
  : null);
@@ -3370,8 +3699,10 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3370
3699
  return {
3371
3700
  current,
3372
3701
  hasSession: typeof session?.current === 'function',
3373
- token,
3374
- sessionToken: token,
3702
+ sessionToken,
3703
+ authSessionBinding: typeof authSessionBinding === 'string' && authSessionBinding.trim()
3704
+ ? authSessionBinding
3705
+ : null,
3375
3706
  uid,
3376
3707
  workspaceEpoch: hasEpoch
3377
3708
  ? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
@@ -3380,9 +3711,16 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
3380
3711
  };
3381
3712
  };
3382
3713
 
3714
+ const rtlSameSessionIdentity = (left, right) => (
3715
+ Boolean(left) &&
3716
+ Boolean(right) &&
3717
+ left.uid === right.uid &&
3718
+ Number(left.workspaceEpoch) === Number(right.workspaceEpoch) &&
3719
+ Object.is(left.sessionToken, right.sessionToken)
3720
+ );
3721
+
3383
3722
  const rtlSessionContext = (captured, workspace, client) => ({
3384
3723
  sessionToken: captured?.sessionToken ?? null,
3385
- token: captured?.token ?? null,
3386
3724
  uid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
3387
3725
  ownerUid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
3388
3726
  workspaceEpoch: workspace?.workspaceEpoch ?? captured?.workspaceEpoch ?? 0,
@@ -3390,18 +3728,54 @@ const rtlSessionContext = (captured, workspace, client) => ({
3390
3728
  });
3391
3729
 
3392
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
+ };
3393
3743
  if (value && typeof value === 'object') {
3394
- if (value.remoteBaseline) return value.remoteBaseline;
3395
- if (value.baseline) return value.baseline;
3396
- if (value.state !== undefined || value.data !== undefined || value.revision !== undefined || value.changeCursor !== undefined) {
3397
- 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;
3398
3769
  }
3399
- if (value.snapshot && typeof value.snapshot === 'object') return value.snapshot;
3400
3770
  // Adapters migrating from the v1 cloud port may still return a raw
3401
3771
  // normalized state. Treat the presence of state-owned keys as an implicit
3402
3772
  // baseline with the persisted revision.
3403
3773
  if (value.folders !== undefined || value.records !== undefined || value.settings !== undefined) {
3404
- 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
+ };
3405
3779
  }
3406
3780
  }
3407
3781
  return null;
@@ -3409,44 +3783,63 @@ const rtlEnvelopePayload = (value) => {
3409
3783
 
3410
3784
  const rtlEnvelopeSuccess = (value) => {
3411
3785
  if (!value || typeof value !== 'object') return false;
3412
- 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;
3413
3793
  return value.success === true || value.ok === true || (
3414
3794
  value.success === undefined && value.ok === undefined && !value.error
3415
3795
  );
3416
3796
  };
3417
3797
 
3418
- const rtlRetryableEnvelopeError = (value) => {
3419
- const error = value?.error && typeof value.error === 'object' ? value.error : value;
3420
- const status = Number(error?.status ?? error?.statusCode ?? value?.status ?? value?.statusCode);
3421
- const details = [
3422
- error?.name,
3423
- error?.code,
3424
- error?.reason,
3425
- error?.message,
3426
- value?.code,
3427
- value?.reason,
3428
- value?.message
3429
- ].filter(Boolean).join(' ').toLowerCase();
3430
- return Boolean(
3431
- error?.retryable === true ||
3432
- value?.retryable === true ||
3433
- status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 ||
3434
- details.includes('bulk_job_in_progress') || details.includes('retryable') ||
3435
- details.includes('temporarily_unavailable') || details.includes('unavailable') ||
3436
- details.includes('deadline_exceeded') || details.includes('failed to fetch') ||
3437
- details.includes('fetch failed') || details.includes('network-request-failed') ||
3438
- details.includes('network request failed') || details.includes('network_error') ||
3439
- details.includes('err_network') || details.includes('econnreset') ||
3440
- details.includes('etimedout')
3441
- );
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
+ };
3442
3837
  };
3443
3838
 
3444
- const rtlRetryAfterMs = (value) => {
3445
- const error = value?.error && typeof value.error === 'object' ? value.error : value;
3446
- const rawRetryAfter = error?.retryAfterMs ?? value?.retryAfterMs;
3447
- if (rawRetryAfter === null || rawRetryAfter === undefined || rawRetryAfter === '') return null;
3448
- const retryAfter = Number(rawRetryAfter);
3449
- 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;
3450
3843
  };
3451
3844
 
3452
3845
  const rtlResultOperationId = (result = {}) => normalizeId(
@@ -3516,6 +3909,7 @@ export const createRecordTimeLabelSyncEngine = ({
3516
3909
  })
3517
3910
  };
3518
3911
  let bootstrapAttemptSequence = 0;
3912
+ let authTransitionLatch = null;
3519
3913
  const listeners = new Set();
3520
3914
  let queue = Promise.resolve();
3521
3915
 
@@ -3553,6 +3947,12 @@ export const createRecordTimeLabelSyncEngine = ({
3553
3947
 
3554
3948
  const capture = () => rtlExtractSession(session, workspace.workspaceEpoch);
3555
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
+
3556
3956
  const isCurrent = async (captured) => {
3557
3957
  if (destroyed || !captured) return false;
3558
3958
  if (typeof session?.isCurrent === 'function') {
@@ -3568,18 +3968,34 @@ export const createRecordTimeLabelSyncEngine = ({
3568
3968
  }
3569
3969
  if (typeof session?.current !== 'function') return true;
3570
3970
  const current = rtlExtractSession(session, captured.workspaceEpoch);
3571
- if (captured.token !== undefined && captured.token !== null && current.token !== captured.token) return false;
3971
+ if (
3972
+ captured.sessionToken !== undefined &&
3973
+ captured.sessionToken !== null &&
3974
+ !Object.is(current.sessionToken, captured.sessionToken)
3975
+ ) return false;
3572
3976
  if (captured.uid !== current.uid) return false;
3573
3977
  return !captured.hasEpoch || current.workspaceEpoch === captured.workspaceEpoch;
3574
3978
  };
3575
3979
 
3576
3980
  const persist = async (candidate, captured) => {
3577
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);
3578
3994
  // The second argument is an optional adapter-side fence. The public
3579
3995
  // StoragePort remains compatible with save(workspace); adapters that can
3580
3996
  // enforce an atomic session/epoch check should consume this context before
3581
3997
  // mutating durable storage.
3582
- await storage.save(clone(candidate), {
3998
+ await storage.save(clone(durableCandidate), {
3583
3999
  sessionToken: captured?.sessionToken ?? null,
3584
4000
  uid: captured?.uid ?? null,
3585
4001
  ownerUid: captured?.uid ?? null,
@@ -3598,7 +4014,9 @@ export const createRecordTimeLabelSyncEngine = ({
3598
4014
  }
3599
4015
  const nextBaseline = rtlNormalizeRemoteBaseline({
3600
4016
  state: remote.state ?? remote.data ?? candidate.remoteBaseline.state,
3601
- revision: Number.isFinite(revision) ? revision : candidate.remoteBaseline.revision,
4017
+ revision: Number.isSafeInteger(revision) && revision >= 0
4018
+ ? revision
4019
+ : candidate.remoteBaseline.revision,
3602
4020
  changeCursor: Object.prototype.hasOwnProperty.call(remote, 'changeCursor')
3603
4021
  ? remote.changeCursor
3604
4022
  : candidate.remoteBaseline.changeCursor
@@ -3618,21 +4036,75 @@ export const createRecordTimeLabelSyncEngine = ({
3618
4036
 
3619
4037
  const normalizeBootstrapResponse = (response) => {
3620
4038
  if (!rtlEnvelopeSuccess(response)) {
3621
- const error = new Error(response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed');
3622
- error.code = response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed';
3623
- throw error;
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);
3624
4045
  }
3625
4046
  const remote = rtlEnvelopePayload(response);
3626
4047
  const revision = Number(remote?.revision);
3627
4048
  const state = remote?.state ?? remote?.data;
3628
- if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
3629
- const error = new Error('recordtimelabel_invalid_bootstrap_response');
3630
- error.code = 'recordtimelabel_invalid_bootstrap_response';
3631
- throw error;
4049
+ if (!remote || !rtlDurableIsObject(state) ||
4050
+ !Number.isSafeInteger(revision) || revision < 0) {
4051
+ throw toRecordTimeLabelCloudFailureError({
4052
+ code: 'recordtimelabel_invalid_bootstrap_response',
4053
+ reason: 'recordtimelabel_invalid_bootstrap_response',
4054
+ message: 'recordtimelabel_invalid_bootstrap_response',
4055
+ retryable: false
4056
+ });
3632
4057
  }
3633
4058
  return {state, revision, changeCursor: remote.changeCursor ?? null};
3634
4059
  };
3635
4060
 
4061
+ const clearAuthTransitionLatchIfSessionChanged = (captured) => {
4062
+ if (!authTransitionLatch) return;
4063
+ if (!rtlSameSessionIdentity(authTransitionLatch, captured)) {
4064
+ authTransitionLatch = null;
4065
+ }
4066
+ };
4067
+
4068
+ const throwIfAuthTransitionLatched = (captured) => {
4069
+ clearAuthTransitionLatchIfSessionChanged(captured);
4070
+ if (!authTransitionLatch) return;
4071
+ throw toRecordTimeLabelCloudFailureError(authTransitionLatch.failure);
4072
+ };
4073
+
4074
+ const rememberAuthTransitionFailure = (captured, failure) => {
4075
+ if (failure?.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
4076
+ return;
4077
+ }
4078
+ authTransitionLatch = {
4079
+ uid: captured?.uid ?? null,
4080
+ sessionToken: captured?.sessionToken ?? null,
4081
+ workspaceEpoch: captured?.workspaceEpoch ?? 0,
4082
+ failure: {
4083
+ class: failure.class,
4084
+ status: failure.status ?? null,
4085
+ reason: failure.reason ?? failure.code ?? 'auth-transition-required',
4086
+ code: failure.code ?? failure.reason ?? 'auth-transition-required',
4087
+ message: failure.message || failure.reason || failure.code || 'auth-transition-required',
4088
+ retryable: false,
4089
+ retryAfterMs: null,
4090
+ bootstrapRequired: false
4091
+ }
4092
+ };
4093
+ };
4094
+
4095
+ const capturedFromContext = (context) => ({
4096
+ uid: context?.uid ?? null,
4097
+ sessionToken: context?.sessionToken ?? null,
4098
+ workspaceEpoch: context?.workspaceEpoch ?? 0
4099
+ });
4100
+
4101
+ const rethrowClassifiedCloudFailure = (captured, error) => {
4102
+ const failure = normalizeRecordTimeLabelCloudFailure(error);
4103
+ rememberAuthTransitionFailure(captured, failure);
4104
+ if (error instanceof Error && error?.class === failure.class) throw error;
4105
+ throw toRecordTimeLabelCloudFailureError(failure);
4106
+ };
4107
+
3636
4108
  const requireBootstrapRevision = (baseline, minimumRevision) => {
3637
4109
  const minimum = Number(minimumRevision);
3638
4110
  if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
@@ -3640,6 +4112,12 @@ export const createRecordTimeLabelSyncEngine = ({
3640
4112
  error.code = 'recordtimelabel_bootstrap_revision_behind_required';
3641
4113
  error.bootstrapRevision = Number(baseline?.revision);
3642
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
+ });
3643
4121
  throw error;
3644
4122
  }
3645
4123
  return baseline;
@@ -3651,10 +4129,13 @@ export const createRecordTimeLabelSyncEngine = ({
3651
4129
  // normalizeBootstrapResponse accepts) or `{success: false,
3652
4130
  // bootstrapRequired: true}`; it may throw on transport errors. Gap
3653
4131
  // recovery prefers it over `cloud.bootstrap` and falls back to a fresh
3654
- // bootstrap walk on transport/protocol failure. A successful but stale
3655
- // catch-up response is fail-closed: falling back would hide a revision
3656
- // contract violation and could apply a baseline older than the ACK.
4132
+ // bootstrap walk only for explicit bootstrap-required / cursor-gap
4133
+ // outcomes. Auth and transient failures keep their class and must not
4134
+ // start a second full walk. A successful but stale catch-up response is
4135
+ // fail-closed: falling back would hide a revision contract violation.
3657
4136
  const recoverBaseline = async (context, targetRevision, mode) => {
4137
+ const captured = capturedFromContext(context);
4138
+ throwIfAuthTransitionLatched(captured);
3658
4139
  if (typeof cloud?.catchUp === 'function') {
3659
4140
  try {
3660
4141
  const caught = await cloud.catchUp(context, {
@@ -3665,20 +4146,35 @@ export const createRecordTimeLabelSyncEngine = ({
3665
4146
  if (rtlEnvelopeSuccess(caught)) {
3666
4147
  return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
3667
4148
  }
4149
+ const failure = normalizeRecordTimeLabelCloudFailure(caught);
4150
+ if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
4151
+ rethrowClassifiedCloudFailure(captured, failure);
4152
+ }
3668
4153
  } catch (error) {
3669
4154
  if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
3670
4155
  throw error;
3671
4156
  }
3672
- logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
4157
+ if (error?.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED ||
4158
+ normalizeRecordTimeLabelCloudFailure(error).class ===
4159
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
4160
+ // Explicit gap recovery only.
4161
+ } else {
4162
+ rethrowClassifiedCloudFailure(captured, error);
4163
+ }
3673
4164
  }
3674
4165
  }
3675
- return requireBootstrapRevision(
3676
- normalizeBootstrapResponse(await cloud.bootstrap(
3677
- context,
3678
- bootstrapAttemptOptions(mode)
3679
- )),
3680
- targetRevision
3681
- );
4166
+ throwIfAuthTransitionLatched(captured);
4167
+ try {
4168
+ return requireBootstrapRevision(
4169
+ normalizeBootstrapResponse(await cloud.bootstrap(
4170
+ context,
4171
+ bootstrapAttemptOptions(mode)
4172
+ )),
4173
+ targetRevision
4174
+ );
4175
+ } catch (error) {
4176
+ rethrowClassifiedCloudFailure(captured, error);
4177
+ }
3682
4178
  };
3683
4179
 
3684
4180
  const normalizeLoadedWorkspace = (loaded, captured) => {
@@ -3847,13 +4343,366 @@ export const createRecordTimeLabelSyncEngine = ({
3847
4343
  nextRetryAt: operationRetryAt(operation, result, retryAfterMs, timestamp)
3848
4344
  });
3849
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
+
3850
4688
  const processRemote = async (remoteValue, captured) => {
3851
4689
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3852
4690
  const candidate = clone(workspace);
3853
4691
  const remote = rtlEnvelopePayload(remoteValue);
3854
4692
  const remoteRevision = Number(remote?.revision);
3855
- if (!Number.isFinite(remoteRevision)) {
3856
- 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
+ };
3857
4706
  }
3858
4707
  if (remoteRevision <= candidate.remoteBaseline.revision) {
3859
4708
  return {success: true, ignored: true};
@@ -3863,7 +4712,19 @@ export const createRecordTimeLabelSyncEngine = ({
3863
4712
  const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
3864
4713
  if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
3865
4714
  if (typeof cloud?.bootstrap !== 'function') {
3866
- 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
+ };
3867
4728
  }
3868
4729
  const context = rtlSessionContext(captured, candidate, client);
3869
4730
  baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
@@ -3893,14 +4754,37 @@ export const createRecordTimeLabelSyncEngine = ({
3893
4754
  state.settled = true;
3894
4755
  resolvePromise({
3895
4756
  success: true,
3896
- ...(value && typeof value === 'object' ? value : {}),
3897
- generation
4757
+ ...(value && typeof value === 'object' ? value : {})
3898
4758
  });
3899
4759
  },
3900
4760
  reject(error) {
3901
4761
  if (state.settled) return;
3902
4762
  state.settled = true;
3903
- 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);
3904
4788
  }
3905
4789
  };
3906
4790
  // Readiness is an opt-in host boundary. Keep a rejection observable to a
@@ -3912,7 +4796,12 @@ export const createRecordTimeLabelSyncEngine = ({
3912
4796
 
3913
4797
  const staleRemoteReady = (state, reason = 'stale_session') => {
3914
4798
  if (!state || state.settled) return;
3915
- 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});
3916
4805
  };
3917
4806
 
3918
4807
  const isCurrentRemoteReady = (state) => (
@@ -3930,6 +4819,17 @@ export const createRecordTimeLabelSyncEngine = ({
3930
4819
  if (settleReady) staleRemoteReady(remoteReadyState);
3931
4820
  };
3932
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
+
3933
4833
  const startCloudSubscription = (captured, candidate) => {
3934
4834
  stopCloudSubscription();
3935
4835
  const readyState = createRemoteReadyState();
@@ -3943,7 +4843,12 @@ export const createRecordTimeLabelSyncEngine = ({
3943
4843
  return;
3944
4844
  }
3945
4845
  if (typeof cloud?.subscribe !== 'function') {
3946
- 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
+ }));
3947
4852
  return;
3948
4853
  }
3949
4854
  const context = rtlSessionContext(captured, candidate, client);
@@ -3951,24 +4856,64 @@ export const createRecordTimeLabelSyncEngine = ({
3951
4856
  let subscriptionReturned = false;
3952
4857
  let adapterReadyExpected = false;
3953
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
+ };
3954
4910
  const onRemote = (remoteValue) => {
3955
4911
  if (destroyed || !isCurrentRemoteReady(readyState)) {
3956
4912
  return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
3957
4913
  }
3958
4914
  const callbackResult = enqueue(() => processRemote(remoteValue, captured));
3959
4915
  if (!firstCallbackResult) firstCallbackResult = callbackResult;
3960
- callbackResult.then((result) => {
3961
- if (
3962
- subscriptionReturned &&
3963
- !adapterReadyExpected &&
3964
- isCurrentRemoteReady(readyState)
3965
- ) {
3966
- readyState.resolve({
3967
- revision: workspace.remoteBaseline.revision,
3968
- result
3969
- });
3970
- }
3971
- }).catch((error) => {
4916
+ callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
3972
4917
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
3973
4918
  readyState.reject(error);
3974
4919
  }
@@ -3984,34 +4929,58 @@ export const createRecordTimeLabelSyncEngine = ({
3984
4929
  logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
3985
4930
  return;
3986
4931
  }
3987
- 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
+ }
3988
4952
  adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
3989
4953
  subscriptionReturned = true;
3990
4954
  if (adapterReadyExpected) {
3991
4955
  Promise.resolve(disposer.ready).then((result) => {
3992
4956
  if (!isCurrentRemoteReady(readyState)) return;
3993
- readyState.resolve({
3994
- revision: workspace.remoteBaseline.revision,
3995
- result
3996
- });
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
+ }
3997
4971
  }).catch((error) => {
3998
4972
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
3999
4973
  readyState.reject(error);
4000
4974
  }
4001
4975
  });
4002
4976
  } else if (firstCallbackResult) {
4003
- firstCallbackResult.then((result) => {
4004
- if (!isCurrentRemoteReady(readyState)) return;
4005
- readyState.resolve({
4006
- revision: workspace.remoteBaseline.revision,
4007
- result
4008
- });
4009
- }).catch(() => null);
4977
+ firstCallbackResult.then(() => settleReadyIfProven()).catch(() => null);
4010
4978
  }
4011
4979
  };
4012
4980
 
4013
4981
  const initialize = async () => {
4014
4982
  const captured = capture();
4983
+ throwIfAuthTransitionLatched(captured);
4015
4984
  const loaded = await storage.load();
4016
4985
  if (!(await isCurrent(captured))) return getSnapshot();
4017
4986
  // Read the raw owner and baseline before normalizeLoadedWorkspace adopts
@@ -4021,10 +4990,7 @@ export const createRecordTimeLabelSyncEngine = ({
4021
4990
  const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
4022
4991
  const loadedBaseline = loaded?.remoteBaseline;
4023
4992
  const committedRevision = Number(loadedBaseline?.revision);
4024
- const hasValidCommittedBaseline = rtlDurableIsObject(loadedBaseline) &&
4025
- rtlDurableIsObject(loadedBaseline.state) &&
4026
- Number.isFinite(committedRevision) &&
4027
- committedRevision > 0;
4993
+ const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
4028
4994
  let candidate = normalizeLoadedWorkspace(loaded, captured);
4029
4995
  const identityChecked = rtlQuarantineOperations(
4030
4996
  candidate,
@@ -4042,6 +5008,13 @@ export const createRecordTimeLabelSyncEngine = ({
4042
5008
  candidate.remoteBaseline.revision === committedRevision &&
4043
5009
  (!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
4044
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
+ });
4045
5018
  let bootstrap;
4046
5019
  try {
4047
5020
  bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
@@ -4050,10 +5023,11 @@ export const createRecordTimeLabelSyncEngine = ({
4050
5023
  ));
4051
5024
  } catch (error) {
4052
5025
  if (!(await isCurrent(captured))) return getSnapshot();
4053
- throw error;
5026
+ rethrowClassifiedCloudFailure(captured, error);
4054
5027
  }
4055
5028
  if (!(await isCurrent(captured))) return getSnapshot();
4056
5029
  applyRemoteBaseline(candidate, bootstrap);
5030
+ delete candidate.syncMeta.terminalFailureBlock;
4057
5031
  }
4058
5032
 
4059
5033
  // Always persist the normalized durable shape before subscribing. This
@@ -4068,6 +5042,7 @@ export const createRecordTimeLabelSyncEngine = ({
4068
5042
  if (destroyed) return;
4069
5043
  enqueue(async () => {
4070
5044
  const current = capture();
5045
+ clearAuthTransitionLatchIfSessionChanged(current);
4071
5046
  const sameIdentity = current.uid === workspace.ownerUid &&
4072
5047
  Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
4073
5048
  if (sameIdentity) {
@@ -4097,7 +5072,12 @@ export const createRecordTimeLabelSyncEngine = ({
4097
5072
  return {success: false, code: 'recordtimelabel_hydration_required'};
4098
5073
  }
4099
5074
  const captured = capture();
5075
+ clearAuthTransitionLatchIfSessionChanged(captured);
4100
5076
  if (!(await isCurrent(captured))) return getSnapshot();
5077
+ if (!workspaceMatchesSession(captured)) {
5078
+ resetForSessionIdentity(captured);
5079
+ return {success: false, code: 'recordtimelabel_hydration_required'};
5080
+ }
4101
5081
  const input = Array.isArray(operations)
4102
5082
  ? operations
4103
5083
  : (operations && typeof operations === 'object' ? [operations] : []);
@@ -4178,11 +5158,25 @@ export const createRecordTimeLabelSyncEngine = ({
4178
5158
  };
4179
5159
 
4180
5160
  const syncInternal = async (reason) => {
5161
+ const captured = capture();
5162
+ throwIfAuthTransitionLatched(captured);
4181
5163
  if (!initialized || hydrationRequired) {
4182
5164
  return {success: false, code: 'recordtimelabel_hydration_required'};
4183
5165
  }
4184
- const captured = capture();
4185
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
+ }
4186
5180
  const timestamp = now();
4187
5181
  const identityChecked = rtlQuarantineOperations(
4188
5182
  workspace,
@@ -4234,7 +5228,22 @@ export const createRecordTimeLabelSyncEngine = ({
4234
5228
  };
4235
5229
  }
4236
5230
  if (typeof cloud?.applyOperations !== 'function') {
4237
- 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
+ };
4238
5247
  }
4239
5248
  const context = rtlSessionContext(captured, workspace, client);
4240
5249
  const wireOperations = ready.map((operation) => toRecordTimeLabelWireOperation(operation));
@@ -4256,59 +5265,79 @@ export const createRecordTimeLabelSyncEngine = ({
4256
5265
  }, context);
4257
5266
  } catch (error) {
4258
5267
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4259
- if (!rtlRetryableEnvelopeError(error)) {
4260
- logger?.error?.('[RecordTimeLabelCore] durable sync failed', error);
4261
- return {success: false, error};
4262
- }
4263
- response = {
4264
- success: false,
4265
- error,
4266
- retryAfterMs: rtlRetryAfterMs(error)
4267
- };
5268
+ return settleApplyFailure({
5269
+ value: error,
5270
+ captured,
5271
+ context,
5272
+ ready,
5273
+ timestamp,
5274
+ identityRejectedCount
5275
+ });
4268
5276
  }
4269
5277
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4270
5278
 
4271
- if (!rtlEnvelopeSuccess(response)) {
4272
- if (!rtlRetryableEnvelopeError(response)) {
4273
- const error = response?.error instanceof Error
4274
- ? response.error
4275
- : new Error(response?.error?.message || response?.message || response?.code || 'sync_envelope_failed');
4276
- logger?.error?.('[RecordTimeLabelCore] durable sync envelope failed', error);
4277
- 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
+ });
4278
5298
  }
4279
- const retryAfterMs = rtlRetryAfterMs(response);
4280
- const candidate = clone(workspace);
4281
- const byId = new Set(ready.map((operation) => operation.id));
4282
- candidate.pendingOperations = candidate.pendingOperations.map((operation) => (
4283
- byId.has(operation.id)
4284
- ? makeRetryOperation(operation, response, retryAfterMs, timestamp)
4285
- : operation
4286
- ));
4287
- candidate.syncMeta = {
4288
- ...candidate.syncMeta,
4289
- lastSyncAttemptAt: timestamp,
4290
- lastSyncError: response?.error?.message || response?.message || response?.code || 'retryable'
4291
- };
4292
- if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4293
- workspace = candidate;
4294
- notify({type: 'sync_retry', reason: response?.error?.code || response?.code || 'retryable'});
4295
- const retryAt = Math.min(...candidate.pendingOperations
4296
- .filter((operation) => Number.isFinite(Number(operation.nextRetryAt)))
4297
- .map((operation) => Number(operation.nextRetryAt)));
4298
- return {
4299
- success: false,
4300
- retryable: true,
4301
- retryAfterMs,
4302
- retryAt: Number.isFinite(retryAt) ? retryAt : null,
4303
- pendingCount: workspace.pendingOperations.length,
4304
- identityRejectedCount
4305
- };
4306
5299
  }
4307
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;
4308
5323
  const parsed = normalizeOperationResults(response, wireOperations);
4309
5324
  if (parsed.error) {
4310
- logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', parsed.error);
4311
- 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
+ });
4312
5341
  }
4313
5342
  let sawRetryable = false;
4314
5343
  for (const result of parsed.results) {
@@ -4318,7 +5347,20 @@ export const createRecordTimeLabelSyncEngine = ({
4318
5347
  const error = new Error('sync_protocol_fifo_retry_barrier_violation');
4319
5348
  error.code = 'sync_protocol_fifo_retry_barrier_violation';
4320
5349
  logger?.error?.('[RecordTimeLabelCore] durable sync protocol error', error);
4321
- 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
+ });
4322
5364
  }
4323
5365
  }
4324
5366
  const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
@@ -4356,40 +5398,109 @@ export const createRecordTimeLabelSyncEngine = ({
4356
5398
  };
4357
5399
  }
4358
5400
  }
4359
- appliedOperations.forEach((operation) => {
4360
- candidate.remoteBaseline.state = normalizeRecordTimeLabelDomainState(
4361
- applyRecordTimeLabelOperation(candidate.remoteBaseline.state, operation)
4362
- );
4363
- });
4364
- const responseRevision = Number(response.revision ?? response.remoteRevision);
4365
- const responseBaseline = rtlEnvelopePayload(response);
4366
5401
  const responseState = responseBaseline?.state ?? responseBaseline?.data;
4367
- if (rtlDurableIsObject(responseState)) {
4368
- 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);
4369
5476
  } else if (
5477
+ !responseIsStale &&
5478
+ responseIsExactNext &&
4370
5479
  rejectedCount === 0 &&
4371
5480
  !sawRetryable &&
4372
- appliedOperations.length === completedOperations.length &&
4373
- appliedOperations.length > 0 &&
4374
- Number.isFinite(responseRevision) &&
4375
- responseRevision === candidate.remoteBaseline.revision + 1
5481
+ hasAllAppliedResults
4376
5482
  ) {
4377
- // The v2 gateway ACK is intentionally compact and omits state. Promote
4378
- // only an all-applied transaction at exactly the next revision; any
4379
- // 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
+ );
4380
5491
  candidate.remoteBaseline.revision = responseRevision;
4381
5492
  if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
4382
5493
  candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
4383
5494
  }
4384
- } else if (
4385
- rejectedCount === 0 &&
4386
- Number.isFinite(responseRevision) &&
4387
- responseRevision > candidate.remoteBaseline.revision + 1 &&
4388
- typeof cloud?.bootstrap === 'function'
4389
- ) {
4390
- const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
4391
- if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4392
- 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
+ );
4393
5504
  }
4394
5505
  candidate.pendingOperations = nextPending;
4395
5506
  candidate.rejectedOperations = nextRejected;
@@ -4420,7 +5531,7 @@ export const createRecordTimeLabelSyncEngine = ({
4420
5531
  );
4421
5532
  } catch (error) {
4422
5533
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4423
- throw error;
5534
+ rethrowClassifiedCloudFailure(captured, error);
4424
5535
  }
4425
5536
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
4426
5537
  applyRemoteBaseline(candidate, freshBaseline);
@@ -4560,7 +5671,14 @@ export const createRecordTimeLabelSyncEngine = ({
4560
5671
  const engine = {
4561
5672
  init() {
4562
5673
  return enqueue(async () => {
4563
- 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
+ }
4564
5682
  return initialize();
4565
5683
  });
4566
5684
  },
@@ -4583,10 +5701,16 @@ export const createRecordTimeLabelSyncEngine = ({
4583
5701
 
4584
5702
  waitForRemoteReady() {
4585
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
+ });
4586
5709
  return Promise.resolve({
4587
5710
  success: false,
4588
5711
  stale: true,
4589
5712
  reason: 'stale_session',
5713
+ failure,
4590
5714
  generation: remoteReadyState.generation
4591
5715
  });
4592
5716
  }
@@ -4790,20 +5914,45 @@ const RTL_BULK_OPERATION_TYPES = new Set([
4790
5914
  ]);
4791
5915
  const RTL_PROTECTED_FOLDER_IDS = new Set(['all', DEFAULT_FOLDER_ID]);
4792
5916
 
4793
- const rtlByteLength = (value) => new TextEncoder().encode(String(value)).byteLength;
4794
- const rtlOperationRecordId = (operation = {}) => normalizeId(
4795
- operation?.payload?.recordId || operation?.payload?.id || operation?.payload?.record?.id
4796
- );
4797
- const rtlOperationFolderId = (operation = {}) => normalizeId(
4798
- operation?.payload?.folderId || operation?.payload?.id || operation?.payload?.folder?.id
4799
- );
4800
- const rtlOperationTrashEntryId = (operation = {}) => normalizeId(
4801
- 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)
4802
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;
4803
5938
  const rtlLifecycleTombstoneDocumentId = (kind, entityId) => (
4804
5939
  encodeURIComponent(`${kind}:${normalizeId(entityId)}`)
4805
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
+ };
4806
5954
  const rtlCloneDocuments = (documents = {}) => ({
5955
+ ...clone(documents || {}),
4807
5956
  root: clone(documents.root || null),
4808
5957
  records: clone(documents.records || {}),
4809
5958
  folders: clone(documents.folders || {}),
@@ -4812,6 +5961,24 @@ const rtlCloneDocuments = (documents = {}) => ({
4812
5961
  ops: {}
4813
5962
  });
4814
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
+ };
4815
5982
  /**
4816
5983
  * Group order ids are opaque to this package — mergeLocalRemote deliberately stops filtering them,
4817
5984
  * so the planner must not drop ids on a prefix guess either.
@@ -4840,7 +6007,7 @@ const rtlValidateIdList = (value, index, field, errors) => {
4840
6007
  errors.push(`operation_${index}_invalid_${field}`);
4841
6008
  return;
4842
6009
  }
4843
- if (value.some((id) => !normalizeId(id) || normalizeId(id).length > 512)) {
6010
+ if (value.some((id) => !rtlIsSafeDocumentId(id))) {
4844
6011
  errors.push(`operation_${index}_invalid_${field}`);
4845
6012
  }
4846
6013
  };
@@ -4935,9 +6102,10 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
4935
6102
  export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) => {
4936
6103
  const errors = [];
4937
6104
  const requireLifecycleGeneration = options.requireLifecycleGeneration === true ||
4938
- toArray(body?.client?.capabilities).includes(
4939
- RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
4940
- );
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
+ ));
4941
6109
  let requestBytes = 0;
4942
6110
  try {
4943
6111
  requestBytes = rtlByteLength(JSON.stringify(body));
@@ -4948,7 +6116,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
4948
6116
  if (body.protocolVersion !== RTL_SYNC_PROTOCOL_VERSION) {
4949
6117
  errors.push('unsupported_protocol_version');
4950
6118
  }
4951
- 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)) {
4952
6121
  errors.push('invalid_request_id');
4953
6122
  }
4954
6123
  if (!RTL_SYNC_CLIENT_APPS.has(body.client?.app)) errors.push('invalid_client_app');
@@ -4962,8 +6131,8 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
4962
6131
  }
4963
6132
  const operationIds = new Set();
4964
6133
  operations.forEach((operation, index) => {
4965
- const id = normalizeId(operation?.id);
4966
- 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`);
4967
6136
  if (operationIds.has(id)) errors.push(`operation_${index}_duplicate_id`);
4968
6137
  operationIds.add(id);
4969
6138
  if (!RTL_SUPPORTED_OPERATION_TYPES.has(operation?.type)) {
@@ -4989,12 +6158,16 @@ export const validateRecordTimeLabelOperationBatch = (body = {}, options = {}) =
4989
6158
  if (!toFiniteTimestamp(operation?.createdAt)) {
4990
6159
  errors.push(`operation_${index}_invalid_created_at`);
4991
6160
  }
4992
- if (rtlOperationRecordId(operation).length > 512) {
6161
+ if (rtlOperationRecordId(operation) && !rtlIsSafeDocumentId(rtlOperationRecordId(operation))) {
4993
6162
  errors.push(`operation_${index}_invalid_record_id`);
4994
6163
  }
4995
- if (rtlOperationFolderId(operation).length > 512) {
6164
+ if (rtlOperationFolderId(operation) && !rtlIsSafeDocumentId(rtlOperationFolderId(operation))) {
4996
6165
  errors.push(`operation_${index}_invalid_folder_id`);
4997
6166
  }
6167
+ const trashEntryId = rtlOperationTrashEntryId(operation);
6168
+ if (trashEntryId && !rtlIsSafeDocumentId(trashEntryId)) {
6169
+ errors.push(`operation_${index}_invalid_trash_entry_id`);
6170
+ }
4998
6171
  });
4999
6172
 
5000
6173
  return {ok: errors.length === 0, errors, requestBytes, operations};
@@ -5030,13 +6203,35 @@ export const buildFirestoreV2OperationReadPlan = (operations = []) => {
5030
6203
  if (recordId) {
5031
6204
  recordIds.add(recordId);
5032
6205
  lifecycleTombstoneIds.add(rtlLifecycleTombstoneDocumentId('record', recordId));
6206
+ trashEntryIds.add(normalizeId(payload.trashEntryId) || `record:${recordId}`);
5033
6207
  }
5034
6208
  break;
5035
6209
  }
5036
6210
  case OPERATION_TYPES.RECORD_RESTORE:
6211
+ case OPERATION_TYPES.FOLDER_RESTORE:
5037
6212
  case OPERATION_TYPES.TRASH_PURGE: {
5038
6213
  const trashEntryId = rtlOperationTrashEntryId(operation);
5039
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
+ }
5040
6235
  break;
5041
6236
  }
5042
6237
  case OPERATION_TYPES.RECORD_REORDER:
@@ -5125,7 +6320,8 @@ const rtlApplyOperationToPartialDocuments = ({
5125
6320
 
5126
6321
  const rtlCanRestoreLocalFolder = (root, folder) => {
5127
6322
  if (!folder?.id) return false;
5128
- const tombstone = root?.deletedFolderTombstones?.[folder.id];
6323
+ const tombstones = root?.deletedFolderTombstones;
6324
+ const tombstone = rtlOwn(tombstones, folder.id) ? tombstones[folder.id] : null;
5129
6325
  return !tombstone;
5130
6326
  };
5131
6327
 
@@ -5136,8 +6332,9 @@ const rtlBuildLocalFolderDocument = (folder, now) => {
5136
6332
  }, {now}).folders[folder.id] || null;
5137
6333
  };
5138
6334
 
5139
- const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
5140
- 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;
5141
6338
  const localFolder = toArray(localState?.folders)
5142
6339
  .find((folder) => folder?.id === folderId);
5143
6340
  if (!localFolder || !rtlCanRestoreLocalFolder(root, localFolder)) return null;
@@ -5152,7 +6349,19 @@ const rtlEnsureTargetFolder = ({root, folders, localState, folderId, now}) => {
5152
6349
 
5153
6350
  const rtlSetRoot = (target, source) => {
5154
6351
  Object.keys(target).forEach((key) => delete target[key]);
5155
- 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
+ });
5156
6365
  };
5157
6366
 
5158
6367
  /**
@@ -5163,22 +6372,48 @@ export const planFirestoreV2OperationChanges = ({
5163
6372
  operations,
5164
6373
  localState = {},
5165
6374
  now = Date.now(),
5166
- requireLifecycleGeneration = true
6375
+ requireLifecycleGeneration = true,
6376
+ plannerMode = RECORD_TIMELABEL_PLANNER_MODES.LEGACY_CONTEXT
5167
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;
5168
6382
  const previousDocuments = rtlCloneDocuments(documents);
5169
6383
  const nextDocuments = rtlCloneDocuments(documents);
5170
6384
  const preservedExpandedGroups = previousDocuments.root &&
5171
6385
  Object.prototype.hasOwnProperty.call(previousDocuments.root, 'expandedGroups')
5172
6386
  ? clone(previousDocuments.root.expandedGroups)
5173
6387
  : undefined;
5174
- 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 || {}) : {};
5175
6393
  const operationResults = [];
5176
6394
  let changed = false;
5177
6395
  if (!nextDocuments.root) {
5178
- return {requiresMigration: true, previousDocuments, nextDocuments, operationResults};
6396
+ return {
6397
+ requiresMigration: true,
6398
+ plannerMode: selectedPlannerMode,
6399
+ previousDocuments,
6400
+ nextDocuments,
6401
+ operationResults
6402
+ };
5179
6403
  }
5180
6404
 
5181
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
+ }
5182
6417
  if (operation?.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE) {
5183
6418
  operationResults.push({
5184
6419
  id: operation.id || null,
@@ -5193,21 +6428,33 @@ export const planFirestoreV2OperationChanges = ({
5193
6428
  const operationNow = toFiniteTimestamp(operation.createdAt || payload.updatedAt) || now;
5194
6429
  const recordId = rtlOperationRecordId(operation);
5195
6430
  const folderId = rtlOperationFolderId(operation);
6431
+ const trashEntryId = rtlOperationTrashEntryId(operation);
5196
6432
  let applied = null;
5197
6433
  let reason = null;
5198
6434
  let lifecycleTombstoneId = null;
5199
6435
 
5200
- 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) {
5201
6446
  case OPERATION_TYPES.RECORD_CREATE: {
5202
6447
  if (!recordId) { reason = 'missing_record_id'; break; }
5203
- if (nextDocuments.records[recordId]) { reason = 'already_exists'; break; }
6448
+ if (rtlOwn(nextDocuments.records, recordId)) { reason = 'already_exists'; break; }
5204
6449
  const targetFolderId = safeFolderId(payload.folderId || payload.record?.folderId);
5205
6450
  applied = rtlApplyOperationToPartialDocuments({
5206
6451
  ...nextDocuments,
5207
6452
  operation: {...operation, payload: {...payload, folderId: targetFolderId}},
5208
6453
  now: operationNow
5209
6454
  });
5210
- const recordDocument = applied.documents.records[recordId];
6455
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6456
+ ? applied.documents.records[recordId]
6457
+ : null;
5211
6458
  if (!recordDocument) { reason = 'blocked_by_tombstone'; break; }
5212
6459
  const candidateRoot = clone(applied.documents.root);
5213
6460
  const candidateFolders = clone(nextDocuments.folders);
@@ -5216,7 +6463,8 @@ export const planFirestoreV2OperationChanges = ({
5216
6463
  folders: candidateFolders,
5217
6464
  localState: normalizedLocalState,
5218
6465
  folderId: targetFolderId,
5219
- now: operationNow
6466
+ now: operationNow,
6467
+ allowContext
5220
6468
  });
5221
6469
  if (!targetFolder) {
5222
6470
  reason = 'folder_not_found';
@@ -5230,13 +6478,17 @@ export const planFirestoreV2OperationChanges = ({
5230
6478
  break;
5231
6479
  }
5232
6480
  case OPERATION_TYPES.RECORD_UPDATE: {
5233
- const existing = nextDocuments.records[recordId];
6481
+ const existing = rtlOwn(nextDocuments.records, recordId)
6482
+ ? nextDocuments.records[recordId]
6483
+ : null;
5234
6484
  if (!recordId || !existing) {
5235
6485
  reason = recordId ? 'record_not_found' : 'missing_record_id';
5236
6486
  break;
5237
6487
  }
5238
6488
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5239
- const recordDocument = applied.documents.records[recordId];
6489
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6490
+ ? applied.documents.records[recordId]
6491
+ : null;
5240
6492
  if (!recordDocument) { reason = 'record_not_found'; break; }
5241
6493
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5242
6494
  nextDocuments.records[recordId] = recordDocument;
@@ -5244,7 +6496,9 @@ export const planFirestoreV2OperationChanges = ({
5244
6496
  break;
5245
6497
  }
5246
6498
  case OPERATION_TYPES.RECORD_MOVE: {
5247
- const existing = nextDocuments.records[recordId];
6499
+ const existing = rtlOwn(nextDocuments.records, recordId)
6500
+ ? nextDocuments.records[recordId]
6501
+ : null;
5248
6502
  if (!recordId || !existing) {
5249
6503
  reason = recordId ? 'record_not_found' : 'missing_record_id';
5250
6504
  break;
@@ -5256,7 +6510,9 @@ export const planFirestoreV2OperationChanges = ({
5256
6510
  operation: {...operation, payload: {...payload, targetFolderId}},
5257
6511
  now: operationNow
5258
6512
  });
5259
- const recordDocument = applied.documents.records[recordId];
6513
+ const recordDocument = rtlOwn(applied.documents.records, recordId)
6514
+ ? applied.documents.records[recordId]
6515
+ : null;
5260
6516
  if (!recordDocument) { reason = 'record_not_found'; break; }
5261
6517
  const candidateRoot = clone(applied.documents.root);
5262
6518
  const candidateFolders = clone(nextDocuments.folders);
@@ -5265,10 +6521,11 @@ export const planFirestoreV2OperationChanges = ({
5265
6521
  folders: candidateFolders,
5266
6522
  localState: normalizedLocalState,
5267
6523
  folderId: targetFolderId,
5268
- now: operationNow
6524
+ now: operationNow,
6525
+ allowContext
5269
6526
  });
5270
6527
  if (!targetFolder) { reason = 'folder_not_found'; break; }
5271
- if (candidateFolders[sourceFolderId]) {
6528
+ if (rtlOwn(candidateFolders, sourceFolderId)) {
5272
6529
  candidateFolders[sourceFolderId].recordOrder = rtlMergeOrder(
5273
6530
  candidateFolders[sourceFolderId].recordOrder
5274
6531
  ).filter((id) => id !== recordId);
@@ -5285,8 +6542,14 @@ export const planFirestoreV2OperationChanges = ({
5285
6542
  }
5286
6543
  case OPERATION_TYPES.RECORD_REORDER: {
5287
6544
  const targetFolderId = safeFolderId(payload.folderId);
5288
- const targetFolder = nextDocuments.folders[targetFolderId];
6545
+ const targetFolder = rtlOwn(nextDocuments.folders, targetFolderId)
6546
+ ? nextDocuments.folders[targetFolderId]
6547
+ : null;
5289
6548
  const requested = rtlPayloadOrder(operation, ['recordIds']);
6549
+ if (rtlHasUnsafeOrderId(requested)) {
6550
+ reason = 'invalid_document_id';
6551
+ break;
6552
+ }
5290
6553
  if (!targetFolder || requested.length === 0) {
5291
6554
  reason = targetFolder ? 'missing_record_order' : 'folder_not_found';
5292
6555
  break;
@@ -5300,14 +6563,50 @@ export const planFirestoreV2OperationChanges = ({
5300
6563
  case OPERATION_TYPES.RECORD_DELETE: {
5301
6564
  if (!recordId) { reason = 'missing_record_id'; break; }
5302
6565
  lifecycleTombstoneId = rtlLifecycleTombstoneDocumentId('record', recordId);
5303
- 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);
5304
6602
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5305
6603
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5306
- const trashEntryId = normalizeId(payload.trashEntryId) || `record:${recordId}`;
5307
- const trashDocument = applied.documents.trash?.[trashEntryId];
5308
- 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;
5309
6608
  delete nextDocuments.records[recordId];
5310
- if (nextDocuments.folders[sourceFolderId]) {
6609
+ if (rtlOwn(nextDocuments.folders, sourceFolderId)) {
5311
6610
  nextDocuments.folders[sourceFolderId].recordOrder = rtlMergeOrder(
5312
6611
  nextDocuments.folders[sourceFolderId].recordOrder
5313
6612
  ).filter((id) => id !== recordId);
@@ -5316,8 +6615,10 @@ export const planFirestoreV2OperationChanges = ({
5316
6615
  break;
5317
6616
  }
5318
6617
  case OPERATION_TYPES.RECORD_RESTORE: {
5319
- const trashEntryId = rtlOperationTrashEntryId(operation);
5320
- const trashEntry = nextDocuments.trash[trashEntryId];
6618
+ const restoreTrashEntryId = trashEntryId;
6619
+ const trashEntry = rtlOwn(nextDocuments.trash, restoreTrashEntryId)
6620
+ ? nextDocuments.trash[restoreTrashEntryId]
6621
+ : null;
5321
6622
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
5322
6623
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
5323
6624
  if (requireLifecycleGeneration !== false && expectedGeneration === null) {
@@ -5333,11 +6634,23 @@ export const planFirestoreV2OperationChanges = ({
5333
6634
  break;
5334
6635
  }
5335
6636
  const restoredRecordId = normalizeId(trashEntry.entityId);
5336
- if (!restoredRecordId || nextDocuments.records[restoredRecordId]) {
6637
+ if (!restoredRecordId || rtlOwn(nextDocuments.records, restoredRecordId)) {
5337
6638
  reason = restoredRecordId ? 'already_exists' : 'missing_record_id';
5338
6639
  break;
5339
6640
  }
5340
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
+ }
5341
6654
  // Explicit legacy compatibility is planner-local. It does not change the
5342
6655
  // original operation, its wire bytes, or the strict durable reducer contract.
5343
6656
  const reducerOperation = requireLifecycleGeneration === false && expectedGeneration === null
@@ -5348,11 +6661,16 @@ export const planFirestoreV2OperationChanges = ({
5348
6661
  operation: reducerOperation,
5349
6662
  now: operationNow
5350
6663
  });
5351
- const restoredRecord = applied.documents.records[restoredRecordId];
6664
+ const restoredRecord = rtlOwn(applied.documents.records, restoredRecordId)
6665
+ ? applied.documents.records[restoredRecordId]
6666
+ : null;
5352
6667
  if (!restoredRecord) { reason = 'restore_conflict'; break; }
5353
6668
  const targetFolderId = safeFolderId(restoredRecord.folderId || trashEntry.originalFolderId);
5354
- const targetFolder = nextDocuments.folders[targetFolderId] ||
5355
- 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);
5356
6674
  if (!targetFolder) { reason = 'folder_not_found'; break; }
5357
6675
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5358
6676
  nextDocuments.records[restoredRecordId] = restoredRecord;
@@ -5368,8 +6686,10 @@ export const planFirestoreV2OperationChanges = ({
5368
6686
  break;
5369
6687
  }
5370
6688
  case OPERATION_TYPES.TRASH_PURGE: {
5371
- const trashEntryId = rtlOperationTrashEntryId(operation);
5372
- const trashEntry = nextDocuments.trash[trashEntryId];
6689
+ const purgeTrashEntryId = trashEntryId;
6690
+ const trashEntry = rtlOwn(nextDocuments.trash, purgeTrashEntryId)
6691
+ ? nextDocuments.trash[purgeTrashEntryId]
6692
+ : null;
5373
6693
  if (!trashEntry) { reason = 'trash_entry_not_found'; break; }
5374
6694
  const expectedGeneration = normalizeExpectedGeneration(payload.expectedGeneration);
5375
6695
  if (requireLifecycleGeneration !== false && expectedGeneration === null) {
@@ -5380,20 +6700,24 @@ export const planFirestoreV2OperationChanges = ({
5380
6700
  reason = 'lifecycle_conflict';
5381
6701
  break;
5382
6702
  }
5383
- delete nextDocuments.trash[trashEntryId];
6703
+ delete nextDocuments.trash[purgeTrashEntryId];
5384
6704
  changed = true;
5385
6705
  break;
5386
6706
  }
5387
6707
  case OPERATION_TYPES.FOLDER_CREATE:
5388
6708
  case OPERATION_TYPES.FOLDER_UPDATE: {
5389
6709
  if (!folderId) { reason = 'missing_folder_id'; break; }
5390
- if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !nextDocuments.folders[folderId]) {
6710
+ if (operation.type === OPERATION_TYPES.FOLDER_UPDATE && !rtlOwn(nextDocuments.folders, folderId)) {
5391
6711
  reason = 'folder_not_found';
5392
6712
  break;
5393
6713
  }
5394
- const previousOrder = nextDocuments.folders[folderId]?.recordOrder || [];
6714
+ const previousOrder = rtlOwn(nextDocuments.folders, folderId)
6715
+ ? nextDocuments.folders[folderId]?.recordOrder || []
6716
+ : [];
5395
6717
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5396
- const folderDocument = applied.documents.folders[folderId];
6718
+ const folderDocument = rtlOwn(applied.documents.folders, folderId)
6719
+ ? applied.documents.folders[folderId]
6720
+ : null;
5397
6721
  if (!folderDocument) { reason = 'folder_not_found'; break; }
5398
6722
  rtlSetRoot(nextDocuments.root, applied.documents.root);
5399
6723
  nextDocuments.folders[folderId] = {
@@ -5413,27 +6737,37 @@ export const planFirestoreV2OperationChanges = ({
5413
6737
  case OPERATION_TYPES.FOLDER_REORDER:
5414
6738
  case OPERATION_TYPES.GROUP_REORDER:
5415
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
+ }
5416
6750
  applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
5417
6751
  const appliedRoot = applied.documents.root;
5418
6752
  if (operation.type === OPERATION_TYPES.FOLDER_REORDER) {
5419
6753
  appliedRoot.folderOrder = rtlMergeOrder(
5420
6754
  rtlPayloadOrder(operation, ['folderOrder', 'order']),
5421
6755
  nextDocuments.root.folderOrder,
5422
- normalizedLocalState.folderOrder
6756
+ ...(allowContext ? [normalizedLocalState.folderOrder] : [])
5423
6757
  );
5424
6758
  } else if (operation.type === OPERATION_TYPES.GROUP_REORDER) {
5425
- appliedRoot.groupOrder = rtlNormalizeGroupOrder(rtlMergeOrder(
6759
+ appliedRoot.groupOrder = rtlMergeOpaqueOrder(
5426
6760
  rtlPayloadOrder(operation, ['groupOrder', 'order']),
5427
6761
  nextDocuments.root.groupOrder,
5428
- normalizedLocalState.groupOrder
5429
- ));
6762
+ ...(allowContext ? [normalizedLocalState.groupOrder] : [])
6763
+ );
5430
6764
  }
5431
6765
  rtlSetRoot(nextDocuments.root, appliedRoot);
5432
6766
  changed = true;
5433
6767
  break;
5434
6768
  }
5435
6769
  default:
5436
- reason = 'unsupported_operation';
6770
+ if (!reason) reason = 'unsupported_operation';
5437
6771
  }
5438
6772
  if (applied && !reason && lifecycleTombstoneId) {
5439
6773
  const nextTombstone = applied.documents.lifecycleTombstones?.[lifecycleTombstoneId];
@@ -5447,6 +6781,7 @@ export const planFirestoreV2OperationChanges = ({
5447
6781
  id: operation.id || null,
5448
6782
  type: operation.type || null,
5449
6783
  applied: !reason,
6784
+ ...(reason === 'already_deleted' ? {status: 'noop'} : {}),
5450
6785
  reason
5451
6786
  });
5452
6787
  }
@@ -5468,6 +6803,7 @@ export const planFirestoreV2OperationChanges = ({
5468
6803
  });
5469
6804
  return {
5470
6805
  requiresMigration: false,
6806
+ plannerMode: selectedPlannerMode,
5471
6807
  previousDocuments,
5472
6808
  nextDocuments,
5473
6809
  changes,
@@ -5475,6 +6811,19 @@ export const planFirestoreV2OperationChanges = ({
5475
6811
  };
5476
6812
  };
5477
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
+
5478
6827
  /**
5479
6828
  * Counts all billable writes before a transaction reserves quota.
5480
6829
  */
@@ -5664,9 +7013,18 @@ export default {
5664
7013
  isPendingChannelFolderId,
5665
7014
  isValidChannelName,
5666
7015
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
7016
+ RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
7017
+ RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER,
5667
7018
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
7019
+ RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE_V1,
5668
7020
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
5669
7021
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
7022
+ RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
7023
+ RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
7024
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
7025
+ isRecordTimeLabelOperationConflictFailure,
7026
+ normalizeRecordTimeLabelCloudFailure,
7027
+ toRecordTimeLabelCloudFailureError,
5670
7028
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
5671
7029
  RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
5672
7030
  RTL_SYNC_PROTOCOL_VERSION,
@@ -5676,6 +7034,11 @@ export default {
5676
7034
  RTL_MAX_REQUEST_BYTES,
5677
7035
  RTL_MAX_TARGET_WRITES,
5678
7036
  RTL_TRASH_RETENTION_MS,
7037
+ RECORD_TIMELABEL_PLANNER_MODES,
7038
+ RECORD_TIMELABEL_SAFE_ID_MAX_BYTES,
7039
+ isRecordTimeLabelSafeDocumentId,
7040
+ normalizeRecordTimeLabelImmutableId,
7041
+ normalizeRecordTimeLabelPlannerId,
5679
7042
  OPERATION_TYPES,
5680
7043
  RECORD_TIMELABEL_SYNC_MODES,
5681
7044
  RECORD_TIMELABEL_CLOUD_SCHEMAS,
@@ -5714,6 +7077,8 @@ export default {
5714
7077
  extendFirestoreV2OperationReadPlanWithRecords,
5715
7078
  extendFirestoreV2OperationReadPlanWithTrash,
5716
7079
  planFirestoreV2OperationChanges,
7080
+ planRecordTimeLabelDeterministicOperationChanges,
7081
+ planFirestoreV2DeterministicOperationChanges,
5717
7082
  estimateFirestoreV2WriteUnits,
5718
7083
  normalizeRecordTimeLabelOperationResults,
5719
7084
  normalizeRecordTimeLabelEnvelopeResponse,
@@ -5724,6 +7089,7 @@ export default {
5724
7089
  flushPendingOperations,
5725
7090
  mergeRemoteStateIntoLocal,
5726
7091
  applyOperation,
7092
+ applyExplicitRecordUpdate,
5727
7093
  applyRecordTimeLabelOperation,
5728
7094
  createOperation,
5729
7095
  createRecordTimeLabelSyncEngine,