@claudian-collab/protocol 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,11 @@ import { COLLAB_AUTHORITY_TRANSFER_CANCELLATION_PHASES, COLLAB_CLOUD_TO_LAN_TRAN
2
2
  import { COLLAB_MAIN_REF, COLLAB_MEMBER_REF_PREFIX, } from './CollabConstants.mjs';
3
3
  import { collabControlOperationCodec, } from './CollabControlOperationCodecs.mjs';
4
4
  import { CollabError } from './CollabError.mjs';
5
- import { COLLAB_CHECKPOINT_ARTIFACT_LIMITS, COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS, COLLAB_PROJECT_COORDINATION_FORMAT_VERSION, decodeCollabProjectCheckpointCoordinationNdjson, decodeCollabProjectCheckpointManifest, validateCollabProjectCheckpointConsistency, } from './CollabProjectCheckpoint.mjs';
5
+ import { COLLAB_CHECKPOINT_ARTIFACT_LIMITS, COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS, COLLAB_PROJECT_COORDINATION_FORMAT_VERSION, decodeCollabProjectCheckpointCoordinationNdjson, decodeCollabProjectCheckpointManifest, validateCollabProjectCheckpointConsistency, } from './CollabProjectCheckpoint.mjs';
6
+ import { COLLAB_PROJECT_MEMBERSHIP_LIMITS, } from './CollabProjectMembership.mjs';
6
7
  import { hasUtf8ByteLengthAtMost, isCollabGitOid, isCollabMemberId, isCollabOpaqueId, isCollabProjectId, } from './CollabValidation.mjs';
7
- export const COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = 2;
8
+ export const COLLAB_PROJECT_BACKUP_COMPATIBILITY_STAGE = 'pre-production-replaceable';
9
+ export const COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = 3;
8
10
  const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
9
11
  'lifecycle-journal',
10
12
  'authority-transfer-recovery',
@@ -15,11 +17,28 @@ const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
15
17
  'terminal-principal',
16
18
  'terminal-responder-replay',
17
19
  'leave-former-principal-replay',
20
+ 'project-invitation',
21
+ 'protected-invitation-envelope',
22
+ 'transferred-membership-claim-override',
23
+ 'protected-claim-override-envelope',
24
+ 'manager-responsibility-offer',
25
+ 'project-membership-recovery',
26
+ 'secret-replay-tombstone',
18
27
  ]);
19
28
  export const COLLAB_PROJECT_BACKUP_RECORD_KINDS = Object.freeze([
20
- ...COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(0, 13),
29
+ ...COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS,
30
+ 'cloud-event',
31
+ 'cloud-event-cursor',
32
+ 'idempotency-result',
33
+ 'principal-binding',
34
+ 'repository-placement',
21
35
  ...BACKUP_CONTINUITY_RECORD_KINDS,
22
- ...COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(14),
36
+ 'terminal-responder',
37
+ 'protected-claim-envelope',
38
+ 'tombstone',
39
+ 'schema-catalog',
40
+ 'server-compatibility',
41
+ 'authority-volume-pair',
23
42
  ]);
24
43
  const CONTINUITY_KIND_SET = new Set(BACKUP_CONTINUITY_RECORD_KINDS);
25
44
  const BACKUP_KIND_SET = new Set(COLLAB_PROJECT_BACKUP_RECORD_KINDS);
@@ -49,7 +68,9 @@ const DELETE_ACTIVE_PHASE_SET = new Set([
49
68
  'tombstoned',
50
69
  ]);
51
70
  const PLAINTEXT_CLAIM_RESPONSE_OPERATION_SET = new Set([
71
+ 'createProjectInvitation',
52
72
  'getTransferredMembershipClaim',
73
+ 'reissueTransferredMembershipClaim',
53
74
  'rotateTransferredMembershipClaims',
54
75
  ]);
55
76
  function invalidPayload(field) {
@@ -107,6 +128,9 @@ function timestampValue(value, field) {
107
128
  function timestamp(source, field) {
108
129
  return timestampValue(source[field], field);
109
130
  }
131
+ function hasExactDuration(start, end, durationMs) {
132
+ return Date.parse(end) === Date.parse(start) + durationMs;
133
+ }
110
134
  function nullableTimestamp(source, field) {
111
135
  return source[field] === null ? null : timestampValue(source[field], field);
112
136
  }
@@ -457,7 +481,15 @@ function lifecycleJournalRecord(source, recordId, revision) {
457
481
  if (recordId !== operationId)
458
482
  throw invalidPayload('recordId');
459
483
  const operationKind = literal(value, 'operationKind', [
460
- 'authority-transfer', 'backup', 'delete', 'export', 'leave', 'retire',
484
+ 'authority-transfer',
485
+ 'backup',
486
+ 'create-project',
487
+ 'delete',
488
+ 'export',
489
+ 'join-project',
490
+ 'leave',
491
+ 'remove-member',
492
+ 'retire',
461
493
  ]);
462
494
  const direction = value.direction === null
463
495
  ? null
@@ -828,6 +860,350 @@ function leaveFormerPrincipalReplayRecord(source, recordId, revision) {
828
860
  },
829
861
  };
830
862
  }
863
+ function projectInvitationRecord(source, recordId, revision) {
864
+ const value = exactRecord(source.value, 'value', [
865
+ 'createdAt',
866
+ 'expiresAt',
867
+ 'idempotencyKey',
868
+ 'invitationId',
869
+ 'issuedByMemberId',
870
+ 'projectId',
871
+ 'requestFingerprint',
872
+ 'revision',
873
+ 'secretReplayExpiresAt',
874
+ 'secretSha256',
875
+ 'state',
876
+ 'terminalAt',
877
+ ]);
878
+ const invitationId = token(value, 'invitationId');
879
+ if (recordId !== invitationId)
880
+ throw invalidPayload('recordId');
881
+ const createdAt = timestamp(value, 'createdAt');
882
+ const expiresAt = timestamp(value, 'expiresAt');
883
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
884
+ const state = literal(value, 'state', [
885
+ 'active', 'redeeming', 'redeemed', 'revoked', 'expired',
886
+ ]);
887
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
888
+ const terminal = state === 'redeemed' || state === 'revoked' || state === 'expired';
889
+ if (!hasExactDuration(createdAt, expiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.invitationTtlMs)
890
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
891
+ || terminal !== (terminalAt !== null)
892
+ || (terminalAt !== null && Date.parse(terminalAt) < Date.parse(createdAt)))
893
+ throw invalidPayload('state');
894
+ return {
895
+ kind: 'project-invitation',
896
+ recordId,
897
+ revision,
898
+ value: {
899
+ createdAt,
900
+ expiresAt,
901
+ idempotencyKey: token(value, 'idempotencyKey'),
902
+ invitationId,
903
+ issuedByMemberId: token(value, 'issuedByMemberId', isCollabMemberId),
904
+ projectId: token(value, 'projectId', isCollabProjectId),
905
+ requestFingerprint: sha256(value, 'requestFingerprint'),
906
+ revision: positiveInteger(value, 'revision'),
907
+ secretReplayExpiresAt,
908
+ secretSha256: sha256(value, 'secretSha256'),
909
+ state,
910
+ terminalAt,
911
+ },
912
+ };
913
+ }
914
+ function protectedSecretEnvelopeFields(value) {
915
+ const ciphertext = boundedString(value, 'ciphertext', 4096);
916
+ const nonce = boundedString(value, 'nonce', 128);
917
+ if (!BASE64URL_PATTERN.test(ciphertext) || !BASE64URL_PATTERN.test(nonce)) {
918
+ throw invalidPayload('ciphertext');
919
+ }
920
+ const createdAt = timestamp(value, 'createdAt');
921
+ const expiresAt = timestamp(value, 'expiresAt');
922
+ if (Date.parse(expiresAt) <= Date.parse(createdAt))
923
+ throw invalidPayload('expiresAt');
924
+ return {
925
+ associatedDataSha256: sha256(value, 'associatedDataSha256'),
926
+ ciphertext,
927
+ createdAt,
928
+ expiresAt,
929
+ keyId: token(value, 'keyId'),
930
+ nonce,
931
+ projectId: token(value, 'projectId', isCollabProjectId),
932
+ };
933
+ }
934
+ function protectedInvitationEnvelopeRecord(source, recordId, revision) {
935
+ const value = exactRecord(source.value, 'value', [
936
+ 'associatedDataSha256',
937
+ 'ciphertext',
938
+ 'createdAt',
939
+ 'expiresAt',
940
+ 'invitationId',
941
+ 'keyId',
942
+ 'nonce',
943
+ 'projectId',
944
+ ]);
945
+ const invitationId = token(value, 'invitationId');
946
+ if (recordId !== invitationId)
947
+ throw invalidPayload('recordId');
948
+ const envelope = protectedSecretEnvelopeFields(value);
949
+ return {
950
+ kind: 'protected-invitation-envelope',
951
+ recordId,
952
+ revision,
953
+ value: {
954
+ associatedDataSha256: envelope.associatedDataSha256,
955
+ ciphertext: envelope.ciphertext,
956
+ createdAt: envelope.createdAt,
957
+ expiresAt: envelope.expiresAt,
958
+ invitationId,
959
+ keyId: envelope.keyId,
960
+ nonce: envelope.nonce,
961
+ projectId: envelope.projectId,
962
+ },
963
+ };
964
+ }
965
+ function transferredMembershipClaimOverrideRecord(source, recordId, revision) {
966
+ const value = exactRecord(source.value, 'value', [
967
+ 'claimGeneration',
968
+ 'claimSha256',
969
+ 'createdAt',
970
+ 'expiresAt',
971
+ 'idempotencyKey',
972
+ 'managerMemberId',
973
+ 'memberId',
974
+ 'projectId',
975
+ 'redemptionReceiptId',
976
+ 'requestFingerprint',
977
+ 'secretReplayExpiresAt',
978
+ 'state',
979
+ 'supersededClaimSha256',
980
+ 'targetPrincipalId',
981
+ 'transferId',
982
+ 'updatedAt',
983
+ ]);
984
+ const transferId = token(value, 'transferId');
985
+ const memberId = token(value, 'memberId', isCollabMemberId);
986
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
987
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
988
+ throw invalidPayload('recordId');
989
+ }
990
+ const state = literal(value, 'state', [
991
+ 'active', 'redeemed', 'revoked', 'superseded', 'expired',
992
+ ]);
993
+ const targetPrincipalId = nullableToken(value, 'targetPrincipalId', candidate => (typeof candidate === 'string' && PRINCIPAL_PATTERN.test(candidate)));
994
+ const redemptionReceiptId = nullableToken(value, 'redemptionReceiptId');
995
+ if ((state === 'redeemed') !== (targetPrincipalId !== null && redemptionReceiptId !== null)
996
+ || (state !== 'redeemed' && (targetPrincipalId !== null || redemptionReceiptId !== null)))
997
+ throw invalidPayload('state');
998
+ const createdAt = timestamp(value, 'createdAt');
999
+ const expiresAt = timestamp(value, 'expiresAt');
1000
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
1001
+ const updatedAt = timestamp(value, 'updatedAt');
1002
+ if (!hasExactDuration(createdAt, expiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.transferredClaimTtlMs)
1003
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
1004
+ || Date.parse(updatedAt) < Date.parse(createdAt))
1005
+ throw invalidPayload('updatedAt');
1006
+ return {
1007
+ kind: 'transferred-membership-claim-override',
1008
+ recordId,
1009
+ revision,
1010
+ value: {
1011
+ claimGeneration,
1012
+ claimSha256: sha256(value, 'claimSha256'),
1013
+ createdAt,
1014
+ expiresAt,
1015
+ idempotencyKey: token(value, 'idempotencyKey'),
1016
+ managerMemberId: token(value, 'managerMemberId', isCollabMemberId),
1017
+ memberId,
1018
+ projectId: token(value, 'projectId', isCollabProjectId),
1019
+ redemptionReceiptId,
1020
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1021
+ secretReplayExpiresAt,
1022
+ state,
1023
+ supersededClaimSha256: sha256(value, 'supersededClaimSha256'),
1024
+ targetPrincipalId,
1025
+ transferId,
1026
+ updatedAt,
1027
+ },
1028
+ };
1029
+ }
1030
+ function protectedClaimOverrideEnvelopeRecord(source, recordId, revision) {
1031
+ const value = exactRecord(source.value, 'value', [
1032
+ 'associatedDataSha256',
1033
+ 'ciphertext',
1034
+ 'claimGeneration',
1035
+ 'createdAt',
1036
+ 'expiresAt',
1037
+ 'keyId',
1038
+ 'memberId',
1039
+ 'nonce',
1040
+ 'projectId',
1041
+ 'transferId',
1042
+ ]);
1043
+ const transferId = token(value, 'transferId');
1044
+ const memberId = token(value, 'memberId', isCollabMemberId);
1045
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
1046
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
1047
+ throw invalidPayload('recordId');
1048
+ }
1049
+ const envelope = protectedSecretEnvelopeFields(value);
1050
+ return {
1051
+ kind: 'protected-claim-override-envelope',
1052
+ recordId,
1053
+ revision,
1054
+ value: {
1055
+ associatedDataSha256: envelope.associatedDataSha256,
1056
+ ciphertext: envelope.ciphertext,
1057
+ claimGeneration,
1058
+ createdAt: envelope.createdAt,
1059
+ expiresAt: envelope.expiresAt,
1060
+ keyId: envelope.keyId,
1061
+ memberId,
1062
+ nonce: envelope.nonce,
1063
+ projectId: envelope.projectId,
1064
+ transferId,
1065
+ },
1066
+ };
1067
+ }
1068
+ function managerResponsibilityOfferRecord(source, recordId, revision) {
1069
+ const value = exactRecord(source.value, 'value', [
1070
+ 'acknowledgedAt',
1071
+ 'expiresAt',
1072
+ 'idempotencyKey',
1073
+ 'managerSetGenerationAtOffer',
1074
+ 'offeredAt',
1075
+ 'offerId',
1076
+ 'projectId',
1077
+ 'purpose',
1078
+ 'requestFingerprint',
1079
+ 'revision',
1080
+ 'sourceManagerMemberId',
1081
+ 'state',
1082
+ 'targetMemberId',
1083
+ 'targetMembershipRevisionAtOffer',
1084
+ 'terminalAt',
1085
+ ]);
1086
+ const offerId = token(value, 'offerId');
1087
+ if (recordId !== offerId)
1088
+ throw invalidPayload('recordId');
1089
+ const state = literal(value, 'state', [
1090
+ 'offered', 'acknowledged', 'declined', 'cancelled', 'consumed', 'expired',
1091
+ ]);
1092
+ const offeredAt = timestamp(value, 'offeredAt');
1093
+ const expiresAt = timestamp(value, 'expiresAt');
1094
+ const acknowledgedAt = nullableTimestamp(value, 'acknowledgedAt');
1095
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
1096
+ const terminal = state === 'declined'
1097
+ || state === 'cancelled'
1098
+ || state === 'consumed'
1099
+ || state === 'expired';
1100
+ if (!hasExactDuration(offeredAt, expiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.managerResponsibilityOfferTtlMs)
1101
+ || (state === 'offered' && acknowledgedAt !== null)
1102
+ || (state === 'acknowledged' && acknowledgedAt === null)
1103
+ || terminal !== (terminalAt !== null))
1104
+ throw invalidPayload('state');
1105
+ return {
1106
+ kind: 'manager-responsibility-offer',
1107
+ recordId,
1108
+ revision,
1109
+ value: {
1110
+ acknowledgedAt,
1111
+ expiresAt,
1112
+ idempotencyKey: token(value, 'idempotencyKey'),
1113
+ managerSetGenerationAtOffer: positiveInteger(value, 'managerSetGenerationAtOffer'),
1114
+ offeredAt,
1115
+ offerId,
1116
+ projectId: token(value, 'projectId', isCollabProjectId),
1117
+ purpose: literal(value, 'purpose', ['manager-promotion', 'manager-leave']),
1118
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1119
+ revision: positiveInteger(value, 'revision'),
1120
+ sourceManagerMemberId: token(value, 'sourceManagerMemberId', isCollabMemberId),
1121
+ state,
1122
+ targetMemberId: token(value, 'targetMemberId', isCollabMemberId),
1123
+ targetMembershipRevisionAtOffer: positiveInteger(value, 'targetMembershipRevisionAtOffer'),
1124
+ terminalAt,
1125
+ },
1126
+ };
1127
+ }
1128
+ function membershipRecoveryRecord(source, recordId, revision) {
1129
+ const value = exactRecord(source.value, 'value', [
1130
+ 'expectedMainOid',
1131
+ 'expectedPersonalRefOid',
1132
+ 'invitationId',
1133
+ 'memberId',
1134
+ 'operationId',
1135
+ 'operationKind',
1136
+ 'principalSha256',
1137
+ 'projectId',
1138
+ 'publicationMarkerSha256',
1139
+ 'repositoryPlanSha256',
1140
+ 'requestFingerprint',
1141
+ ]);
1142
+ const operationId = token(value, 'operationId');
1143
+ if (recordId !== operationId)
1144
+ throw invalidPayload('recordId');
1145
+ const operationKind = literal(value, 'operationKind', [
1146
+ 'create-project', 'join-project', 'remove-member',
1147
+ ]);
1148
+ const invitationId = nullableToken(value, 'invitationId');
1149
+ const principalSha256 = nullableSha256(value, 'principalSha256');
1150
+ const publicationMarkerSha256 = nullableSha256(value, 'publicationMarkerSha256');
1151
+ const repositoryPlanSha256 = nullableSha256(value, 'repositoryPlanSha256');
1152
+ if ((operationKind === 'join-project') !== (invitationId !== null)
1153
+ || (operationKind === 'remove-member') === (principalSha256 !== null)
1154
+ || (operationKind === 'create-project') !== (repositoryPlanSha256 !== null)
1155
+ || (operationKind !== 'create-project' && publicationMarkerSha256 !== null))
1156
+ throw invalidPayload('operationKind');
1157
+ return {
1158
+ kind: 'project-membership-recovery',
1159
+ recordId,
1160
+ revision,
1161
+ value: {
1162
+ expectedMainOid: token(value, 'expectedMainOid', isCollabGitOid),
1163
+ expectedPersonalRefOid: token(value, 'expectedPersonalRefOid', isCollabGitOid),
1164
+ invitationId,
1165
+ memberId: token(value, 'memberId', isCollabMemberId),
1166
+ operationId,
1167
+ operationKind,
1168
+ principalSha256,
1169
+ projectId: token(value, 'projectId', isCollabProjectId),
1170
+ publicationMarkerSha256,
1171
+ repositoryPlanSha256,
1172
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1173
+ },
1174
+ };
1175
+ }
1176
+ function secretReplayTombstoneRecord(source, recordId, revision) {
1177
+ const value = exactRecord(source.value, 'value', [
1178
+ 'actorMemberId',
1179
+ 'expiredAt',
1180
+ 'idempotencyKey',
1181
+ 'operation',
1182
+ 'projectId',
1183
+ 'requestFingerprint',
1184
+ ]);
1185
+ const actorMemberId = token(value, 'actorMemberId', isCollabMemberId);
1186
+ const idempotencyKey = token(value, 'idempotencyKey');
1187
+ const operation = literal(value, 'operation', [
1188
+ 'createProjectInvitation', 'reissueTransferredMembershipClaim',
1189
+ ]);
1190
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1191
+ throw invalidPayload('recordId');
1192
+ }
1193
+ return {
1194
+ kind: 'secret-replay-tombstone',
1195
+ recordId,
1196
+ revision,
1197
+ value: {
1198
+ actorMemberId,
1199
+ expiredAt: timestamp(value, 'expiredAt'),
1200
+ idempotencyKey,
1201
+ operation,
1202
+ projectId: token(value, 'projectId', isCollabProjectId),
1203
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1204
+ },
1205
+ };
1206
+ }
831
1207
  function decodeContinuityRecord(kind, source, recordId, revision) {
832
1208
  switch (kind) {
833
1209
  case 'lifecycle-journal': return lifecycleJournalRecord(source, recordId, revision);
@@ -845,6 +1221,19 @@ function decodeContinuityRecord(kind, source, recordId, revision) {
845
1221
  return terminalResponderReplayRecord(source, recordId, revision);
846
1222
  case 'leave-former-principal-replay':
847
1223
  return leaveFormerPrincipalReplayRecord(source, recordId, revision);
1224
+ case 'project-invitation': return projectInvitationRecord(source, recordId, revision);
1225
+ case 'protected-invitation-envelope':
1226
+ return protectedInvitationEnvelopeRecord(source, recordId, revision);
1227
+ case 'transferred-membership-claim-override':
1228
+ return transferredMembershipClaimOverrideRecord(source, recordId, revision);
1229
+ case 'protected-claim-override-envelope':
1230
+ return protectedClaimOverrideEnvelopeRecord(source, recordId, revision);
1231
+ case 'manager-responsibility-offer':
1232
+ return managerResponsibilityOfferRecord(source, recordId, revision);
1233
+ case 'project-membership-recovery':
1234
+ return membershipRecoveryRecord(source, recordId, revision);
1235
+ case 'secret-replay-tombstone':
1236
+ return secretReplayTombstoneRecord(source, recordId, revision);
848
1237
  }
849
1238
  }
850
1239
  function kindOrder(kind) {
@@ -901,6 +1290,33 @@ function lifecycleJournalHasInvalidSemantics(item) {
901
1290
  }
902
1291
  if (hasCheckpoint)
903
1292
  return true;
1293
+ if (value.operationKind === 'create-project'
1294
+ || value.operationKind === 'join-project'
1295
+ || value.operationKind === 'remove-member') {
1296
+ const phases = value.operationKind === 'create-project'
1297
+ ? new Set([
1298
+ 'prepared',
1299
+ 'repository-publication-intent',
1300
+ 'repository-published',
1301
+ 'activated',
1302
+ ])
1303
+ : value.operationKind === 'join-project'
1304
+ ? new Set(['prepared', 'membership-pending', 'personal-ref-created', 'membership-active'])
1305
+ : new Set(['prepared', 'membership-revoked', 'personal-ref-removed']);
1306
+ const statePhaseValid = (value.state === 'active' && phases.has(value.phase))
1307
+ || (value.state === 'recovery-required'
1308
+ && value.phase === 'recovery-required'
1309
+ && value.recoveryFromPhase !== null
1310
+ && phases.has(value.recoveryFromPhase))
1311
+ || (value.state === 'completed' && value.phase === 'completed');
1312
+ const resultRequired = value.state === 'completed'
1313
+ || (value.operationKind === 'create-project' && effectivePhase === 'activated')
1314
+ || (value.operationKind === 'join-project' && effectivePhase === 'membership-active')
1315
+ || (value.operationKind === 'remove-member'
1316
+ && (effectivePhase === 'membership-revoked'
1317
+ || effectivePhase === 'personal-ref-removed'));
1318
+ return !statePhaseValid || hasResult !== resultRequired;
1319
+ }
904
1320
  if (value.operationKind === 'leave') {
905
1321
  const statePhaseValid = (value.state === 'active' && (value.phase === 'prepared'
906
1322
  || value.phase === 'membership-left'
@@ -969,13 +1385,41 @@ function validateContinuity(records) {
969
1385
  const leaveReplays = new Map(records
970
1386
  .filter((item) => (item.kind === 'leave-former-principal-replay'))
971
1387
  .map(item => [item.value.operationId, item]));
1388
+ const invitations = new Map(records
1389
+ .filter((item) => (item.kind === 'project-invitation'))
1390
+ .map(item => [item.value.invitationId, item]));
1391
+ const invitationEnvelopes = new Map(records
1392
+ .filter((item) => (item.kind === 'protected-invitation-envelope'))
1393
+ .map(item => [item.value.invitationId, item]));
1394
+ const claimOverrides = records.filter((item) => (item.kind === 'transferred-membership-claim-override'));
1395
+ const claimOverrideEnvelopes = new Map(records
1396
+ .filter((item) => (item.kind === 'protected-claim-override-envelope'))
1397
+ .map(item => [
1398
+ `${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`,
1399
+ item,
1400
+ ]));
1401
+ const managerOffers = records.filter((item) => (item.kind === 'manager-responsibility-offer'));
1402
+ const membershipRecoveries = new Map(records
1403
+ .filter((item) => (item.kind === 'project-membership-recovery'))
1404
+ .map(item => [item.value.operationId, item]));
1405
+ const secretReplayTombstones = records.filter((item) => (item.kind === 'secret-replay-tombstone'));
972
1406
  const nonterminalLifecycles = [...lifecycles.values()].filter(item => (item.value.state === 'active' || item.value.state === 'recovery-required'));
973
1407
  if (nonterminalLifecycles.length > 1
974
1408
  || principalBindingRecords.some(item => (memberRecords.get(item.value.memberId)?.value.status !== 'active'))
975
1409
  || new Set(principalBindingRecords.map(item => item.value.principalId)).size
976
1410
  !== principalBindingRecords.length
977
1411
  || new Set(claimRecords.map(item => (`${item.value.transferId}:${item.value.claimSha256}`))).size !== claimRecords.length
978
- || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length)
1412
+ || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length
1413
+ || invitations.size !== records.filter(item => item.kind === 'project-invitation').length
1414
+ || invitationEnvelopes.size
1415
+ !== records.filter(item => item.kind === 'protected-invitation-envelope').length
1416
+ || new Set([...invitations.values()].map(item => (`${item.value.issuedByMemberId}:${item.value.idempotencyKey}`))).size !== invitations.size
1417
+ || new Set(claimOverrides.map(item => (`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`))).size !== claimOverrides.length
1418
+ || new Set(claimOverrides.map(item => (`${item.value.managerMemberId}:${item.value.idempotencyKey}`))).size !== claimOverrides.length
1419
+ || claimOverrideEnvelopes.size
1420
+ !== records.filter(item => item.kind === 'protected-claim-override-envelope').length
1421
+ || membershipRecoveries.size
1422
+ !== records.filter(item => item.kind === 'project-membership-recovery').length)
979
1423
  throw invalidPayload('records');
980
1424
  for (const lifecycle of lifecycles.values()) {
981
1425
  if (lifecycleJournalHasInvalidSemantics(lifecycle)
@@ -986,6 +1430,11 @@ function validateContinuity(records) {
986
1430
  if (lifecycle.value.operationKind === 'authority-transfer'
987
1431
  && !recoveries.has(lifecycle.value.operationId))
988
1432
  throw invalidPayload('records');
1433
+ if ((lifecycle.value.operationKind === 'create-project'
1434
+ || lifecycle.value.operationKind === 'join-project'
1435
+ || lifecycle.value.operationKind === 'remove-member')
1436
+ && !membershipRecoveries.has(lifecycle.value.operationId))
1437
+ throw invalidPayload('records');
989
1438
  if (lifecycle.value.operationKind === 'leave') {
990
1439
  const actorMemberId = lifecycle.value.actorMemberId;
991
1440
  const actor = actorMemberId === null ? undefined : memberRecords.get(actorMemberId);
@@ -1162,6 +1611,13 @@ function validateContinuity(records) {
1162
1611
  const lifecycle = lifecycles.get(claim.value.transferId);
1163
1612
  const recovery = recoveries.get(claim.value.transferId);
1164
1613
  const redemptionReceipt = redemptionReceipts.get(`${claim.value.transferId}:${claim.value.memberId}`);
1614
+ const redeemedOverride = redemptionReceipt === undefined
1615
+ ? undefined
1616
+ : claimOverrides.find(item => (item.value.transferId === claim.value.transferId
1617
+ && item.value.memberId === claim.value.memberId
1618
+ && item.value.state === 'redeemed'
1619
+ && item.value.claimSha256 === redemptionReceipt.value.receipt.claimSha256
1620
+ && item.value.redemptionReceiptId === redemptionReceipt.value.receipt.receiptId));
1165
1621
  if (memberRecords.get(claim.value.memberId)?.value.status !== 'active'
1166
1622
  || recovery === undefined
1167
1623
  || recovery.value.sourceAuthority.kind !== 'lan'
@@ -1171,7 +1627,11 @@ function validateContinuity(records) {
1171
1627
  || claim.value.expiresAt !== recovery.value.expiresAt
1172
1628
  || (claim.value.targetPrincipalId !== null
1173
1629
  && principalBindings.get(claim.value.memberId) !== claim.value.targetPrincipalId)
1174
- || (claim.value.state === 'redeemed') !== (redemptionReceipt !== undefined)) {
1630
+ || (claim.value.state === 'redeemed' && redemptionReceipt === undefined)
1631
+ || (redemptionReceipt !== undefined
1632
+ && claim.value.state !== 'redeemed'
1633
+ && redeemedOverride === undefined)
1634
+ || (claim.value.state === 'redeemed' && redeemedOverride !== undefined)) {
1175
1635
  throw invalidPayload('records');
1176
1636
  }
1177
1637
  }
@@ -1187,6 +1647,141 @@ function validateContinuity(records) {
1187
1647
  !== responderReplays.has(terminal.value.operationId))
1188
1648
  throw invalidPayload('records');
1189
1649
  }
1650
+ for (const recovery of membershipRecoveries.values()) {
1651
+ const lifecycle = lifecycles.get(recovery.value.operationId);
1652
+ const invitation = recovery.value.invitationId === null
1653
+ ? undefined
1654
+ : invitations.get(recovery.value.invitationId);
1655
+ const member = memberRecords.get(recovery.value.memberId);
1656
+ if (lifecycle === undefined
1657
+ || lifecycle.value.operationKind !== recovery.value.operationKind
1658
+ || lifecycle.value.requestFingerprint !== recovery.value.requestFingerprint
1659
+ || lifecycle.value.state !== 'completed'
1660
+ || member === undefined
1661
+ || (recovery.value.operationKind === 'join-project' && invitation === undefined)
1662
+ || (recovery.value.operationKind !== 'join-project' && invitation !== undefined)
1663
+ || (recovery.value.operationKind === 'create-project'
1664
+ && lifecycle.value.actorMemberId !== null)
1665
+ || (recovery.value.operationKind === 'join-project'
1666
+ && lifecycle.value.actorMemberId !== null)
1667
+ || (recovery.value.operationKind === 'remove-member'
1668
+ && lifecycle.value.actorMemberId === null)
1669
+ || (recovery.value.operationKind === 'join-project'
1670
+ && invitation?.value.state !== 'redeemed')
1671
+ || (recovery.value.operationKind === 'remove-member' && (member.value.status !== 'revoked'
1672
+ || principalBindings.has(recovery.value.memberId))))
1673
+ throw invalidPayload('records');
1674
+ }
1675
+ for (const invitation of invitations.values()) {
1676
+ const envelope = invitationEnvelopes.get(invitation.value.invitationId);
1677
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'createProjectInvitation'
1678
+ && item.value.actorMemberId === invitation.value.issuedByMemberId
1679
+ && item.value.idempotencyKey === invitation.value.idempotencyKey
1680
+ && item.value.requestFingerprint === invitation.value.requestFingerprint));
1681
+ if (!members.has(invitation.value.issuedByMemberId)
1682
+ || (envelope === undefined) === (tombstone === undefined)
1683
+ || (tombstone !== undefined
1684
+ && tombstone.value.expiredAt !== invitation.value.secretReplayExpiresAt)
1685
+ || (envelope !== undefined && (envelope.value.projectId !== invitation.value.projectId
1686
+ || envelope.value.createdAt !== invitation.value.createdAt
1687
+ || envelope.value.expiresAt !== invitation.value.secretReplayExpiresAt)))
1688
+ throw invalidPayload('records');
1689
+ }
1690
+ for (const envelope of invitationEnvelopes.values()) {
1691
+ if (!invitations.has(envelope.value.invitationId))
1692
+ throw invalidPayload('records');
1693
+ }
1694
+ const overridesByMember = new Map();
1695
+ for (const override of claimOverrides) {
1696
+ const identity = `${override.value.transferId}:${override.value.memberId}`;
1697
+ const values = overridesByMember.get(identity) ?? [];
1698
+ values.push(override);
1699
+ overridesByMember.set(identity, values);
1700
+ const envelope = claimOverrideEnvelopes.get(`${identity}:${override.value.claimGeneration}`);
1701
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'reissueTransferredMembershipClaim'
1702
+ && item.value.actorMemberId === override.value.managerMemberId
1703
+ && item.value.idempotencyKey === override.value.idempotencyKey
1704
+ && item.value.requestFingerprint === override.value.requestFingerprint));
1705
+ if (!members.has(override.value.memberId)
1706
+ || !members.has(override.value.managerMemberId)
1707
+ || (envelope === undefined) === (tombstone === undefined)
1708
+ || (tombstone !== undefined
1709
+ && tombstone.value.expiredAt !== override.value.secretReplayExpiresAt)
1710
+ || (envelope !== undefined && (envelope.value.projectId !== override.value.projectId
1711
+ || envelope.value.createdAt !== override.value.createdAt
1712
+ || envelope.value.expiresAt !== override.value.secretReplayExpiresAt)))
1713
+ throw invalidPayload('records');
1714
+ }
1715
+ for (const overrides of overridesByMember.values()) {
1716
+ overrides.sort((left, right) => left.value.claimGeneration - right.value.claimGeneration);
1717
+ const highest = overrides.at(-1);
1718
+ const first = overrides[0];
1719
+ const identity = first === undefined
1720
+ ? ''
1721
+ : `${first.value.transferId}:${first.value.memberId}`;
1722
+ const sourceClaim = claims.get(identity);
1723
+ const recovery = first === undefined ? undefined : recoveries.get(first.value.transferId);
1724
+ const member = first === undefined ? undefined : memberRecords.get(first.value.memberId);
1725
+ const principalBinding = first === undefined
1726
+ ? undefined
1727
+ : principalBindings.get(first.value.memberId);
1728
+ const redemptionReceipt = redemptionReceipts.get(identity);
1729
+ const redeemedOverrides = overrides.filter(item => item.value.state === 'redeemed');
1730
+ if (first === undefined
1731
+ || sourceClaim === undefined
1732
+ || recovery?.value.sourceAuthority.kind !== 'lan'
1733
+ || member?.value.status !== 'active'
1734
+ || sourceClaim.value.state === 'redeemed'
1735
+ || overrides.some((item, index) => item.value.claimGeneration !== index + 1)
1736
+ || overrides.some((item, index) => item.value.supersededClaimSha256 !== (index === 0
1737
+ ? sourceClaim.value.claimSha256
1738
+ : overrides[index - 1].value.claimSha256))
1739
+ || new Set(overrides.map(item => item.value.claimSha256)).size !== overrides.length
1740
+ || overrides.filter(item => item.value.state === 'active').length > 1
1741
+ || overrides.some(item => item.value.state === 'active' && item !== highest)
1742
+ || redeemedOverrides.length > 1
1743
+ || redeemedOverrides.some(item => item !== highest)
1744
+ || highest?.value.state === 'superseded'
1745
+ || (highest?.value.state === 'active' && (principalBinding !== undefined
1746
+ || redemptionReceipt !== undefined))
1747
+ || (highest?.value.state === 'redeemed' && (principalBinding !== highest.value.targetPrincipalId
1748
+ || redemptionReceipt === undefined
1749
+ || redemptionReceipt.value.receipt.transferId !== highest.value.transferId
1750
+ || redemptionReceipt.value.receipt.memberId !== highest.value.memberId
1751
+ || redemptionReceipt.value.receipt.claimSha256 !== highest.value.claimSha256
1752
+ || redemptionReceipt.value.receipt.receiptId !== highest.value.redemptionReceiptId
1753
+ || redemptionReceipt.value.receipt.checkpointSha256
1754
+ !== sourceClaim.value.checkpointSha256))
1755
+ || (highest?.value.state !== 'redeemed' && redemptionReceipt !== undefined))
1756
+ throw invalidPayload('records');
1757
+ }
1758
+ for (const envelope of claimOverrideEnvelopes.values()) {
1759
+ if (!claimOverrides.some(item => (item.value.transferId === envelope.value.transferId
1760
+ && item.value.memberId === envelope.value.memberId
1761
+ && item.value.claimGeneration === envelope.value.claimGeneration)))
1762
+ throw invalidPayload('records');
1763
+ }
1764
+ const currentOffers = managerOffers.filter(item => (item.value.state === 'offered' || item.value.state === 'acknowledged'));
1765
+ if (managerOffers.some(item => (item.value.sourceManagerMemberId === item.value.targetMemberId
1766
+ || !members.has(item.value.sourceManagerMemberId)
1767
+ || !members.has(item.value.targetMemberId)))
1768
+ || new Set(currentOffers.map(item => item.value.sourceManagerMemberId)).size
1769
+ !== currentOffers.length
1770
+ || new Set(currentOffers.map(item => item.value.targetMemberId)).size
1771
+ !== currentOffers.length)
1772
+ throw invalidPayload('records');
1773
+ for (const tombstone of secretReplayTombstones) {
1774
+ const liveSecretRecord = tombstone.value.operation === 'createProjectInvitation'
1775
+ ? [...invitations.values()].some(item => (item.value.issuedByMemberId === tombstone.value.actorMemberId
1776
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1777
+ && invitationEnvelopes.has(item.value.invitationId)))
1778
+ : claimOverrides.some(item => (item.value.managerMemberId === tombstone.value.actorMemberId
1779
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1780
+ && claimOverrideEnvelopes.has(`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`)));
1781
+ if (!members.has(tombstone.value.actorMemberId) || liveSecretRecord) {
1782
+ throw invalidPayload('records');
1783
+ }
1784
+ }
1190
1785
  for (const item of records) {
1191
1786
  if (item.kind === 'idempotency-result' && !members.has(item.value.memberId)) {
1192
1787
  throw invalidPayload('records');
@@ -1238,6 +1833,19 @@ function validateContinuity(records) {
1238
1833
  const lifecycle = lifecycles.get(receipt.transferId);
1239
1834
  const terminalPrincipal = terminalPrincipals.get(identity);
1240
1835
  const lanToCloud = recovery?.value.sourceAuthority.kind === 'lan';
1836
+ const redeemedOverride = claimOverrides.find(item => (item.value.transferId === receipt.transferId
1837
+ && item.value.memberId === receipt.memberId
1838
+ && item.value.state === 'redeemed'
1839
+ && item.value.claimSha256 === receipt.claimSha256
1840
+ && item.value.redemptionReceiptId === receipt.receiptId));
1841
+ const matchesSourceClaim = claim?.value.state === 'redeemed'
1842
+ && claim.value.claimSha256 === receipt.claimSha256
1843
+ && claim.value.checkpointSha256 === receipt.checkpointSha256
1844
+ && claim.value.operationIntentId === receipt.operationIntentId
1845
+ && claim.value.redemptionReceiptId === receipt.receiptId;
1846
+ const matchesOverride = claim !== undefined
1847
+ && redeemedOverride !== undefined
1848
+ && claim.value.checkpointSha256 === receipt.checkpointSha256;
1241
1849
  if (recovery === undefined
1242
1850
  || lifecycle === undefined
1243
1851
  || memberRecords.get(receipt.memberId)?.value.status !== 'active'
@@ -1245,11 +1853,7 @@ function validateContinuity(records) {
1245
1853
  || !keys.has(`${receipt.transferId}:${receipt.receiptKeyId}`)
1246
1854
  || Date.parse(receipt.redeemedAt) > Date.parse(recovery.value.expiresAt)
1247
1855
  || (lanToCloud && (item.value.acknowledgedAt !== null
1248
- || claim === undefined
1249
- || claim.value.claimSha256 !== receipt.claimSha256
1250
- || claim.value.checkpointSha256 !== receipt.checkpointSha256
1251
- || claim.value.operationIntentId !== receipt.operationIntentId
1252
- || claim.value.redemptionReceiptId !== receipt.receiptId))
1856
+ || (!matchesSourceClaim && !matchesOverride)))
1253
1857
  || (!lanToCloud && (item.value.acknowledgedAt === null
1254
1858
  || receipt.memberId === recovery.value.targetHostMemberId
1255
1859
  || receipt.checkpointSha256 !== lifecycle.value.checkpointSha256
@@ -1444,6 +2048,30 @@ export function encodeCollabProjectBackupCheckpointCoordinationNdjson(records) {
1444
2048
  export function validateCollabProjectBackupCheckpointConsistency(manifest, records) {
1445
2049
  const decodedManifest = decodeCollabProjectBackupCheckpointManifest(manifest);
1446
2050
  const decodedRecords = decodeCollabProjectBackupCheckpointCoordinationNdjson(records.map(item => JSON.stringify(item)).join('\n') + '\n');
2051
+ const invitationEnvelopeIds = new Set(decodedRecords
2052
+ .filter(item => item.kind === 'protected-invitation-envelope')
2053
+ .map(item => item.recordId));
2054
+ const claimOverrideEnvelopeIds = new Set(decodedRecords
2055
+ .filter(item => item.kind === 'protected-claim-override-envelope')
2056
+ .map(item => item.recordId));
2057
+ const captureTime = Date.parse(decodedManifest.createdAt);
2058
+ if (decodedRecords.some(item => {
2059
+ if (item.kind === 'secret-replay-tombstone') {
2060
+ return Date.parse(item.value.expiredAt) > captureTime;
2061
+ }
2062
+ if (item.kind === 'project-invitation') {
2063
+ return captureTime < Date.parse(item.value.createdAt)
2064
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2065
+ !== invitationEnvelopeIds.has(item.recordId);
2066
+ }
2067
+ if (item.kind === 'transferred-membership-claim-override') {
2068
+ return captureTime < Date.parse(item.value.createdAt)
2069
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2070
+ !== claimOverrideEnvelopeIds.has(item.recordId);
2071
+ }
2072
+ return false;
2073
+ }))
2074
+ throw invalidPayload('records');
1447
2075
  if (decodedRecords.some(item => {
1448
2076
  switch (item.kind) {
1449
2077
  case 'lifecycle-journal': return item.value.operationId === decodedManifest.operationId;