@claudian-collab/protocol 3.3.0 → 3.3.2

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/README.md CHANGED
@@ -37,7 +37,9 @@ fixed part of the detail so the whole detail stays within
37
37
  has one compatible final-serialization limit. Request comments and Ticket
38
38
  accepted relations also have shared authority-enforced total limits; summary
39
39
  counts let complete consumers reject partial or cross-snapshot assembly.
40
- Cloud binding v2 adds the authority-neutral Project snapshot, nine redacted durable event kinds plus `snapshot.required`, eighteen capability tokens, the exact ordinary Project/Git route catalog, bounded authority-transfer artifact routes, and six private-development bootstrap bindings. Wire v6 includes the additive Cloud Project-membership operation family while preserving every existing operation contract. The shared checkpoint container is exactly `checkpoint.json`, `coordination.ndjson`, and `repository.bundle`; authority-transfer and export retain wire-visible coordination format v1, while the pre-production offline backup contract is directly replaced by format v3. Backup v3 adds Project invitations, protected invitation and transferred-claim override envelopes, manager-responsibility offers, membership recovery journals, and secret-replay tombstones to the existing continuity records. Portable profiles exclude engine storage, paths, credentials, tokens, private keys, and operational refs, and backups exclude plaintext invitation secrets, raw claims, and private keys. Domain-separated canonical UTF-8 inputs define protected-claim AEAD associated data and bind every declared field of transferred-membership redemption receipts and authority-relinquishment proofs before consumer-owned Ed25519 signing or verification. Existing LAN v9 Join, invitation, endpoint, membership lifecycle, Host-transfer, snapshot, event, and HTTP bindings remain independently application-owned; the dedicated LAN authority-transfer transport binds these shared payloads without creating another shared operation registry.
40
+ Cloud binding v2 adds the authority-neutral Project snapshot, nine redacted durable event kinds plus `snapshot.required`, eighteen capability tokens, the exact ordinary Project/Git route catalog, bounded authority-transfer artifact routes, and six private-development bootstrap bindings. Wire v6 includes the Cloud Project-membership operation family. The shared checkpoint container is exactly `checkpoint.json`, `coordination.ndjson`, and `repository.bundle`; authority-transfer and export use wire-visible coordination format v1, and offline backup uses format v3. Backup v3 adds Project invitations, protected invitation and transferred-claim override envelopes, manager-responsibility offers, membership recovery journals, secret-replay tombstones, and compacted Manager-responsibility idempotency tombstones to the existing continuity records. Portable profiles exclude engine storage, paths, credentials, tokens, private keys, and operational refs, and backups exclude plaintext invitation secrets, raw claims, and private keys. Domain-separated canonical UTF-8 inputs define protected-claim AEAD associated data and bind every declared field of transferred-membership redemption receipts and authority-relinquishment proofs before consumer-owned Ed25519 signing or verification. Existing LAN v9 Join, invitation, endpoint, membership lifecycle, Host-transfer, snapshot, event, and HTTP bindings remain independently application-owned; the dedicated LAN authority-transfer transport binds these shared payloads without creating another shared operation registry.
41
+
42
+ Backup coordination artifacts are limited to 256 MiB of actual UTF-8 bytes, including the final newline. The decoder, encoder, and consistency checker apply this same inclusive limit without rewriting principals into an intermediate format. Valid current-format artifacts at the limit are accepted; an additional byte is rejected.
41
43
 
42
44
  ## Usage
43
45
 
@@ -62,28 +64,14 @@ part of the public surface.
62
64
 
63
65
  Package SemVer, canonical wire version, and Cloud binding version are independent concepts.
64
66
 
65
- - **Package version** (this `package.json`): `3.3.0`. Patch releases preserve
66
- every existing public declaration and runtime behavior baseline. Minor
67
- releases may add backward-compatible API. Removing or changing an existing
68
- declaration, export, runtime behavior, codec, error, limit, ref rule,
69
- operation, or compatibility rule requires a major release. An independently
70
- proven additive control operation may use a package minor release without a
71
- wire-version increase; existing operation declarations and fixtures must stay
72
- exact. Package 3 and wire 6 support exact transfer-claim batches and a
73
- source-pinned target receipt verifier. Package 3.3 adds the Cloud Project-membership
74
- operation family and capability tokens and directly replaces the pre-production
75
- offline backup contract with coordination format v3, without changing
76
- authority-transfer/export format v1, canonical wire v6, or Cloud binding v2.
77
- Backup v2 has no supported decoder or migration path. The package version never
78
- signals wire compatibility by itself.
67
+ - **Package version** (this `package.json`): `3.3.2`. Behavior-preserving implementation refactors and reviewed compatible defect corrections may use a patch release when public declarations, runtime exports, and accepted wire/binding semantics remain unchanged. Minor releases may add backward-compatible API. Removing or incompatibly changing an existing declaration, export, runtime behavior, codec, error, limit, ref rule, operation, or compatibility rule requires a major release. Package API SemVer is classified independently from the wire and Cloud binding contracts, but a compatible package addition cannot exempt a changed wire contract from a wire-version increase or a changed Cloud binding contract from a binding-version increase. The package version never signals wire compatibility by itself.
79
68
  - **Wire version** (`COLLAB_PROTOCOL_VERSION`): currently `6`. The supported
80
69
  range is exactly `[6, 6]`. This is independent from Cloud binding version `2` and the existing application
81
70
  LAN control version `9`. Any change to an existing envelope, DTO, operation
82
- payload shape, or operation definition is wire-breaking and requires a new
83
- wire-protocol version. A strictly additive operation may remain on the
84
- current wire version when the compatibility classifier proves that every
85
- existing operation contract is unchanged.
86
- - **Cloud binding version** (`COLLAB_CLOUD_BINDING_VERSION`): currently `2`. It versions Cloud routes, bounded transfer streams, and capabilities independently from package and wire versions. Binding v1 and wire versions before v6 are unsupported rather than translated or dual-interpreted.
71
+ payload shape, or operation definition requires a new wire-protocol version.
72
+ This includes additive operations: every wire-contract change increases the
73
+ canonical wire version.
74
+ - **Cloud binding version** (`COLLAB_CLOUD_BINDING_VERSION`): currently `2`. It versions Cloud routes, bounded transfer streams, and capabilities independently from package and wire versions. Every Cloud binding-contract change increases this version. Binding v1 and wire versions before v6 are unsupported rather than translated or dual-interpreted.
87
75
 
88
76
  ### Compatibility behavior
89
77
 
@@ -102,14 +90,18 @@ Compatibility is tested with executable fixtures in this package
102
90
  (`tests/`), and cross-repository contract fixtures pin client and server to
103
91
  the same behavior.
104
92
 
93
+ The compatibility snapshot also fingerprints implementation tokens. Those fingerprints detect edits but cannot establish whether behavior changed. A reviewed implementation-only refactor or approved compatible defect correction uses `compatibility-review.json`, generated with `npm run check:compatibility -- --base <base-sha> --record-implementation-only-review "<reviewed reason>"` after updating the generated snapshot and characterizing the public seams. The reason distinguishes preserved behavior from any approved correction and its executable evidence. The record binds the exact base and candidate snapshots; the classifier independently rejects any changed public declaration, export, or wire/binding semantic fact. Missing or stale evidence cannot authorize an implementation change, and the record never applies to another version or later edit merely because an earlier change was accepted. This review requirement is general, not a prelaunch or one-release compatibility exception, and it is not an automated proof of behavioral equivalence.
94
+
105
95
  ## Development
106
96
 
107
97
  ```bash
108
98
  npm run build # compile src/ -> dist/ (CommonJS + declarations)
109
99
  npm test # package unit tests
110
100
  npm run check:compatibility
111
- npm run verify:pack # pack the artifact and smoke-test a clean consumer
101
+ npm run release:candidate # build and verify the reviewed release artifact
102
+ npm run verify:pack # smoke-test that exact artifact in a clean consumer
112
103
  ```
113
104
 
114
105
  `dist/` is generated output and is never committed. The packed artifact is
115
106
  reproducible from source: `npm pack` rebuilds via `prepack`.
107
+ The full verification gate constructs one release candidate and uses those same reviewed tarball bytes for clean-consumer smoke tests and publication.
@@ -5,7 +5,7 @@ import { type CollabManagerResponsibilityOfferState, type CollabManagerResponsib
5
5
  import type { CollabIsoTimestamp, CollabMemberId, CollabProjectId } from './types';
6
6
  export declare const COLLAB_PROJECT_BACKUP_COMPATIBILITY_STAGE: "pre-production-replaceable";
7
7
  export declare const COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION: 3;
8
- export declare const COLLAB_PROJECT_BACKUP_RECORD_KINDS: readonly ["project", "member", "request", "request-comment", "ticket", "ticket-comment", "ticket-relation", "ticket-mention", "cloud-event", "cloud-event-cursor", "idempotency-result", "principal-binding", "repository-placement", "lifecycle-journal", "authority-transfer-recovery", "transferred-membership-claim", "transfer-receipt-key", "transfer-claim-batch-receipt", "transfer-redemption-receipt", "terminal-principal", "terminal-responder-replay", "leave-former-principal-replay", "project-invitation", "protected-invitation-envelope", "transferred-membership-claim-override", "protected-claim-override-envelope", "manager-responsibility-offer", "project-membership-recovery", "secret-replay-tombstone", "terminal-responder", "protected-claim-envelope", "tombstone", "schema-catalog", "server-compatibility", "authority-volume-pair"];
8
+ export declare const COLLAB_PROJECT_BACKUP_RECORD_KINDS: readonly ["project", "member", "request", "request-comment", "ticket", "ticket-comment", "ticket-relation", "ticket-mention", "cloud-event", "cloud-event-cursor", "idempotency-result", "principal-binding", "repository-placement", "lifecycle-journal", "authority-transfer-recovery", "transferred-membership-claim", "transfer-receipt-key", "transfer-claim-batch-receipt", "transfer-redemption-receipt", "terminal-principal", "terminal-responder-replay", "leave-former-principal-replay", "project-invitation", "protected-invitation-envelope", "transferred-membership-claim-override", "protected-claim-override-envelope", "manager-responsibility-offer", "membership-idempotency-tombstone", "project-membership-recovery", "secret-replay-tombstone", "terminal-responder", "protected-claim-envelope", "tombstone", "schema-catalog", "server-compatibility", "authority-volume-pair"];
9
9
  export type CollabProjectBackupRecordKind = typeof COLLAB_PROJECT_BACKUP_RECORD_KINDS[number];
10
10
  interface BackupRecordBase<Kind extends string, Value> {
11
11
  readonly kind: Kind;
@@ -208,6 +208,15 @@ export type CollabProjectBackupManagerResponsibilityOfferRecord = BackupRecordBa
208
208
  readonly targetMembershipRevisionAtOffer: number;
209
209
  readonly terminalAt: CollabIsoTimestamp | null;
210
210
  }>;
211
+ type CollabProjectBackupCompactedMembershipOperation = Extract<CollabProjectMembershipOperation, 'createManagerResponsibilityOffer' | 'acknowledgeManagerResponsibility' | 'declineManagerResponsibility' | 'cancelManagerResponsibilityOffer' | 'promoteManager'>;
212
+ export type CollabProjectBackupMembershipIdempotencyTombstoneRecord = BackupRecordBase<'membership-idempotency-tombstone', {
213
+ readonly actorMemberId: CollabMemberId;
214
+ readonly compactedAt: CollabIsoTimestamp;
215
+ readonly idempotencyKey: string;
216
+ readonly operation: CollabProjectBackupCompactedMembershipOperation;
217
+ readonly projectId: CollabProjectId;
218
+ readonly requestFingerprint: string;
219
+ }>;
211
220
  export type CollabProjectBackupMembershipRecoveryRecord = BackupRecordBase<'project-membership-recovery', {
212
221
  readonly expectedMainOid: string;
213
222
  readonly expectedPersonalRefOid: string;
@@ -229,7 +238,7 @@ export type CollabProjectBackupSecretReplayTombstoneRecord = BackupRecordBase<'s
229
238
  readonly projectId: CollabProjectId;
230
239
  readonly requestFingerprint: string;
231
240
  }>;
232
- export type CollabProjectBackupContinuityRecord = CollabProjectBackupLifecycleJournalRecord | CollabProjectBackupAuthorityTransferRecoveryRecord | CollabProjectBackupTransferredMembershipClaimRecord | CollabProjectBackupTransferReceiptKeyRecord | CollabProjectBackupTransferClaimBatchReceiptRecord | CollabProjectBackupTransferRedemptionReceiptRecord | CollabProjectBackupTerminalPrincipalRecord | CollabProjectBackupTerminalResponderReplayRecord | CollabProjectBackupLeaveFormerPrincipalReplayRecord | CollabProjectBackupInvitationRecord | CollabProjectBackupProtectedInvitationEnvelopeRecord | CollabProjectBackupTransferredMembershipClaimOverrideRecord | CollabProjectBackupProtectedClaimOverrideEnvelopeRecord | CollabProjectBackupManagerResponsibilityOfferRecord | CollabProjectBackupMembershipRecoveryRecord | CollabProjectBackupSecretReplayTombstoneRecord;
241
+ export type CollabProjectBackupContinuityRecord = CollabProjectBackupLifecycleJournalRecord | CollabProjectBackupAuthorityTransferRecoveryRecord | CollabProjectBackupTransferredMembershipClaimRecord | CollabProjectBackupTransferReceiptKeyRecord | CollabProjectBackupTransferClaimBatchReceiptRecord | CollabProjectBackupTransferRedemptionReceiptRecord | CollabProjectBackupTerminalPrincipalRecord | CollabProjectBackupTerminalResponderReplayRecord | CollabProjectBackupLeaveFormerPrincipalReplayRecord | CollabProjectBackupInvitationRecord | CollabProjectBackupProtectedInvitationEnvelopeRecord | CollabProjectBackupTransferredMembershipClaimOverrideRecord | CollabProjectBackupProtectedClaimOverrideEnvelopeRecord | CollabProjectBackupManagerResponsibilityOfferRecord | CollabProjectBackupMembershipIdempotencyTombstoneRecord | CollabProjectBackupMembershipRecoveryRecord | CollabProjectBackupSecretReplayTombstoneRecord;
233
242
  type CollabProjectBackupBaseRecord = Exclude<CollabCheckpointBackupRecord, {
234
243
  readonly kind: 'lifecycle-state';
235
244
  }>;
@@ -32,6 +32,7 @@ const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
32
32
  'transferred-membership-claim-override',
33
33
  'protected-claim-override-envelope',
34
34
  'manager-responsibility-offer',
35
+ 'membership-idempotency-tombstone',
35
36
  'project-membership-recovery',
36
37
  'secret-replay-tombstone',
37
38
  ]);
@@ -207,65 +208,19 @@ function absoluteTargetUrl(source) {
207
208
  function principal(source, field) {
208
209
  return token(source, field, value => (typeof value === 'string' && PRINCIPAL_PATTERN.test(value)));
209
210
  }
210
- function sanitizeBackupBasePrincipalRecord(item) {
211
- const source = record(item, 'record');
212
- const value = record(source.value, 'value');
213
- if (source.kind === 'principal-binding') {
214
- return {
215
- ...source,
216
- value: { ...value, principalId: `principal_${String(value.memberId)}` },
217
- };
218
- }
219
- if (source.kind === 'terminal-responder' && Array.isArray(value.acknowledgements)) {
220
- return {
221
- ...source,
222
- value: {
223
- ...value,
224
- acknowledgements: value.acknowledgements.map(item => ({
225
- ...record(item, 'acknowledgement'),
226
- principalId: `principal_${String(record(item, 'acknowledgement').memberId)}`,
227
- })),
228
- },
229
- };
230
- }
231
- return item;
232
- }
233
- function restoreBackupBasePrincipals(source, base) {
234
- if (base.kind === 'principal-binding') {
235
- const value = exactRecord(source.value, 'value', [
236
- 'boundAt', 'memberId', 'principalId', 'projectId',
237
- ]);
238
- return {
239
- ...base,
240
- value: { ...base.value, principalId: principal(value, 'principalId') },
241
- };
242
- }
211
+ function validateBackupBasePrincipals(base) {
212
+ if (base.kind === 'principal-binding')
213
+ principal(base.value, 'principalId');
243
214
  if (base.kind === 'terminal-responder') {
244
- const value = exactRecord(source.value, 'value', [
245
- 'acknowledgements',
246
- 'eligibleMemberIds',
247
- 'expiresAt',
248
- 'operation',
249
- 'operationId',
250
- 'projectId',
251
- 'responseJson',
252
- ]);
253
- const sourceAcknowledgements = value.acknowledgements;
254
- if (!Array.isArray(sourceAcknowledgements)
255
- || sourceAcknowledgements.length !== base.value.acknowledgements.length)
256
- throw invalidPayload('acknowledgements');
257
- const acknowledgements = base.value.acknowledgements.map((item, index) => {
258
- const original = exactRecord(sourceAcknowledgements[index], 'acknowledgement', [
259
- 'acknowledgedAt', 'memberId', 'principalId',
260
- ]);
261
- return { ...item, principalId: principal(original, 'principalId') };
262
- });
263
- if (new Set(acknowledgements.map(item => item.principalId)).size !== acknowledgements.length) {
264
- throw invalidPayload('acknowledgements');
215
+ for (const acknowledgement of base.value.acknowledgements) {
216
+ if (acknowledgement.principalId === undefined)
217
+ throw invalidPayload('acknowledgement');
218
+ principal(acknowledgement, 'principalId');
265
219
  }
266
- return { ...base, value: { ...base.value, acknowledgements } };
220
+ if (new Set(base.value.acknowledgements.map(item => item.principalId)).size
221
+ !== base.value.acknowledgements.length)
222
+ throw invalidPayload('acknowledgements');
267
223
  }
268
- return base;
269
224
  }
270
225
  function repositoryPublicationRef(value) {
271
226
  const source = exactRecord(value, 'ref', ['name', 'oid']);
@@ -1183,6 +1138,41 @@ function membershipRecoveryRecord(source, recordId, revision) {
1183
1138
  },
1184
1139
  };
1185
1140
  }
1141
+ function membershipIdempotencyTombstoneRecord(source, recordId, revision) {
1142
+ const value = exactRecord(source.value, 'value', [
1143
+ 'actorMemberId',
1144
+ 'compactedAt',
1145
+ 'idempotencyKey',
1146
+ 'operation',
1147
+ 'projectId',
1148
+ 'requestFingerprint',
1149
+ ]);
1150
+ const actorMemberId = token(value, 'actorMemberId', CollabValidation_1.isCollabMemberId);
1151
+ const idempotencyKey = token(value, 'idempotencyKey');
1152
+ const operation = literal(value, 'operation', [
1153
+ 'createManagerResponsibilityOffer',
1154
+ 'acknowledgeManagerResponsibility',
1155
+ 'declineManagerResponsibility',
1156
+ 'cancelManagerResponsibilityOffer',
1157
+ 'promoteManager',
1158
+ ]);
1159
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1160
+ throw invalidPayload('recordId');
1161
+ }
1162
+ return {
1163
+ kind: 'membership-idempotency-tombstone',
1164
+ recordId,
1165
+ revision,
1166
+ value: {
1167
+ actorMemberId,
1168
+ compactedAt: timestamp(value, 'compactedAt'),
1169
+ idempotencyKey,
1170
+ operation,
1171
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1172
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1173
+ },
1174
+ };
1175
+ }
1186
1176
  function secretReplayTombstoneRecord(source, recordId, revision) {
1187
1177
  const value = exactRecord(source.value, 'value', [
1188
1178
  'actorMemberId',
@@ -1240,6 +1230,8 @@ function decodeContinuityRecord(kind, source, recordId, revision) {
1240
1230
  return protectedClaimOverrideEnvelopeRecord(source, recordId, revision);
1241
1231
  case 'manager-responsibility-offer':
1242
1232
  return managerResponsibilityOfferRecord(source, recordId, revision);
1233
+ case 'membership-idempotency-tombstone':
1234
+ return membershipIdempotencyTombstoneRecord(source, recordId, revision);
1243
1235
  case 'project-membership-recovery':
1244
1236
  return membershipRecoveryRecord(source, recordId, revision);
1245
1237
  case 'secret-replay-tombstone':
@@ -1409,6 +1401,7 @@ function validateContinuity(records) {
1409
1401
  item,
1410
1402
  ]));
1411
1403
  const managerOffers = records.filter((item) => (item.kind === 'manager-responsibility-offer'));
1404
+ const membershipIdempotencyTombstones = records.filter((item) => (item.kind === 'membership-idempotency-tombstone'));
1412
1405
  const membershipRecoveries = new Map(records
1413
1406
  .filter((item) => (item.kind === 'project-membership-recovery'))
1414
1407
  .map(item => [item.value.operationId, item]));
@@ -1792,6 +1785,18 @@ function validateContinuity(records) {
1792
1785
  throw invalidPayload('records');
1793
1786
  }
1794
1787
  }
1788
+ for (const tombstone of membershipIdempotencyTombstones) {
1789
+ const exactResult = records.some(item => (item.kind === 'idempotency-result'
1790
+ && item.value.memberId === tombstone.value.actorMemberId
1791
+ && item.value.operation === tombstone.value.operation
1792
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1793
+ const exactOffer = tombstone.value.operation === 'createManagerResponsibilityOffer'
1794
+ && managerOffers.some(item => (item.value.sourceManagerMemberId === tombstone.value.actorMemberId
1795
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1796
+ if (!members.has(tombstone.value.actorMemberId) || exactResult || exactOffer) {
1797
+ throw invalidPayload('records');
1798
+ }
1799
+ }
1795
1800
  for (const item of records) {
1796
1801
  if (item.kind === 'idempotency-result' && !members.has(item.value.memberId)) {
1797
1802
  throw invalidPayload('records');
@@ -1959,55 +1964,18 @@ function validateContinuity(records) {
1959
1964
  }
1960
1965
  }
1961
1966
  }
1962
- function manifestWithFormatOne(value) {
1967
+ function decodeCollabProjectBackupCheckpointManifest(value) {
1963
1968
  const source = record(value, 'manifest');
1964
1969
  if (source.profile !== 'backup'
1965
1970
  || source.coordinationFormatVersion !== exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION)
1966
1971
  throw invalidPayload('manifest');
1967
- return (0, CollabProjectCheckpoint_1.decodeCollabProjectCheckpointManifest)({
1968
- ...source,
1969
- coordinationFormatVersion: CollabProjectCheckpoint_1.COLLAB_PROJECT_COORDINATION_FORMAT_VERSION,
1970
- });
1971
- }
1972
- function decodeCollabProjectBackupCheckpointManifest(value) {
1973
- const decoded = manifestWithFormatOne(value);
1974
- return {
1975
- artifacts: decoded.artifacts,
1976
- coordinationFormatVersion: exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION,
1977
- createdAt: decoded.createdAt,
1978
- expectedMainOid: decoded.expectedMainOid,
1979
- gitObjectFormat: decoded.gitObjectFormat,
1980
- manifestSchemaVersion: decoded.manifestSchemaVersion,
1981
- manifestSha256: decoded.manifestSha256,
1982
- operationId: decoded.operationId,
1983
- profile: 'backup',
1984
- projectId: decoded.projectId,
1985
- protocolVersion: decoded.protocolVersion,
1986
- refs: decoded.refs,
1987
- sourceAuthority: decoded.sourceAuthority,
1988
- targetAuthority: decoded.targetAuthority,
1989
- };
1972
+ return (0, CollabProjectCheckpoint_1.decodeCheckpointManifestFields)(source, exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION);
1990
1973
  }
1991
1974
  function encodeCollabProjectBackupCheckpointManifestCanonicalJson(manifest) {
1992
1975
  return JSON.stringify(decodeCollabProjectBackupCheckpointManifest(manifest));
1993
1976
  }
1994
1977
  function encodeCollabProjectBackupCheckpointManifestDigestInput(manifest) {
1995
- const decoded = decodeCollabProjectBackupCheckpointManifest(manifest);
1996
- return JSON.stringify({
1997
- artifacts: decoded.artifacts,
1998
- coordinationFormatVersion: decoded.coordinationFormatVersion,
1999
- createdAt: decoded.createdAt,
2000
- expectedMainOid: decoded.expectedMainOid,
2001
- gitObjectFormat: decoded.gitObjectFormat,
2002
- manifestSchemaVersion: decoded.manifestSchemaVersion,
2003
- operationId: decoded.operationId,
2004
- profile: decoded.profile,
2005
- projectId: decoded.projectId,
2006
- protocolVersion: decoded.protocolVersion,
2007
- refs: decoded.refs,
2008
- sourceAuthority: decoded.sourceAuthority,
2009
- targetAuthority: decoded.targetAuthority,
2010
- });
1978
+ return (0, CollabProjectCheckpoint_1.encodeCheckpointManifestDigestInput)(decodeCollabProjectBackupCheckpointManifest(manifest));
2011
1979
  }
2012
1980
  function decodeCollabProjectBackupCheckpointCoordinationNdjson(value) {
2013
1981
  if (typeof value !== 'string'
@@ -2027,22 +1995,43 @@ function decodeCollabProjectBackupCheckpointCoordinationNdjson(value) {
2027
1995
  }
2028
1996
  if (JSON.stringify(item) !== line)
2029
1997
  throw invalidPayload('coordination');
2030
- return { envelope: recordEnvelope(item), item };
1998
+ return {
1999
+ envelope: recordEnvelope(item),
2000
+ item,
2001
+ base: undefined,
2002
+ };
2031
2003
  });
2032
2004
  const baseItems = parsed.filter(({ envelope }) => (!CONTINUITY_KIND_SET.has(envelope.kind) && envelope.kind !== 'idempotency-result'));
2033
- const baseDecoded = (0, CollabProjectCheckpoint_1.decodeCollabProjectCheckpointCoordinationNdjson)(baseItems.map(({ item }) => JSON.stringify(sanitizeBackupBasePrincipalRecord(item))).join('\n') + '\n', 'backup');
2034
- const baseByIdentity = new Map(baseDecoded.map(item => [`${item.kind}\0${item.recordId}`, item]));
2035
- const decoded = parsed.map(({ envelope }) => {
2005
+ for (const { envelope } of baseItems) {
2006
+ const value = record(envelope.source.value, 'value');
2007
+ if (envelope.kind === 'terminal-responder' && Array.isArray(value.acknowledgements)) {
2008
+ for (const acknowledgement of value.acknowledgements)
2009
+ record(acknowledgement, 'acknowledgement');
2010
+ }
2011
+ }
2012
+ const baseRecords = [];
2013
+ for (const entry of baseItems) {
2014
+ entry.base = (0, CollabProjectCheckpoint_1.decodeCheckpointRecord)(entry.item, 'deferred');
2015
+ if (JSON.stringify(entry.base) !== JSON.stringify(entry.item)
2016
+ || (entry.base.kind === 'principal-binding' && entry.base.value.principalId === undefined)) {
2017
+ throw invalidPayload('coordination');
2018
+ }
2019
+ baseRecords.push(entry.base);
2020
+ }
2021
+ if (baseRecords.length === 0)
2022
+ throw invalidPayload('coordination');
2023
+ (0, CollabProjectCheckpoint_1.validateCheckpointRecordSequence)(baseRecords, 'backup', 'member');
2024
+ const decoded = parsed.map(({ envelope, base }) => {
2036
2025
  if (envelope.kind === 'idempotency-result') {
2037
2026
  return idempotencyResultRecord(envelope.source, envelope.recordId, envelope.revision);
2038
2027
  }
2039
2028
  if (CONTINUITY_KIND_SET.has(envelope.kind)) {
2040
2029
  return decodeContinuityRecord(envelope.kind, envelope.source, envelope.recordId, envelope.revision);
2041
2030
  }
2042
- const base = baseByIdentity.get(`${envelope.kind}\0${envelope.recordId}`);
2043
2031
  if (base === undefined)
2044
2032
  throw invalidPayload('records');
2045
- return restoreBackupBasePrincipals(envelope.source, base);
2033
+ validateBackupBasePrincipals(base);
2034
+ return base;
2046
2035
  });
2047
2036
  if (decoded.some((item, index) => JSON.stringify(item) !== lines[index])) {
2048
2037
  throw invalidPayload('coordination');
@@ -2069,6 +2058,9 @@ function validateCollabProjectBackupCheckpointConsistency(manifest, records) {
2069
2058
  if (item.kind === 'secret-replay-tombstone') {
2070
2059
  return Date.parse(item.value.expiredAt) > captureTime;
2071
2060
  }
2061
+ if (item.kind === 'membership-idempotency-tombstone') {
2062
+ return Date.parse(item.value.compactedAt) > captureTime;
2063
+ }
2072
2064
  if (item.kind === 'project-invitation') {
2073
2065
  return captureTime < Date.parse(item.value.createdAt)
2074
2066
  || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
@@ -2103,9 +2095,7 @@ function validateCollabProjectBackupCheckpointConsistency(manifest, records) {
2103
2095
  }))
2104
2096
  throw invalidPayload('records');
2105
2097
  const baseRecords = decodedRecords.filter(item => (!CONTINUITY_KIND_SET.has(item.kind) && item.kind !== 'idempotency-result'));
2106
- (0, CollabProjectCheckpoint_1.validateCollabProjectCheckpointConsistency)((0, CollabProjectCheckpoint_1.decodeCollabProjectCheckpointManifest)({
2107
- ...decodedManifest,
2108
- coordinationFormatVersion: CollabProjectCheckpoint_1.COLLAB_PROJECT_COORDINATION_FORMAT_VERSION,
2109
- }), baseRecords);
2098
+ (0, CollabProjectCheckpoint_1.validateCheckpointRecordSequence)(baseRecords, 'backup');
2099
+ (0, CollabProjectCheckpoint_1.validateCheckpointManifestConsistency)(decodedManifest, baseRecords);
2110
2100
  return records;
2111
2101
  }
@@ -265,11 +265,36 @@ export type CollabCheckpointAuthorityVolumePairRecord = CollabCheckpointRecordBa
265
265
  }>;
266
266
  export type CollabCheckpointPortableRecord = CollabCheckpointProjectRecord | CollabCheckpointMemberRecord | CollabCheckpointRequestRecord | CollabCheckpointRequestCommentRecord | CollabCheckpointTicketRecord | CollabCheckpointTicketCommentRecord | CollabCheckpointTicketRelationRecord | CollabCheckpointTicketMentionRecord;
267
267
  export type CollabCheckpointBackupRecord = CollabCheckpointPortableRecord | CollabCheckpointCloudEventRecord | CollabCheckpointCloudEventCursorRecord | CollabCheckpointIdempotencyResultRecord | CollabCheckpointPrincipalBindingRecord | CollabCheckpointRepositoryPlacementRecord | CollabCheckpointLifecycleStateRecord | CollabCheckpointTerminalResponderRecord | CollabCheckpointProtectedClaimEnvelopeRecord | CollabCheckpointTombstoneRecord | CollabCheckpointSchemaCatalogRecord | CollabCheckpointServerCompatibilityRecord | CollabCheckpointAuthorityVolumePairRecord;
268
+ type ParsedCheckpointPrincipalBindingRecord = CollabCheckpointRecordBase<'principal-binding', Omit<CollabCheckpointPrincipalBindingRecord['value'], 'principalId'> & {
269
+ readonly principalId: unknown;
270
+ }>;
271
+ type ParsedCheckpointTerminalResponderRecord = CollabCheckpointRecordBase<'terminal-responder', Omit<CollabCheckpointTerminalResponderRecord['value'], 'acknowledgements'> & {
272
+ readonly acknowledgements: readonly (Omit<CollabCheckpointTerminalAcknowledgement, 'principalId'> & {
273
+ readonly principalId: unknown;
274
+ })[];
275
+ }>;
276
+ /** @internal Format v3 validates principal fields after the shared base-record phase. */
277
+ export type ParsedCheckpointRecord = Exclude<CollabCheckpointBackupRecord, CollabCheckpointPrincipalBindingRecord | CollabCheckpointTerminalResponderRecord> | ParsedCheckpointPrincipalBindingRecord | ParsedCheckpointTerminalResponderRecord;
278
+ type CheckpointManifestFields<CoordinationVersion extends number> = Omit<CollabProjectCheckpointManifest, 'coordinationFormatVersion'> & {
279
+ readonly coordinationFormatVersion: CoordinationVersion;
280
+ };
281
+ /** @internal Shared field validation; public entry points select their own format. */
282
+ export declare function decodeCheckpointManifestFields<CoordinationVersion extends number>(value: unknown, coordinationFormatVersion: CoordinationVersion): CheckpointManifestFields<CoordinationVersion>;
268
283
  export declare function decodeCollabProjectCheckpointManifest(value: unknown): CollabProjectCheckpointManifest;
269
284
  export declare function encodeCollabProjectCheckpointManifestCanonicalJson(manifest: CollabProjectCheckpointManifest): string;
270
285
  export declare function encodeCollabProjectCheckpointManifestDigestInput(manifest: CollabProjectCheckpointManifest): string;
286
+ /** @internal Encode already validated fields without changing their format. */
287
+ export declare function encodeCheckpointManifestDigestInput(decoded: CheckpointManifestFields<number>): string;
271
288
  export declare function encodeCollabProtectedClaimAssociatedData(associatedData: CollabProtectedClaimAssociatedData): string;
289
+ /** @internal Public v1 decoding validates principals in each record. */
290
+ export declare function decodeCheckpointRecord(value: unknown): CollabCheckpointBackupRecord;
291
+ /** @internal Offline v3 decoding validates principals after the base-record sequence. */
292
+ export declare function decodeCheckpointRecord(value: unknown, principalPhase: 'deferred'): ParsedCheckpointRecord;
293
+ /** @internal V3 decoding checks terminal principals separately; consistency binds current principals. */
294
+ export declare function validateCheckpointRecordSequence(records: readonly ParsedCheckpointRecord[], profile: CollabCheckpointProfile, acknowledgementBinding?: 'current-principal' | 'member'): void;
272
295
  export declare function decodeCollabProjectCheckpointCoordinationNdjson(value: string, profile: CollabCheckpointProfile): readonly CollabCheckpointBackupRecord[];
273
296
  export declare function encodeCollabProjectCheckpointCoordinationNdjson(records: readonly CollabCheckpointBackupRecord[], profile: CollabCheckpointProfile): string;
274
297
  export declare function validateCollabProjectCheckpointConsistency(manifest: CollabProjectCheckpointManifest, records: readonly CollabCheckpointBackupRecord[]): readonly CollabCheckpointBackupRecord[];
298
+ /** @internal Cross-artifact checks operate on validated fields, not another format. */
299
+ export declare function validateCheckpointManifestConsistency(decodedManifest: CheckpointManifestFields<number>, records: readonly CollabCheckpointBackupRecord[]): readonly CollabCheckpointBackupRecord[];
275
300
  export {};
@@ -1,13 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS = exports.COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS = exports.COLLAB_CHECKPOINT_ARTIFACT_LIMITS = exports.COLLAB_PROJECT_CHECKPOINT_ARTIFACTS = exports.COLLAB_CHECKPOINT_PROFILES = exports.COLLAB_PROTECTED_CLAIM_ENVELOPE_LIMITS = exports.COLLAB_PROTECTED_CLAIM_ENVELOPE_VERSION = exports.COLLAB_PROJECT_COORDINATION_FORMAT_VERSION = exports.COLLAB_PROJECT_CHECKPOINT_MANIFEST_SCHEMA_VERSION = void 0;
4
+ exports.decodeCheckpointManifestFields = decodeCheckpointManifestFields;
4
5
  exports.decodeCollabProjectCheckpointManifest = decodeCollabProjectCheckpointManifest;
5
6
  exports.encodeCollabProjectCheckpointManifestCanonicalJson = encodeCollabProjectCheckpointManifestCanonicalJson;
6
7
  exports.encodeCollabProjectCheckpointManifestDigestInput = encodeCollabProjectCheckpointManifestDigestInput;
8
+ exports.encodeCheckpointManifestDigestInput = encodeCheckpointManifestDigestInput;
7
9
  exports.encodeCollabProtectedClaimAssociatedData = encodeCollabProtectedClaimAssociatedData;
10
+ exports.decodeCheckpointRecord = decodeCheckpointRecord;
11
+ exports.validateCheckpointRecordSequence = validateCheckpointRecordSequence;
8
12
  exports.decodeCollabProjectCheckpointCoordinationNdjson = decodeCollabProjectCheckpointCoordinationNdjson;
9
13
  exports.encodeCollabProjectCheckpointCoordinationNdjson = encodeCollabProjectCheckpointCoordinationNdjson;
10
14
  exports.validateCollabProjectCheckpointConsistency = validateCollabProjectCheckpointConsistency;
15
+ exports.validateCheckpointManifestConsistency = validateCheckpointManifestConsistency;
11
16
  const CollabAuthorityTransfer_1 = require("./CollabAuthorityTransfer");
12
17
  const CollabCloudProjectEvent_1 = require("./CollabCloudProjectEvent");
13
18
  const CollabConstants_1 = require("./CollabConstants");
@@ -237,7 +242,7 @@ function gitRefs(value, expectedMainOid) {
237
242
  throw invalidPayload('refs');
238
243
  return Object.freeze(decoded);
239
244
  }
240
- function manifestObject(source, includeManifestSha256) {
245
+ function manifestObject(source, coordinationFormatVersion) {
241
246
  const profile = literal(source, 'profile', exports.COLLAB_CHECKPOINT_PROFILES);
242
247
  const expectedMainOid = token(source, 'expectedMainOid', CollabValidation_1.isCollabGitOid);
243
248
  const gitObjectFormat = literal(source, 'gitObjectFormat', ['sha1', 'sha256']);
@@ -273,15 +278,13 @@ function manifestObject(source, includeManifestSha256) {
273
278
  sourceAuthority,
274
279
  targetAuthority,
275
280
  };
276
- if (common.coordinationFormatVersion !== exports.COLLAB_PROJECT_COORDINATION_FORMAT_VERSION
281
+ if (common.coordinationFormatVersion !== coordinationFormatVersion
277
282
  || common.manifestSchemaVersion !== exports.COLLAB_PROJECT_CHECKPOINT_MANIFEST_SCHEMA_VERSION
278
283
  || common.protocolVersion !== CollabConstants_1.COLLAB_PROTOCOL_VERSION)
279
284
  throw invalidPayload('manifest');
280
- if (!includeManifestSha256)
281
- return common;
282
285
  return {
283
286
  artifacts: common.artifacts,
284
- coordinationFormatVersion: exports.COLLAB_PROJECT_COORDINATION_FORMAT_VERSION,
287
+ coordinationFormatVersion,
285
288
  createdAt: common.createdAt,
286
289
  expectedMainOid: common.expectedMainOid,
287
290
  gitObjectFormat: common.gitObjectFormat,
@@ -296,7 +299,8 @@ function manifestObject(source, includeManifestSha256) {
296
299
  targetAuthority: common.targetAuthority,
297
300
  };
298
301
  }
299
- function decodeCollabProjectCheckpointManifest(value) {
302
+ /** @internal Shared field validation; public entry points select their own format. */
303
+ function decodeCheckpointManifestFields(value, coordinationFormatVersion) {
300
304
  const source = exactRecord(value, 'manifest', [
301
305
  'artifacts',
302
306
  'coordinationFormatVersion',
@@ -313,18 +317,24 @@ function decodeCollabProjectCheckpointManifest(value) {
313
317
  'sourceAuthority',
314
318
  'targetAuthority',
315
319
  ]);
316
- const decoded = manifestObject(source, true);
320
+ const decoded = manifestObject(source, coordinationFormatVersion);
317
321
  const encoded = JSON.stringify(decoded);
318
322
  if (!(0, CollabValidation_1.hasUtf8ByteLengthAtMost)(encoded, exports.COLLAB_CHECKPOINT_ARTIFACT_LIMITS.maxManifestBytes)) {
319
323
  throw invalidPayload('manifest');
320
324
  }
321
325
  return decoded;
322
326
  }
327
+ function decodeCollabProjectCheckpointManifest(value) {
328
+ return decodeCheckpointManifestFields(value, exports.COLLAB_PROJECT_COORDINATION_FORMAT_VERSION);
329
+ }
323
330
  function encodeCollabProjectCheckpointManifestCanonicalJson(manifest) {
324
331
  return JSON.stringify(decodeCollabProjectCheckpointManifest(manifest));
325
332
  }
326
333
  function encodeCollabProjectCheckpointManifestDigestInput(manifest) {
327
- const decoded = decodeCollabProjectCheckpointManifest(manifest);
334
+ return encodeCheckpointManifestDigestInput(decodeCollabProjectCheckpointManifest(manifest));
335
+ }
336
+ /** @internal Encode already validated fields without changing their format. */
337
+ function encodeCheckpointManifestDigestInput(decoded) {
328
338
  const source = {
329
339
  artifacts: decoded.artifacts,
330
340
  coordinationFormatVersion: decoded.coordinationFormatVersion,
@@ -707,13 +717,13 @@ function idempotencyResultRecord(source, recordId, revision) {
707
717
  },
708
718
  };
709
719
  }
710
- function principalBindingRecord(source, recordId, revision) {
711
- const value = exactRecord(source.value, 'value', [
720
+ function principalBindingRecord(source, recordId, revision, deferPrincipals) {
721
+ const value = principalRecord(source.value, 'value', [
712
722
  'boundAt',
713
723
  'memberId',
714
724
  'principalId',
715
725
  'projectId',
716
- ]);
726
+ ], deferPrincipals);
717
727
  const memberId = token(value, 'memberId', CollabValidation_1.isCollabMemberId);
718
728
  if (recordId !== memberId)
719
729
  throw invalidPayload('recordId');
@@ -724,7 +734,7 @@ function principalBindingRecord(source, recordId, revision) {
724
734
  value: {
725
735
  boundAt: timestamp(value, 'boundAt'),
726
736
  memberId,
727
- principalId: token(value, 'principalId'),
737
+ principalId: deferPrincipals ? value.principalId : token(value, 'principalId'),
728
738
  projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
729
739
  },
730
740
  };
@@ -858,7 +868,13 @@ function lifecycleStateRecord(source, recordId, revision) {
858
868
  value: internalState,
859
869
  };
860
870
  }
861
- function terminalResponderRecord(source, recordId, revision) {
871
+ function principalRecord(value, field, keys, deferPrincipals) {
872
+ const source = record(value, field);
873
+ return exactRecord(source, field, deferPrincipals && !Object.hasOwn(source, 'principalId')
874
+ ? keys.filter(key => key !== 'principalId')
875
+ : keys);
876
+ }
877
+ function terminalResponderRecord(source, recordId, revision, deferPrincipals) {
862
878
  const value = exactRecord(source.value, 'value', [
863
879
  'acknowledgements',
864
880
  'eligibleMemberIds',
@@ -910,22 +926,24 @@ function terminalResponderRecord(source, recordId, revision) {
910
926
  if (!Array.isArray(value.acknowledgements))
911
927
  throw invalidPayload('acknowledgements');
912
928
  const acknowledgements = value.acknowledgements.map((item) => {
913
- const acknowledgement = exactRecord(item, 'acknowledgement', [
929
+ const acknowledgement = principalRecord(item, 'acknowledgement', [
914
930
  'acknowledgedAt',
915
931
  'memberId',
916
932
  'principalId',
917
- ]);
933
+ ], deferPrincipals);
918
934
  return {
919
935
  acknowledgedAt: timestamp(acknowledgement, 'acknowledgedAt'),
920
936
  memberId: token(acknowledgement, 'memberId', CollabValidation_1.isCollabMemberId),
921
- principalId: token(acknowledgement, 'principalId'),
937
+ principalId: deferPrincipals
938
+ ? acknowledgement.principalId
939
+ : token(acknowledgement, 'principalId'),
922
940
  };
923
941
  });
924
942
  const acknowledgementPrincipals = new Set();
925
943
  acknowledgements.forEach((item, index) => {
926
944
  if (!eligibleMemberIds.includes(item.memberId)
927
945
  || (index > 0 && acknowledgements[index - 1].memberId.localeCompare(item.memberId, 'en-US') >= 0)
928
- || acknowledgementPrincipals.has(item.principalId))
946
+ || (!deferPrincipals && acknowledgementPrincipals.has(item.principalId)))
929
947
  throw invalidPayload('acknowledgements');
930
948
  acknowledgementPrincipals.add(item.principalId);
931
949
  });
@@ -934,7 +952,7 @@ function terminalResponderRecord(source, recordId, revision) {
934
952
  recordId,
935
953
  revision,
936
954
  value: {
937
- acknowledgements: Object.freeze(acknowledgements),
955
+ acknowledgements,
938
956
  eligibleMemberIds: Object.freeze(eligibleMemberIds),
939
957
  expiresAt,
940
958
  operation,
@@ -1107,8 +1125,9 @@ function authorityVolumePairRecord(source, recordId, revision) {
1107
1125
  },
1108
1126
  };
1109
1127
  }
1110
- function decodeCheckpointRecord(value) {
1128
+ function decodeCheckpointRecord(value, principalPhase) {
1111
1129
  const { kind, recordId, revision, source } = recordEnvelope(value);
1130
+ const deferPrincipals = principalPhase === 'deferred';
1112
1131
  switch (kind) {
1113
1132
  case 'project': return projectRecord(source, recordId, revision);
1114
1133
  case 'member': return memberRecord(source, recordId, revision);
@@ -1121,10 +1140,12 @@ function decodeCheckpointRecord(value) {
1121
1140
  case 'cloud-event': return cloudEventRecord(source, recordId, revision);
1122
1141
  case 'cloud-event-cursor': return cloudEventCursorRecord(source, recordId, revision);
1123
1142
  case 'idempotency-result': return idempotencyResultRecord(source, recordId, revision);
1124
- case 'principal-binding': return principalBindingRecord(source, recordId, revision);
1143
+ case 'principal-binding':
1144
+ return principalBindingRecord(source, recordId, revision, deferPrincipals);
1125
1145
  case 'repository-placement': return repositoryPlacementRecord(source, recordId, revision);
1126
1146
  case 'lifecycle-state': return lifecycleStateRecord(source, recordId, revision);
1127
- case 'terminal-responder': return terminalResponderRecord(source, recordId, revision);
1147
+ case 'terminal-responder':
1148
+ return terminalResponderRecord(source, recordId, revision, deferPrincipals);
1128
1149
  case 'protected-claim-envelope':
1129
1150
  return protectedClaimEnvelopeRecord(source, recordId, revision);
1130
1151
  case 'tombstone': return tombstoneRecord(source, recordId, revision);
@@ -1140,7 +1161,8 @@ function compareRecords(left, right) {
1140
1161
  const kindOrder = recordKindOrder(left.kind) - recordKindOrder(right.kind);
1141
1162
  return kindOrder !== 0 ? kindOrder : left.recordId.localeCompare(right.recordId, 'en-US');
1142
1163
  }
1143
- function validateRecordSequence(records, profile) {
1164
+ /** @internal V3 decoding checks terminal principals separately; consistency binds current principals. */
1165
+ function validateCheckpointRecordSequence(records, profile, acknowledgementBinding = 'current-principal') {
1144
1166
  if (records.length === 0 || records[0].kind !== 'project')
1145
1167
  throw invalidPayload('records');
1146
1168
  const projectId = records[0].recordId;
@@ -1243,7 +1265,9 @@ function validateRecordSequence(records, profile) {
1243
1265
  throw invalidPayload('records');
1244
1266
  }
1245
1267
  if (item.kind === 'terminal-responder' && (item.value.eligibleMemberIds.some(memberId => !members.has(memberId))
1246
- || item.value.acknowledgements.some(acknowledgement => (principalBindings.get(acknowledgement.memberId) !== acknowledgement.principalId))))
1268
+ || item.value.acknowledgements.some(acknowledgement => (acknowledgementBinding === 'current-principal'
1269
+ ? principalBindings.get(acknowledgement.memberId) !== acknowledgement.principalId
1270
+ : !principalBindings.has(acknowledgement.memberId)))))
1247
1271
  throw invalidPayload('records');
1248
1272
  }
1249
1273
  if (profile === 'backup') {
@@ -1270,11 +1294,13 @@ function decodeCollabProjectCheckpointCoordinationNdjson(value, profile) {
1270
1294
  throw invalidPayload('coordination');
1271
1295
  }
1272
1296
  const result = decodeCheckpointRecord(parsed);
1297
+ if (result.kind === 'terminal-responder')
1298
+ Object.freeze(result.value.acknowledgements);
1273
1299
  if (JSON.stringify(result) !== line)
1274
1300
  throw invalidPayload('coordination');
1275
1301
  return result;
1276
1302
  });
1277
- validateRecordSequence(decoded, profile);
1303
+ validateCheckpointRecordSequence(decoded, profile);
1278
1304
  return Object.freeze(decoded);
1279
1305
  }
1280
1306
  function encodeCollabProjectCheckpointCoordinationNdjson(records, profile) {
@@ -1306,7 +1332,11 @@ function checkpointRecordGitOids(checkpointRecord) {
1306
1332
  }
1307
1333
  function validateCollabProjectCheckpointConsistency(manifest, records) {
1308
1334
  const decodedManifest = decodeCollabProjectCheckpointManifest(manifest);
1309
- validateRecordSequence(records, decodedManifest.profile);
1335
+ validateCheckpointRecordSequence(records, decodedManifest.profile);
1336
+ return validateCheckpointManifestConsistency(decodedManifest, records);
1337
+ }
1338
+ /** @internal Cross-artifact checks operate on validated fields, not another format. */
1339
+ function validateCheckpointManifestConsistency(decodedManifest, records) {
1310
1340
  const project = records[0];
1311
1341
  if (project.kind !== 'project'
1312
1342
  || project.value.projectId !== decodedManifest.projectId
@@ -2,7 +2,7 @@ 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_PORTABLE_RECORD_KINDS, COLLAB_PROJECT_COORDINATION_FORMAT_VERSION, decodeCollabProjectCheckpointCoordinationNdjson, decodeCollabProjectCheckpointManifest, validateCollabProjectCheckpointConsistency, } from './CollabProjectCheckpoint.mjs';
5
+ import { COLLAB_CHECKPOINT_ARTIFACT_LIMITS, COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS, decodeCheckpointManifestFields, decodeCheckpointRecord, encodeCheckpointManifestDigestInput, validateCheckpointManifestConsistency, validateCheckpointRecordSequence, } from './CollabProjectCheckpoint.mjs';
6
6
  import { COLLAB_PROJECT_MEMBERSHIP_LIMITS, } from './CollabProjectMembership.mjs';
7
7
  import { hasUtf8ByteLengthAtMost, isCollabGitOid, isCollabMemberId, isCollabOpaqueId, isCollabProjectId, } from './CollabValidation.mjs';
8
8
  export const COLLAB_PROJECT_BACKUP_COMPATIBILITY_STAGE = 'pre-production-replaceable';
@@ -22,6 +22,7 @@ const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
22
22
  'transferred-membership-claim-override',
23
23
  'protected-claim-override-envelope',
24
24
  'manager-responsibility-offer',
25
+ 'membership-idempotency-tombstone',
25
26
  'project-membership-recovery',
26
27
  'secret-replay-tombstone',
27
28
  ]);
@@ -197,65 +198,19 @@ function absoluteTargetUrl(source) {
197
198
  function principal(source, field) {
198
199
  return token(source, field, value => (typeof value === 'string' && PRINCIPAL_PATTERN.test(value)));
199
200
  }
200
- function sanitizeBackupBasePrincipalRecord(item) {
201
- const source = record(item, 'record');
202
- const value = record(source.value, 'value');
203
- if (source.kind === 'principal-binding') {
204
- return {
205
- ...source,
206
- value: { ...value, principalId: `principal_${String(value.memberId)}` },
207
- };
208
- }
209
- if (source.kind === 'terminal-responder' && Array.isArray(value.acknowledgements)) {
210
- return {
211
- ...source,
212
- value: {
213
- ...value,
214
- acknowledgements: value.acknowledgements.map(item => ({
215
- ...record(item, 'acknowledgement'),
216
- principalId: `principal_${String(record(item, 'acknowledgement').memberId)}`,
217
- })),
218
- },
219
- };
220
- }
221
- return item;
222
- }
223
- function restoreBackupBasePrincipals(source, base) {
224
- if (base.kind === 'principal-binding') {
225
- const value = exactRecord(source.value, 'value', [
226
- 'boundAt', 'memberId', 'principalId', 'projectId',
227
- ]);
228
- return {
229
- ...base,
230
- value: { ...base.value, principalId: principal(value, 'principalId') },
231
- };
232
- }
201
+ function validateBackupBasePrincipals(base) {
202
+ if (base.kind === 'principal-binding')
203
+ principal(base.value, 'principalId');
233
204
  if (base.kind === 'terminal-responder') {
234
- const value = exactRecord(source.value, 'value', [
235
- 'acknowledgements',
236
- 'eligibleMemberIds',
237
- 'expiresAt',
238
- 'operation',
239
- 'operationId',
240
- 'projectId',
241
- 'responseJson',
242
- ]);
243
- const sourceAcknowledgements = value.acknowledgements;
244
- if (!Array.isArray(sourceAcknowledgements)
245
- || sourceAcknowledgements.length !== base.value.acknowledgements.length)
246
- throw invalidPayload('acknowledgements');
247
- const acknowledgements = base.value.acknowledgements.map((item, index) => {
248
- const original = exactRecord(sourceAcknowledgements[index], 'acknowledgement', [
249
- 'acknowledgedAt', 'memberId', 'principalId',
250
- ]);
251
- return { ...item, principalId: principal(original, 'principalId') };
252
- });
253
- if (new Set(acknowledgements.map(item => item.principalId)).size !== acknowledgements.length) {
254
- throw invalidPayload('acknowledgements');
205
+ for (const acknowledgement of base.value.acknowledgements) {
206
+ if (acknowledgement.principalId === undefined)
207
+ throw invalidPayload('acknowledgement');
208
+ principal(acknowledgement, 'principalId');
255
209
  }
256
- return { ...base, value: { ...base.value, acknowledgements } };
210
+ if (new Set(base.value.acknowledgements.map(item => item.principalId)).size
211
+ !== base.value.acknowledgements.length)
212
+ throw invalidPayload('acknowledgements');
257
213
  }
258
- return base;
259
214
  }
260
215
  function repositoryPublicationRef(value) {
261
216
  const source = exactRecord(value, 'ref', ['name', 'oid']);
@@ -1173,6 +1128,41 @@ function membershipRecoveryRecord(source, recordId, revision) {
1173
1128
  },
1174
1129
  };
1175
1130
  }
1131
+ function membershipIdempotencyTombstoneRecord(source, recordId, revision) {
1132
+ const value = exactRecord(source.value, 'value', [
1133
+ 'actorMemberId',
1134
+ 'compactedAt',
1135
+ 'idempotencyKey',
1136
+ 'operation',
1137
+ 'projectId',
1138
+ 'requestFingerprint',
1139
+ ]);
1140
+ const actorMemberId = token(value, 'actorMemberId', isCollabMemberId);
1141
+ const idempotencyKey = token(value, 'idempotencyKey');
1142
+ const operation = literal(value, 'operation', [
1143
+ 'createManagerResponsibilityOffer',
1144
+ 'acknowledgeManagerResponsibility',
1145
+ 'declineManagerResponsibility',
1146
+ 'cancelManagerResponsibilityOffer',
1147
+ 'promoteManager',
1148
+ ]);
1149
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1150
+ throw invalidPayload('recordId');
1151
+ }
1152
+ return {
1153
+ kind: 'membership-idempotency-tombstone',
1154
+ recordId,
1155
+ revision,
1156
+ value: {
1157
+ actorMemberId,
1158
+ compactedAt: timestamp(value, 'compactedAt'),
1159
+ idempotencyKey,
1160
+ operation,
1161
+ projectId: token(value, 'projectId', isCollabProjectId),
1162
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1163
+ },
1164
+ };
1165
+ }
1176
1166
  function secretReplayTombstoneRecord(source, recordId, revision) {
1177
1167
  const value = exactRecord(source.value, 'value', [
1178
1168
  'actorMemberId',
@@ -1230,6 +1220,8 @@ function decodeContinuityRecord(kind, source, recordId, revision) {
1230
1220
  return protectedClaimOverrideEnvelopeRecord(source, recordId, revision);
1231
1221
  case 'manager-responsibility-offer':
1232
1222
  return managerResponsibilityOfferRecord(source, recordId, revision);
1223
+ case 'membership-idempotency-tombstone':
1224
+ return membershipIdempotencyTombstoneRecord(source, recordId, revision);
1233
1225
  case 'project-membership-recovery':
1234
1226
  return membershipRecoveryRecord(source, recordId, revision);
1235
1227
  case 'secret-replay-tombstone':
@@ -1399,6 +1391,7 @@ function validateContinuity(records) {
1399
1391
  item,
1400
1392
  ]));
1401
1393
  const managerOffers = records.filter((item) => (item.kind === 'manager-responsibility-offer'));
1394
+ const membershipIdempotencyTombstones = records.filter((item) => (item.kind === 'membership-idempotency-tombstone'));
1402
1395
  const membershipRecoveries = new Map(records
1403
1396
  .filter((item) => (item.kind === 'project-membership-recovery'))
1404
1397
  .map(item => [item.value.operationId, item]));
@@ -1782,6 +1775,18 @@ function validateContinuity(records) {
1782
1775
  throw invalidPayload('records');
1783
1776
  }
1784
1777
  }
1778
+ for (const tombstone of membershipIdempotencyTombstones) {
1779
+ const exactResult = records.some(item => (item.kind === 'idempotency-result'
1780
+ && item.value.memberId === tombstone.value.actorMemberId
1781
+ && item.value.operation === tombstone.value.operation
1782
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1783
+ const exactOffer = tombstone.value.operation === 'createManagerResponsibilityOffer'
1784
+ && managerOffers.some(item => (item.value.sourceManagerMemberId === tombstone.value.actorMemberId
1785
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1786
+ if (!members.has(tombstone.value.actorMemberId) || exactResult || exactOffer) {
1787
+ throw invalidPayload('records');
1788
+ }
1789
+ }
1785
1790
  for (const item of records) {
1786
1791
  if (item.kind === 'idempotency-result' && !members.has(item.value.memberId)) {
1787
1792
  throw invalidPayload('records');
@@ -1949,55 +1954,18 @@ function validateContinuity(records) {
1949
1954
  }
1950
1955
  }
1951
1956
  }
1952
- function manifestWithFormatOne(value) {
1957
+ export function decodeCollabProjectBackupCheckpointManifest(value) {
1953
1958
  const source = record(value, 'manifest');
1954
1959
  if (source.profile !== 'backup'
1955
1960
  || source.coordinationFormatVersion !== COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION)
1956
1961
  throw invalidPayload('manifest');
1957
- return decodeCollabProjectCheckpointManifest({
1958
- ...source,
1959
- coordinationFormatVersion: COLLAB_PROJECT_COORDINATION_FORMAT_VERSION,
1960
- });
1961
- }
1962
- export function decodeCollabProjectBackupCheckpointManifest(value) {
1963
- const decoded = manifestWithFormatOne(value);
1964
- return {
1965
- artifacts: decoded.artifacts,
1966
- coordinationFormatVersion: COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION,
1967
- createdAt: decoded.createdAt,
1968
- expectedMainOid: decoded.expectedMainOid,
1969
- gitObjectFormat: decoded.gitObjectFormat,
1970
- manifestSchemaVersion: decoded.manifestSchemaVersion,
1971
- manifestSha256: decoded.manifestSha256,
1972
- operationId: decoded.operationId,
1973
- profile: 'backup',
1974
- projectId: decoded.projectId,
1975
- protocolVersion: decoded.protocolVersion,
1976
- refs: decoded.refs,
1977
- sourceAuthority: decoded.sourceAuthority,
1978
- targetAuthority: decoded.targetAuthority,
1979
- };
1962
+ return decodeCheckpointManifestFields(source, COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION);
1980
1963
  }
1981
1964
  export function encodeCollabProjectBackupCheckpointManifestCanonicalJson(manifest) {
1982
1965
  return JSON.stringify(decodeCollabProjectBackupCheckpointManifest(manifest));
1983
1966
  }
1984
1967
  export function encodeCollabProjectBackupCheckpointManifestDigestInput(manifest) {
1985
- const decoded = decodeCollabProjectBackupCheckpointManifest(manifest);
1986
- return JSON.stringify({
1987
- artifacts: decoded.artifacts,
1988
- coordinationFormatVersion: decoded.coordinationFormatVersion,
1989
- createdAt: decoded.createdAt,
1990
- expectedMainOid: decoded.expectedMainOid,
1991
- gitObjectFormat: decoded.gitObjectFormat,
1992
- manifestSchemaVersion: decoded.manifestSchemaVersion,
1993
- operationId: decoded.operationId,
1994
- profile: decoded.profile,
1995
- projectId: decoded.projectId,
1996
- protocolVersion: decoded.protocolVersion,
1997
- refs: decoded.refs,
1998
- sourceAuthority: decoded.sourceAuthority,
1999
- targetAuthority: decoded.targetAuthority,
2000
- });
1968
+ return encodeCheckpointManifestDigestInput(decodeCollabProjectBackupCheckpointManifest(manifest));
2001
1969
  }
2002
1970
  export function decodeCollabProjectBackupCheckpointCoordinationNdjson(value) {
2003
1971
  if (typeof value !== 'string'
@@ -2017,22 +1985,43 @@ export function decodeCollabProjectBackupCheckpointCoordinationNdjson(value) {
2017
1985
  }
2018
1986
  if (JSON.stringify(item) !== line)
2019
1987
  throw invalidPayload('coordination');
2020
- return { envelope: recordEnvelope(item), item };
1988
+ return {
1989
+ envelope: recordEnvelope(item),
1990
+ item,
1991
+ base: undefined,
1992
+ };
2021
1993
  });
2022
1994
  const baseItems = parsed.filter(({ envelope }) => (!CONTINUITY_KIND_SET.has(envelope.kind) && envelope.kind !== 'idempotency-result'));
2023
- const baseDecoded = decodeCollabProjectCheckpointCoordinationNdjson(baseItems.map(({ item }) => JSON.stringify(sanitizeBackupBasePrincipalRecord(item))).join('\n') + '\n', 'backup');
2024
- const baseByIdentity = new Map(baseDecoded.map(item => [`${item.kind}\0${item.recordId}`, item]));
2025
- const decoded = parsed.map(({ envelope }) => {
1995
+ for (const { envelope } of baseItems) {
1996
+ const value = record(envelope.source.value, 'value');
1997
+ if (envelope.kind === 'terminal-responder' && Array.isArray(value.acknowledgements)) {
1998
+ for (const acknowledgement of value.acknowledgements)
1999
+ record(acknowledgement, 'acknowledgement');
2000
+ }
2001
+ }
2002
+ const baseRecords = [];
2003
+ for (const entry of baseItems) {
2004
+ entry.base = decodeCheckpointRecord(entry.item, 'deferred');
2005
+ if (JSON.stringify(entry.base) !== JSON.stringify(entry.item)
2006
+ || (entry.base.kind === 'principal-binding' && entry.base.value.principalId === undefined)) {
2007
+ throw invalidPayload('coordination');
2008
+ }
2009
+ baseRecords.push(entry.base);
2010
+ }
2011
+ if (baseRecords.length === 0)
2012
+ throw invalidPayload('coordination');
2013
+ validateCheckpointRecordSequence(baseRecords, 'backup', 'member');
2014
+ const decoded = parsed.map(({ envelope, base }) => {
2026
2015
  if (envelope.kind === 'idempotency-result') {
2027
2016
  return idempotencyResultRecord(envelope.source, envelope.recordId, envelope.revision);
2028
2017
  }
2029
2018
  if (CONTINUITY_KIND_SET.has(envelope.kind)) {
2030
2019
  return decodeContinuityRecord(envelope.kind, envelope.source, envelope.recordId, envelope.revision);
2031
2020
  }
2032
- const base = baseByIdentity.get(`${envelope.kind}\0${envelope.recordId}`);
2033
2021
  if (base === undefined)
2034
2022
  throw invalidPayload('records');
2035
- return restoreBackupBasePrincipals(envelope.source, base);
2023
+ validateBackupBasePrincipals(base);
2024
+ return base;
2036
2025
  });
2037
2026
  if (decoded.some((item, index) => JSON.stringify(item) !== lines[index])) {
2038
2027
  throw invalidPayload('coordination');
@@ -2059,6 +2048,9 @@ export function validateCollabProjectBackupCheckpointConsistency(manifest, recor
2059
2048
  if (item.kind === 'secret-replay-tombstone') {
2060
2049
  return Date.parse(item.value.expiredAt) > captureTime;
2061
2050
  }
2051
+ if (item.kind === 'membership-idempotency-tombstone') {
2052
+ return Date.parse(item.value.compactedAt) > captureTime;
2053
+ }
2062
2054
  if (item.kind === 'project-invitation') {
2063
2055
  return captureTime < Date.parse(item.value.createdAt)
2064
2056
  || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
@@ -2093,9 +2085,7 @@ export function validateCollabProjectBackupCheckpointConsistency(manifest, recor
2093
2085
  }))
2094
2086
  throw invalidPayload('records');
2095
2087
  const baseRecords = decodedRecords.filter(item => (!CONTINUITY_KIND_SET.has(item.kind) && item.kind !== 'idempotency-result'));
2096
- validateCollabProjectCheckpointConsistency(decodeCollabProjectCheckpointManifest({
2097
- ...decodedManifest,
2098
- coordinationFormatVersion: COLLAB_PROJECT_COORDINATION_FORMAT_VERSION,
2099
- }), baseRecords);
2088
+ validateCheckpointRecordSequence(baseRecords, 'backup');
2089
+ validateCheckpointManifestConsistency(decodedManifest, baseRecords);
2100
2090
  return records;
2101
2091
  }
@@ -227,7 +227,7 @@ function gitRefs(value, expectedMainOid) {
227
227
  throw invalidPayload('refs');
228
228
  return Object.freeze(decoded);
229
229
  }
230
- function manifestObject(source, includeManifestSha256) {
230
+ function manifestObject(source, coordinationFormatVersion) {
231
231
  const profile = literal(source, 'profile', COLLAB_CHECKPOINT_PROFILES);
232
232
  const expectedMainOid = token(source, 'expectedMainOid', isCollabGitOid);
233
233
  const gitObjectFormat = literal(source, 'gitObjectFormat', ['sha1', 'sha256']);
@@ -263,15 +263,13 @@ function manifestObject(source, includeManifestSha256) {
263
263
  sourceAuthority,
264
264
  targetAuthority,
265
265
  };
266
- if (common.coordinationFormatVersion !== COLLAB_PROJECT_COORDINATION_FORMAT_VERSION
266
+ if (common.coordinationFormatVersion !== coordinationFormatVersion
267
267
  || common.manifestSchemaVersion !== COLLAB_PROJECT_CHECKPOINT_MANIFEST_SCHEMA_VERSION
268
268
  || common.protocolVersion !== COLLAB_PROTOCOL_VERSION)
269
269
  throw invalidPayload('manifest');
270
- if (!includeManifestSha256)
271
- return common;
272
270
  return {
273
271
  artifacts: common.artifacts,
274
- coordinationFormatVersion: COLLAB_PROJECT_COORDINATION_FORMAT_VERSION,
272
+ coordinationFormatVersion,
275
273
  createdAt: common.createdAt,
276
274
  expectedMainOid: common.expectedMainOid,
277
275
  gitObjectFormat: common.gitObjectFormat,
@@ -286,7 +284,8 @@ function manifestObject(source, includeManifestSha256) {
286
284
  targetAuthority: common.targetAuthority,
287
285
  };
288
286
  }
289
- export function decodeCollabProjectCheckpointManifest(value) {
287
+ /** @internal Shared field validation; public entry points select their own format. */
288
+ export function decodeCheckpointManifestFields(value, coordinationFormatVersion) {
290
289
  const source = exactRecord(value, 'manifest', [
291
290
  'artifacts',
292
291
  'coordinationFormatVersion',
@@ -303,18 +302,24 @@ export function decodeCollabProjectCheckpointManifest(value) {
303
302
  'sourceAuthority',
304
303
  'targetAuthority',
305
304
  ]);
306
- const decoded = manifestObject(source, true);
305
+ const decoded = manifestObject(source, coordinationFormatVersion);
307
306
  const encoded = JSON.stringify(decoded);
308
307
  if (!hasUtf8ByteLengthAtMost(encoded, COLLAB_CHECKPOINT_ARTIFACT_LIMITS.maxManifestBytes)) {
309
308
  throw invalidPayload('manifest');
310
309
  }
311
310
  return decoded;
312
311
  }
312
+ export function decodeCollabProjectCheckpointManifest(value) {
313
+ return decodeCheckpointManifestFields(value, COLLAB_PROJECT_COORDINATION_FORMAT_VERSION);
314
+ }
313
315
  export function encodeCollabProjectCheckpointManifestCanonicalJson(manifest) {
314
316
  return JSON.stringify(decodeCollabProjectCheckpointManifest(manifest));
315
317
  }
316
318
  export function encodeCollabProjectCheckpointManifestDigestInput(manifest) {
317
- const decoded = decodeCollabProjectCheckpointManifest(manifest);
319
+ return encodeCheckpointManifestDigestInput(decodeCollabProjectCheckpointManifest(manifest));
320
+ }
321
+ /** @internal Encode already validated fields without changing their format. */
322
+ export function encodeCheckpointManifestDigestInput(decoded) {
318
323
  const source = {
319
324
  artifacts: decoded.artifacts,
320
325
  coordinationFormatVersion: decoded.coordinationFormatVersion,
@@ -697,13 +702,13 @@ function idempotencyResultRecord(source, recordId, revision) {
697
702
  },
698
703
  };
699
704
  }
700
- function principalBindingRecord(source, recordId, revision) {
701
- const value = exactRecord(source.value, 'value', [
705
+ function principalBindingRecord(source, recordId, revision, deferPrincipals) {
706
+ const value = principalRecord(source.value, 'value', [
702
707
  'boundAt',
703
708
  'memberId',
704
709
  'principalId',
705
710
  'projectId',
706
- ]);
711
+ ], deferPrincipals);
707
712
  const memberId = token(value, 'memberId', isCollabMemberId);
708
713
  if (recordId !== memberId)
709
714
  throw invalidPayload('recordId');
@@ -714,7 +719,7 @@ function principalBindingRecord(source, recordId, revision) {
714
719
  value: {
715
720
  boundAt: timestamp(value, 'boundAt'),
716
721
  memberId,
717
- principalId: token(value, 'principalId'),
722
+ principalId: deferPrincipals ? value.principalId : token(value, 'principalId'),
718
723
  projectId: token(value, 'projectId', isCollabProjectId),
719
724
  },
720
725
  };
@@ -848,7 +853,13 @@ function lifecycleStateRecord(source, recordId, revision) {
848
853
  value: internalState,
849
854
  };
850
855
  }
851
- function terminalResponderRecord(source, recordId, revision) {
856
+ function principalRecord(value, field, keys, deferPrincipals) {
857
+ const source = record(value, field);
858
+ return exactRecord(source, field, deferPrincipals && !Object.hasOwn(source, 'principalId')
859
+ ? keys.filter(key => key !== 'principalId')
860
+ : keys);
861
+ }
862
+ function terminalResponderRecord(source, recordId, revision, deferPrincipals) {
852
863
  const value = exactRecord(source.value, 'value', [
853
864
  'acknowledgements',
854
865
  'eligibleMemberIds',
@@ -900,22 +911,24 @@ function terminalResponderRecord(source, recordId, revision) {
900
911
  if (!Array.isArray(value.acknowledgements))
901
912
  throw invalidPayload('acknowledgements');
902
913
  const acknowledgements = value.acknowledgements.map((item) => {
903
- const acknowledgement = exactRecord(item, 'acknowledgement', [
914
+ const acknowledgement = principalRecord(item, 'acknowledgement', [
904
915
  'acknowledgedAt',
905
916
  'memberId',
906
917
  'principalId',
907
- ]);
918
+ ], deferPrincipals);
908
919
  return {
909
920
  acknowledgedAt: timestamp(acknowledgement, 'acknowledgedAt'),
910
921
  memberId: token(acknowledgement, 'memberId', isCollabMemberId),
911
- principalId: token(acknowledgement, 'principalId'),
922
+ principalId: deferPrincipals
923
+ ? acknowledgement.principalId
924
+ : token(acknowledgement, 'principalId'),
912
925
  };
913
926
  });
914
927
  const acknowledgementPrincipals = new Set();
915
928
  acknowledgements.forEach((item, index) => {
916
929
  if (!eligibleMemberIds.includes(item.memberId)
917
930
  || (index > 0 && acknowledgements[index - 1].memberId.localeCompare(item.memberId, 'en-US') >= 0)
918
- || acknowledgementPrincipals.has(item.principalId))
931
+ || (!deferPrincipals && acknowledgementPrincipals.has(item.principalId)))
919
932
  throw invalidPayload('acknowledgements');
920
933
  acknowledgementPrincipals.add(item.principalId);
921
934
  });
@@ -924,7 +937,7 @@ function terminalResponderRecord(source, recordId, revision) {
924
937
  recordId,
925
938
  revision,
926
939
  value: {
927
- acknowledgements: Object.freeze(acknowledgements),
940
+ acknowledgements,
928
941
  eligibleMemberIds: Object.freeze(eligibleMemberIds),
929
942
  expiresAt,
930
943
  operation,
@@ -1097,8 +1110,9 @@ function authorityVolumePairRecord(source, recordId, revision) {
1097
1110
  },
1098
1111
  };
1099
1112
  }
1100
- function decodeCheckpointRecord(value) {
1113
+ export function decodeCheckpointRecord(value, principalPhase) {
1101
1114
  const { kind, recordId, revision, source } = recordEnvelope(value);
1115
+ const deferPrincipals = principalPhase === 'deferred';
1102
1116
  switch (kind) {
1103
1117
  case 'project': return projectRecord(source, recordId, revision);
1104
1118
  case 'member': return memberRecord(source, recordId, revision);
@@ -1111,10 +1125,12 @@ function decodeCheckpointRecord(value) {
1111
1125
  case 'cloud-event': return cloudEventRecord(source, recordId, revision);
1112
1126
  case 'cloud-event-cursor': return cloudEventCursorRecord(source, recordId, revision);
1113
1127
  case 'idempotency-result': return idempotencyResultRecord(source, recordId, revision);
1114
- case 'principal-binding': return principalBindingRecord(source, recordId, revision);
1128
+ case 'principal-binding':
1129
+ return principalBindingRecord(source, recordId, revision, deferPrincipals);
1115
1130
  case 'repository-placement': return repositoryPlacementRecord(source, recordId, revision);
1116
1131
  case 'lifecycle-state': return lifecycleStateRecord(source, recordId, revision);
1117
- case 'terminal-responder': return terminalResponderRecord(source, recordId, revision);
1132
+ case 'terminal-responder':
1133
+ return terminalResponderRecord(source, recordId, revision, deferPrincipals);
1118
1134
  case 'protected-claim-envelope':
1119
1135
  return protectedClaimEnvelopeRecord(source, recordId, revision);
1120
1136
  case 'tombstone': return tombstoneRecord(source, recordId, revision);
@@ -1130,7 +1146,8 @@ function compareRecords(left, right) {
1130
1146
  const kindOrder = recordKindOrder(left.kind) - recordKindOrder(right.kind);
1131
1147
  return kindOrder !== 0 ? kindOrder : left.recordId.localeCompare(right.recordId, 'en-US');
1132
1148
  }
1133
- function validateRecordSequence(records, profile) {
1149
+ /** @internal V3 decoding checks terminal principals separately; consistency binds current principals. */
1150
+ export function validateCheckpointRecordSequence(records, profile, acknowledgementBinding = 'current-principal') {
1134
1151
  if (records.length === 0 || records[0].kind !== 'project')
1135
1152
  throw invalidPayload('records');
1136
1153
  const projectId = records[0].recordId;
@@ -1233,7 +1250,9 @@ function validateRecordSequence(records, profile) {
1233
1250
  throw invalidPayload('records');
1234
1251
  }
1235
1252
  if (item.kind === 'terminal-responder' && (item.value.eligibleMemberIds.some(memberId => !members.has(memberId))
1236
- || item.value.acknowledgements.some(acknowledgement => (principalBindings.get(acknowledgement.memberId) !== acknowledgement.principalId))))
1253
+ || item.value.acknowledgements.some(acknowledgement => (acknowledgementBinding === 'current-principal'
1254
+ ? principalBindings.get(acknowledgement.memberId) !== acknowledgement.principalId
1255
+ : !principalBindings.has(acknowledgement.memberId)))))
1237
1256
  throw invalidPayload('records');
1238
1257
  }
1239
1258
  if (profile === 'backup') {
@@ -1260,11 +1279,13 @@ export function decodeCollabProjectCheckpointCoordinationNdjson(value, profile)
1260
1279
  throw invalidPayload('coordination');
1261
1280
  }
1262
1281
  const result = decodeCheckpointRecord(parsed);
1282
+ if (result.kind === 'terminal-responder')
1283
+ Object.freeze(result.value.acknowledgements);
1263
1284
  if (JSON.stringify(result) !== line)
1264
1285
  throw invalidPayload('coordination');
1265
1286
  return result;
1266
1287
  });
1267
- validateRecordSequence(decoded, profile);
1288
+ validateCheckpointRecordSequence(decoded, profile);
1268
1289
  return Object.freeze(decoded);
1269
1290
  }
1270
1291
  export function encodeCollabProjectCheckpointCoordinationNdjson(records, profile) {
@@ -1296,7 +1317,11 @@ function checkpointRecordGitOids(checkpointRecord) {
1296
1317
  }
1297
1318
  export function validateCollabProjectCheckpointConsistency(manifest, records) {
1298
1319
  const decodedManifest = decodeCollabProjectCheckpointManifest(manifest);
1299
- validateRecordSequence(records, decodedManifest.profile);
1320
+ validateCheckpointRecordSequence(records, decodedManifest.profile);
1321
+ return validateCheckpointManifestConsistency(decodedManifest, records);
1322
+ }
1323
+ /** @internal Cross-artifact checks operate on validated fields, not another format. */
1324
+ export function validateCheckpointManifestConsistency(decodedManifest, records) {
1300
1325
  const project = records[0];
1301
1326
  if (project.kind !== 'project'
1302
1327
  || project.value.projectId !== decodedManifest.projectId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudian-collab/protocol",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "Canonical Collab wire and Cloud binding contract for Claudian clients and servers.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,7 +46,7 @@
46
46
  "release:candidate": "node scripts/release-candidate.mjs --output .context/release",
47
47
  "test": "jest && node --test tests/*.test.mjs",
48
48
  "typecheck": "tsc --noEmit -p tsconfig.jest.json",
49
- "verify": "npm run lint && npm run typecheck && npm run build && npm run test && npm run check:compatibility && npm run check:migration-provenance && npm run verify:pack && npm run release:candidate",
49
+ "verify": "npm run lint && npm run typecheck && npm run release:candidate && npm run test && npm run check:compatibility && npm run check:migration-provenance && npm run verify:pack",
50
50
  "verify:pack": "node scripts/verify-pack.mjs"
51
51
  },
52
52
  "dependencies": {