@enbox/agent 0.7.7 → 0.7.8

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.
Files changed (77) hide show
  1. package/dist/browser.mjs +9 -9
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dwn-api.js +3 -2
  4. package/dist/esm/dwn-api.js.map +1 -1
  5. package/dist/esm/enbox-connect-protocol.js +5 -5
  6. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  7. package/dist/esm/index.js +1 -1
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/permissions-api.js +7 -34
  10. package/dist/esm/permissions-api.js.map +1 -1
  11. package/dist/esm/sync-closure-resolver.js +229 -110
  12. package/dist/esm/sync-closure-resolver.js.map +1 -1
  13. package/dist/esm/sync-closure-types.js +24 -7
  14. package/dist/esm/sync-closure-types.js.map +1 -1
  15. package/dist/esm/sync-engine-level.js +1961 -764
  16. package/dist/esm/sync-engine-level.js.map +1 -1
  17. package/dist/esm/sync-link-id.js +4 -13
  18. package/dist/esm/sync-link-id.js.map +1 -1
  19. package/dist/esm/sync-link-reconciler.js +26 -8
  20. package/dist/esm/sync-link-reconciler.js.map +1 -1
  21. package/dist/esm/sync-messages.js +218 -154
  22. package/dist/esm/sync-messages.js.map +1 -1
  23. package/dist/esm/sync-permission-grants.js +208 -0
  24. package/dist/esm/sync-permission-grants.js.map +1 -0
  25. package/dist/esm/sync-replication-ledger.js +23 -40
  26. package/dist/esm/sync-replication-ledger.js.map +1 -1
  27. package/dist/esm/sync-scope-acceptance.js +126 -0
  28. package/dist/esm/sync-scope-acceptance.js.map +1 -0
  29. package/dist/esm/sync-topological-sort.js +57 -15
  30. package/dist/esm/sync-topological-sort.js.map +1 -1
  31. package/dist/esm/types/sync.js +130 -22
  32. package/dist/esm/types/sync.js.map +1 -1
  33. package/dist/types/dwn-api.d.ts.map +1 -1
  34. package/dist/types/index.d.ts +1 -1
  35. package/dist/types/index.d.ts.map +1 -1
  36. package/dist/types/permissions-api.d.ts +1 -2
  37. package/dist/types/permissions-api.d.ts.map +1 -1
  38. package/dist/types/sync-closure-resolver.d.ts.map +1 -1
  39. package/dist/types/sync-closure-types.d.ts +14 -3
  40. package/dist/types/sync-closure-types.d.ts.map +1 -1
  41. package/dist/types/sync-engine-level.d.ts +127 -25
  42. package/dist/types/sync-engine-level.d.ts.map +1 -1
  43. package/dist/types/sync-link-id.d.ts +3 -9
  44. package/dist/types/sync-link-id.d.ts.map +1 -1
  45. package/dist/types/sync-link-reconciler.d.ts +12 -2
  46. package/dist/types/sync-link-reconciler.d.ts.map +1 -1
  47. package/dist/types/sync-messages.d.ts +16 -13
  48. package/dist/types/sync-messages.d.ts.map +1 -1
  49. package/dist/types/sync-permission-grants.d.ts +52 -0
  50. package/dist/types/sync-permission-grants.d.ts.map +1 -0
  51. package/dist/types/sync-replication-ledger.d.ts +5 -13
  52. package/dist/types/sync-replication-ledger.d.ts.map +1 -1
  53. package/dist/types/sync-scope-acceptance.d.ts +28 -0
  54. package/dist/types/sync-scope-acceptance.d.ts.map +1 -0
  55. package/dist/types/sync-topological-sort.d.ts +2 -1
  56. package/dist/types/sync-topological-sort.d.ts.map +1 -1
  57. package/dist/types/types/permissions.d.ts +2 -0
  58. package/dist/types/types/permissions.d.ts.map +1 -1
  59. package/dist/types/types/sync.d.ts +137 -75
  60. package/dist/types/types/sync.d.ts.map +1 -1
  61. package/package.json +3 -3
  62. package/src/dwn-api.ts +3 -2
  63. package/src/enbox-connect-protocol.ts +5 -5
  64. package/src/index.ts +10 -1
  65. package/src/permissions-api.ts +11 -42
  66. package/src/sync-closure-resolver.ts +306 -126
  67. package/src/sync-closure-types.ts +38 -9
  68. package/src/sync-engine-level.ts +2560 -797
  69. package/src/sync-link-id.ts +9 -14
  70. package/src/sync-link-reconciler.ts +43 -10
  71. package/src/sync-messages.ts +263 -159
  72. package/src/sync-permission-grants.ts +297 -0
  73. package/src/sync-replication-ledger.ts +55 -50
  74. package/src/sync-scope-acceptance.ts +186 -0
  75. package/src/sync-topological-sort.ts +89 -21
  76. package/src/types/permissions.ts +2 -0
  77. package/src/types/sync.ts +235 -62
@@ -4,12 +4,14 @@ import type {
4
4
  ClosureEvaluationContext,
5
5
  ClosureResult,
6
6
  } from './sync-closure-types.js';
7
- import type { GenericMessage, MessageStore } from '@enbox/dwn-sdk-js';
8
-
9
- import { Message, PermissionsProtocol } from '@enbox/dwn-sdk-js';
7
+ import type { GenericMessage, MessageStore, ProtocolDefinition, ProtocolRuleSet } from '@enbox/dwn-sdk-js';
10
8
 
9
+ import { classifySyncMessageScope } from './sync-scope-acceptance.js';
10
+ import { getInvokedPermissionGrantIds } from './sync-permission-grants.js';
11
11
  import { isMultiPartyContext } from './protocol-utils.js';
12
+ import { protocolsForSyncScope } from './types/sync.js';
12
13
  import { ClosureFailureCode, createClosureContext } from './sync-closure-types.js';
14
+ import { Message, PermissionScopeMatcher, PermissionsProtocol } from '@enbox/dwn-sdk-js';
13
15
 
14
16
  // ---------------------------------------------------------------------------
15
17
  // Dependency extraction helpers (one per dependency class)
@@ -44,111 +46,173 @@ function extractProtocolDeps(message: GenericMessage): ClosureDependencyEdge[] {
44
46
  * - initialWrite (for non-initial writes)
45
47
  * - parentId chain
46
48
  */
47
- function extractAncestryDeps(message: GenericMessage): ClosureDependencyEdge[] {
49
+ function extractAncestryDeps(message: GenericMessage, protocolDef?: ProtocolDefinition): ClosureDependencyEdge[] {
48
50
  const desc = message.descriptor as Record<string, unknown>;
49
- const edges: ClosureDependencyEdge[] = [];
50
51
 
51
52
  // Only Records interface messages have ancestry dependencies.
52
53
  if (desc.interface !== 'Records') { return []; }
53
54
 
54
- const recordId = (message as any).recordId as string | undefined;
55
+ const context = createAncestryContext(message, desc, protocolDef);
56
+ return [
57
+ createParentRecordDependency(context),
58
+ createContextRootDependency(context),
59
+ createInitialWriteDependency(context),
60
+ ].filter((edge): edge is ClosureDependencyEdge => edge !== undefined);
61
+ }
55
62
 
56
- // parentId dependency the parent record must be present.
57
- const parentId = desc.parentId as string | undefined;
58
- if (parentId) {
59
- edges.push({
60
- dependencyClass : 2,
61
- label : 'parentRecord',
62
- identifier : parentId,
63
- identifierType : 'recordId',
64
- });
63
+ function expectedProtocol(protocol: string | undefined): Partial<Pick<ClosureDependencyEdge, 'expectedProtocol'>> {
64
+ return protocol === undefined ? {} : { expectedProtocol: protocol };
65
+ }
66
+
67
+ type AncestryContext = {
68
+ descriptor: Record<string, unknown>;
69
+ recordId?: string;
70
+ protocol?: string;
71
+ parentId?: string;
72
+ contextId?: string;
73
+ rootRefProtocol?: string;
74
+ parentResolvedByCrossProtocolRef: boolean;
75
+ };
76
+
77
+ function createAncestryContext(
78
+ message: GenericMessage,
79
+ descriptor: Record<string, unknown>,
80
+ protocolDef: ProtocolDefinition | undefined,
81
+ ): AncestryContext {
82
+ const rootRefProtocol = getRootRefProtocol(descriptor, protocolDef);
83
+
84
+ return {
85
+ descriptor,
86
+ recordId : (message as any).recordId as string | undefined,
87
+ protocol : descriptor.protocol as string | undefined,
88
+ parentId : descriptor.parentId as string | undefined,
89
+ contextId : (message as any).contextId as string | undefined,
90
+ rootRefProtocol,
91
+ parentResolvedByCrossProtocolRef : rootRefProtocol !== undefined && isDirectChildOfRootRef(descriptor),
92
+ };
93
+ }
94
+
95
+ function createParentRecordDependency(context: AncestryContext): ClosureDependencyEdge | undefined {
96
+ if (!context.parentId || context.parentResolvedByCrossProtocolRef) { return undefined; }
97
+
98
+ return {
99
+ dependencyClass : 2,
100
+ label : 'parentRecord',
101
+ identifier : context.parentId,
102
+ identifierType : 'recordId',
103
+ ...expectedProtocol(context.protocol),
104
+ };
105
+ }
106
+
107
+ function createContextRootDependency(context: AncestryContext): ClosureDependencyEdge | undefined {
108
+ const contextRootId = getContextRootId(context);
109
+ if (!contextRootId || contextRootIsCrossProtocolParent(context, contextRootId)) {
110
+ return undefined;
65
111
  }
66
112
 
67
- // contextId dependency — if the record has a contextId that differs from
68
- // its own recordId, the context root record must be present. The contextId
69
- // is a hierarchical path of recordIds (e.g., "rootId/childId/grandchildId").
70
- // The context root is the first segment.
71
- const contextId = (message as any).contextId as string | undefined;
72
- if (contextId && recordId && contextId !== recordId) {
73
- const contextRootId = contextId.split('/')[0];
74
- edges.push({
75
- dependencyClass : 2,
76
- label : 'contextRoot',
77
- identifier : contextRootId,
78
- identifierType : 'recordId',
79
- });
113
+ return {
114
+ dependencyClass : 2,
115
+ label : 'contextRoot',
116
+ identifier : contextRootId,
117
+ identifierType : 'recordId',
118
+ ...expectedProtocol(context.rootRefProtocol ?? context.protocol),
119
+ };
120
+ }
121
+
122
+ function getContextRootId(context: AncestryContext): string | undefined {
123
+ if (!context.contextId || !context.recordId || context.contextId === context.recordId) {
124
+ return undefined;
80
125
  }
81
126
 
82
- // initialWrite dependency — non-initial writes need their initialWrite.
83
- // An initial write has entryId === recordId, but we can't compute entryId here
84
- // without the full CID computation. Instead, check dateCreated vs messageTimestamp
85
- // as a heuristic, then the resolver will verify via the message store.
86
- if (recordId && desc.method === 'Write') {
87
- const dateCreated = desc.dateCreated as string | undefined;
88
- const messageTimestamp = desc.messageTimestamp as string | undefined;
89
- if (dateCreated && messageTimestamp && dateCreated !== messageTimestamp) {
90
- // Non-initial write needs the initialWrite.
91
- edges.push({
92
- dependencyClass : 2,
93
- label : 'initialWrite',
94
- identifier : recordId,
95
- identifierType : 'recordId',
96
- });
97
- }
127
+ return context.contextId.split('/')[0];
128
+ }
129
+
130
+ function contextRootIsCrossProtocolParent(context: AncestryContext, contextRootId: string): boolean {
131
+ return context.parentResolvedByCrossProtocolRef && contextRootId === context.parentId;
132
+ }
133
+
134
+ function createInitialWriteDependency(context: AncestryContext): ClosureDependencyEdge | undefined {
135
+ if (!context.recordId) { return undefined; }
136
+
137
+ if (isNonInitialWrite(context.descriptor)) {
138
+ return {
139
+ dependencyClass : 2,
140
+ label : 'initialWrite',
141
+ identifier : context.recordId,
142
+ identifierType : 'recordId',
143
+ ...expectedProtocol(context.protocol),
144
+ };
98
145
  }
99
146
 
100
- // RecordsDelete also needs the initialWrite for authorization and index construction.
101
- if (recordId && desc.method === 'Delete') {
102
- edges.push({
147
+ if (context.descriptor.method === 'Delete') {
148
+ return {
103
149
  dependencyClass : 2,
104
150
  label : 'initialWrite',
105
- identifier : recordId,
151
+ identifier : context.recordId,
106
152
  identifierType : 'recordId',
107
- });
153
+ };
108
154
  }
109
155
 
110
- return edges;
156
+ return undefined;
157
+ }
158
+
159
+ function isNonInitialWrite(descriptor: Record<string, unknown>): boolean {
160
+ if (descriptor.method !== 'Write') { return false; }
161
+
162
+ const dateCreated = descriptor.dateCreated as string | undefined;
163
+ const messageTimestamp = descriptor.messageTimestamp as string | undefined;
164
+ return dateCreated !== undefined &&
165
+ messageTimestamp !== undefined &&
166
+ dateCreated !== messageTimestamp;
167
+ }
168
+
169
+ function isDirectChildOfRootRef(descriptor: Record<string, unknown>): boolean {
170
+ const protocolPath = descriptor.protocolPath as string | undefined;
171
+ return protocolPath?.split('/').length === 2;
172
+ }
173
+
174
+ function getRootRefProtocol(
175
+ descriptor: Record<string, unknown>,
176
+ protocolDef: ProtocolDefinition | undefined,
177
+ ): string | undefined {
178
+ const protocolPath = descriptor.protocolPath as string | undefined;
179
+ if (!protocolPath) { return undefined; }
180
+
181
+ const pathSegments = protocolPath.split('/');
182
+ const rootRuleSet = protocolDef?.structure?.[pathSegments[0]];
183
+ if (typeof rootRuleSet?.$ref !== 'string') { return undefined; }
184
+
185
+ const colonIdx = rootRuleSet.$ref.indexOf(':');
186
+ if (colonIdx <= 0) { return undefined; }
187
+
188
+ const alias = rootRuleSet.$ref.substring(0, colonIdx);
189
+ const referencedProtocol = protocolDef?.uses?.[alias];
190
+ return typeof referencedProtocol === 'string' ? referencedProtocol : undefined;
111
191
  }
112
192
 
113
193
  /**
114
194
  * Class 3: Authorization closure.
115
- * If the message uses a permissionGrantId, the grant record must be present.
195
+ * If the message invokes permission grant IDs, each grant record must be present.
116
196
  */
117
197
  function extractAuthorizationDeps(message: GenericMessage): ClosureDependencyEdge[] {
118
198
  const edges: ClosureDependencyEdge[] = [];
119
- const auth = (message as any).authorization;
120
- if (!auth) { return []; }
121
-
122
- // The signature payload is at authorization.signature.payload (GeneralJws).
123
- // This is the base64url-encoded JSON containing permissionGrantId, protocolRole, etc.
124
- const payload = auth.signature?.payload;
125
- if (payload) {
126
- try {
127
- // Payload is base64url-encoded JSON.
128
- const decoded = JSON.parse(
129
- Buffer.from(payload, 'base64url').toString('utf-8')
130
- );
131
- if (decoded.permissionGrantId) {
132
- edges.push({
133
- dependencyClass : 3,
134
- label : 'permissionGrant',
135
- identifier : decoded.permissionGrantId,
136
- identifierType : 'grantId',
137
- });
138
- // Also require the grant's revocation state to be resolvable.
139
- // The revocation is a child record at protocolPath 'grant/revocation'
140
- // with parentId === grantId. We add it as a separate edge so the
141
- // resolver can check for its presence (or confirmed absence).
142
- edges.push({
143
- dependencyClass : 3,
144
- label : 'grantRevocation',
145
- identifier : decoded.permissionGrantId,
146
- identifierType : 'grantId',
147
- });
148
- }
149
- } catch {
150
- // If we can't decode, skip — authorization will fail at apply time.
151
- }
199
+ for (const permissionGrantId of getInvokedPermissionGrantIds(message)) {
200
+ edges.push({
201
+ dependencyClass : 3,
202
+ label : 'permissionGrant',
203
+ identifier : permissionGrantId,
204
+ identifierType : 'grantId',
205
+ });
206
+ // Also require the grant's revocation state to be resolvable.
207
+ // The revocation is a child record at protocolPath 'grant/revocation'
208
+ // with parentId === grantId. We add it as a separate edge so the
209
+ // resolver can check for its presence (or confirmed absence).
210
+ edges.push({
211
+ dependencyClass : 3,
212
+ label : 'grantRevocation',
213
+ identifier : permissionGrantId,
214
+ identifierType : 'grantId',
215
+ });
152
216
  }
153
217
 
154
218
  return edges;
@@ -170,24 +234,30 @@ function extractAuthorizationDeps(message: GenericMessage): ClosureDependencyEdg
170
234
  * (not in public API, reimplemented here to avoid internal path coupling).
171
235
  */
172
236
  function getRuleSetAtProtocolPath(
173
- structure: Record<string, any> | undefined,
237
+ structure: ProtocolDefinition['structure'] | undefined,
174
238
  protocolPath: string,
175
- ): any | undefined {
239
+ ): ProtocolRuleSet | undefined {
176
240
  if (!structure || !protocolPath) { return undefined; }
177
241
 
178
242
  const segments = protocolPath.split('/');
179
- let currentLevel: Record<string, any> = structure;
180
- let current: any;
243
+ let currentLevel: Record<string, unknown> = structure;
244
+ let current: ProtocolRuleSet | undefined;
181
245
 
182
246
  for (const segment of segments) {
183
247
  if (!Object.hasOwn(currentLevel, segment)) { return undefined; }
184
- current = currentLevel[segment];
248
+ const next = currentLevel[segment];
249
+ if (!isProtocolRuleSet(next)) { return undefined; }
250
+ current = next;
185
251
  currentLevel = current;
186
252
  }
187
253
 
188
254
  return current;
189
255
  }
190
256
 
257
+ function isProtocolRuleSet(value: unknown): value is ProtocolRuleSet {
258
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
259
+ }
260
+
191
261
  /**
192
262
  * Extract protocol-definition-aware dependencies for classes 4, 5, and 6.
193
263
  * Called during BFS traversal after the ProtocolDefinition has been fetched
@@ -199,7 +269,7 @@ function getRuleSetAtProtocolPath(
199
269
  */
200
270
  function extractProtocolAwareDeps(
201
271
  message: GenericMessage,
202
- protocolDef: any,
272
+ protocolDef: ProtocolDefinition,
203
273
  isDelegateSession?: boolean,
204
274
  ): ClosureDependencyEdge[] {
205
275
  const desc = message.descriptor as Record<string, unknown>;
@@ -342,10 +412,11 @@ function extractProtocolAwareDeps(
342
412
  const parentId = desc.parentId as string | undefined;
343
413
  if (pathSegments.length === 2 && parentId) {
344
414
  edges.push({
345
- dependencyClass : 6,
346
- label : 'crossProtocolParent',
347
- identifier : `${referencedProtocol}|${parentId}`,
348
- identifierType : 'recordId',
415
+ dependencyClass : 6,
416
+ label : 'crossProtocolParent',
417
+ identifier : `${referencedProtocol}|${parentId}`,
418
+ identifierType : 'recordId',
419
+ expectedProtocol : referencedProtocol,
349
420
  });
350
421
  }
351
422
  }
@@ -421,11 +492,12 @@ function extractProtocolAwareDeps(
421
492
  const contextPrefix = roleFilter.contextId
422
493
  ? JSON.stringify(roleFilter.contextId) : 'no-context';
423
494
  edges.push({
424
- dependencyClass : 6,
425
- label : 'crossProtocolRoleRecord',
426
- identifier : `${roleProtocol}|${roleProtocolPath}|${messageAuthor}|${contextPrefix}`,
427
- identifierType : 'filter',
428
- filter : roleFilter,
495
+ dependencyClass : 6,
496
+ label : 'crossProtocolRoleRecord',
497
+ identifier : `${roleProtocol}|${roleProtocolPath}|${messageAuthor}|${contextPrefix}`,
498
+ identifierType : 'filter',
499
+ expectedProtocol : roleProtocol,
500
+ filter : roleFilter,
429
501
  });
430
502
  }
431
503
  }
@@ -498,38 +570,41 @@ export async function evaluateClosure(
498
570
  for (let i = 0; i < batchSize; i++) {
499
571
  const current = queue.shift()!;
500
572
 
501
- // Phase 1: Extract and resolve static dependency edges (classes 1-3).
502
- // This populates the protocolCache when class 1 (ProtocolsConfigure)
503
- // is resolved, which is needed by classes 4-6.
504
- const staticEdges = [
505
- ...extractProtocolDeps(current),
506
- ...extractAncestryDeps(current),
507
- ...extractAuthorizationDeps(current),
508
- ];
509
-
510
- const resolveResult = await resolveEdges(
511
- staticEdges, allEdges, messageStore, context, visited, queue, rootCid, currentDepth
573
+ // Resolve protocol metadata first. This populates the protocol
574
+ // cache when a ProtocolsConfigure dependency is resolved, which is
575
+ // needed to distinguish normal parent records from $ref parents.
576
+ const protocolResult = await resolveEdges(
577
+ extractProtocolDeps(current), allEdges, messageStore, scope, context, visited, queue, rootCid, currentDepth
512
578
  );
513
- if (resolveResult) { return resolveResult; } // Early failure.
579
+ if (protocolResult) { return protocolResult; } // Early failure.
514
580
 
515
- // Phase 2: Extract protocol-definition-aware edges (classes 4-6).
516
- // Runs AFTER static resolution so the ProtocolDefinition is in the cache.
517
581
  const currentDesc = current.descriptor as Record<string, unknown>;
518
582
  const currentProtocol = currentDesc.protocol as string | undefined;
519
- if (currentProtocol) {
520
- const cachedProtocolMsg = context.protocolCache.get(currentProtocol);
521
- const protocolDef = cachedProtocolMsg?.descriptor?.definition;
522
- if (protocolDef) {
523
- const protoAwareEdges = extractProtocolAwareDeps(current, protocolDef, context.isDelegateSession);
524
- const protoResult = await resolveEdges(
525
- protoAwareEdges, allEdges, messageStore, context, visited, queue, rootCid, currentDepth
526
- );
527
- if (protoResult) { return protoResult; }
528
- }
583
+ const cachedProtocolMsg = currentProtocol ? context.protocolCache.get(currentProtocol) : undefined;
584
+ const protocolDef = cachedProtocolMsg?.descriptor?.definition as ProtocolDefinition | undefined;
585
+
586
+ const structuralEdges = [
587
+ ...extractAncestryDeps(current, protocolDef),
588
+ ...extractAuthorizationDeps(current),
589
+ ];
590
+
591
+ const structuralResult = await resolveEdges(
592
+ structuralEdges, allEdges, messageStore, scope, context, visited, queue, rootCid, currentDepth
593
+ );
594
+ if (structuralResult) { return structuralResult; } // Early failure.
595
+
596
+ // Resolve protocol-definition-aware edges after static dependencies so
597
+ // the ProtocolDefinition is in the cache.
598
+ if (protocolDef) {
599
+ const protoAwareEdges = extractProtocolAwareDeps(current, protocolDef, context.isDelegateSession);
600
+ const protoResult = await resolveEdges(
601
+ protoAwareEdges, allEdges, messageStore, scope, context, visited, queue, rootCid, currentDepth
602
+ );
603
+ if (protoResult) { return protoResult; }
529
604
  }
530
605
 
531
- // Phase 3: Validate grant temporal ordering (causal grant check).
532
- // After all edges are resolved, if this message uses a grant, verify
606
+ // Validate grant temporal ordering after all dependency edges resolve.
607
+ // If this message uses a grant, verify
533
608
  // that the grant is temporally valid at the message's commit point:
534
609
  // - grant.dateGranted <= message.messageTimestamp < grant.dateExpires
535
610
  // - no revocation exists with revocation.messageTimestamp <= message.messageTimestamp
@@ -684,6 +759,7 @@ async function resolveEdges(
684
759
  edges: ClosureDependencyEdge[],
685
760
  allEdges: ClosureDependencyEdge[],
686
761
  messageStore: MessageStore,
762
+ scope: SyncScope,
687
763
  context: ClosureEvaluationContext,
688
764
  visited: Set<string>,
689
765
  queue: GenericMessage[],
@@ -696,7 +772,7 @@ async function resolveEdges(
696
772
  // Include label in dep key to distinguish edges with the same identifier
697
773
  // but different semantics (e.g., permissionGrant vs grantRevocation both
698
774
  // use identifierType:'grantId' with the same grantId).
699
- const depKey = `${edge.label}:${edge.identifierType}:${edge.identifier}`;
775
+ const depKey = dependencyCacheKey(edge, scope);
700
776
  if (context.satisfiedDeps.has(depKey)) { continue; }
701
777
  if (context.missingDeps.has(depKey)) {
702
778
  return {
@@ -728,6 +804,20 @@ async function resolveEdges(
728
804
  };
729
805
  }
730
806
 
807
+ if (!isResolvedDependencyAllowed(edge, resolved, scope)) {
808
+ return {
809
+ complete : false,
810
+ rootMessageCid : rootCid,
811
+ edges : allEdges,
812
+ failure : {
813
+ code : ClosureFailureCode.DependencyForbidden,
814
+ edge,
815
+ detail : `dependency '${edge.label}' (${edge.identifier}) is outside the current sync scope`,
816
+ },
817
+ depth: currentDepth,
818
+ };
819
+ }
820
+
731
821
  context.satisfiedDeps.add(depKey);
732
822
 
733
823
  // Add resolved record to the BFS queue for transitive dependency evaluation,
@@ -750,6 +840,96 @@ async function resolveEdges(
750
840
  return null; // All edges resolved successfully.
751
841
  }
752
842
 
843
+ function dependencyCacheKey(edge: ClosureDependencyEdge, scope: SyncScope): string {
844
+ const expectedProtocolSegment = edge.expectedProtocol === undefined
845
+ ? ''
846
+ : `expectedProtocol:${edge.expectedProtocol}:`;
847
+ return `${scopeCacheKey(scope)}:${expectedProtocolSegment}${edge.label}:${edge.identifierType}:${edge.identifier}`;
848
+ }
849
+
850
+ function scopeCacheKey(scope: SyncScope): string {
851
+ if (scope.kind === 'full') {
852
+ return 'full';
853
+ }
854
+
855
+ if (scope.kind === 'protocolSet') {
856
+ return `protocolSet:${scope.protocols.join('\u001f')}`;
857
+ }
858
+
859
+ return `recordsProjection:${scope.scopes.map(scopeEntry => JSON.stringify(scopeEntry)).join('\u001f')}`;
860
+ }
861
+
862
+ function isResolvedDependencyAllowed(
863
+ edge: ClosureDependencyEdge,
864
+ resolved: GenericMessage,
865
+ scope: SyncScope,
866
+ ): boolean {
867
+ if (isSyntheticDependency(resolved)) {
868
+ return true;
869
+ }
870
+
871
+ // Protocol metadata is re-derived from an accepted primary and is safe to
872
+ // use as closure metadata even when it is not itself a primary in the scope.
873
+ if (edge.identifierType === 'protocol') {
874
+ return true;
875
+ }
876
+
877
+ if (edge.expectedProtocol !== undefined) {
878
+ return getMessageProtocol(resolved) === edge.expectedProtocol;
879
+ }
880
+
881
+ if (edge.label === 'contextKeyRecord') {
882
+ return isContextKeyDependencyAllowed(edge, resolved, scope);
883
+ }
884
+
885
+ return classifySyncMessageScope({ message: resolved, scope }) === 'in-scope';
886
+ }
887
+
888
+ function isSyntheticDependency(message: GenericMessage): boolean {
889
+ return (message.descriptor as Record<string, unknown>).interface === 'Synthetic';
890
+ }
891
+
892
+ function getMessageProtocol(message: GenericMessage): string | undefined {
893
+ const descriptor = message.descriptor as Record<string, unknown>;
894
+ return typeof descriptor.protocol === 'string' ? descriptor.protocol : undefined;
895
+ }
896
+
897
+ function isContextKeyDependencyAllowed(
898
+ edge: ClosureDependencyEdge,
899
+ message: GenericMessage,
900
+ scope: SyncScope,
901
+ ): boolean {
902
+ if (scope.kind === 'full') { return true; }
903
+
904
+ const separatorIdx = edge.identifier.indexOf('|');
905
+ if (separatorIdx <= 0) { return false; }
906
+
907
+ const sourceProtocol = edge.identifier.substring(0, separatorIdx);
908
+ const rootContextId = edge.identifier.substring(separatorIdx + 1);
909
+ const descriptor = message.descriptor as Record<string, unknown>;
910
+ const tags = descriptor.tags as Record<string, unknown> | undefined;
911
+
912
+ const isContextKeyRecord = descriptor.interface === 'Records' &&
913
+ descriptor.method === 'Write' &&
914
+ descriptor.protocol === 'https://identity.foundation/protocols/key-delivery' &&
915
+ descriptor.protocolPath === 'contextKey' &&
916
+ tags?.protocol === sourceProtocol &&
917
+ tags?.contextId === rootContextId;
918
+ if (!isContextKeyRecord) {
919
+ return false;
920
+ }
921
+
922
+ if (scope.kind === 'recordsProjection') {
923
+ return scope.scopes.some(scopeEntry => PermissionScopeMatcher.matches(scopeEntry, {
924
+ protocol : sourceProtocol,
925
+ contextId : rootContextId,
926
+ }));
927
+ }
928
+
929
+ const coveredProtocols = protocolsForSyncScope(scope);
930
+ return coveredProtocols?.includes(sourceProtocol) === true;
931
+ }
932
+
753
933
  /**
754
934
  * Resolve a dependency edge by querying the local MessageStore.
755
935
  * Returns the resolved message or null if not found.
@@ -17,7 +17,7 @@ export enum ClosureFailureCode {
17
17
  ParentChainMissing = 'ClosureParentChainMissing',
18
18
  /** Class 2: A context ancestor record is missing. */
19
19
  ContextChainMissing = 'ClosureContextChainMissing',
20
- /** Class 3: A permission grant referenced by permissionGrantId is missing. */
20
+ /** Class 3: A permission grant referenced by a grant invocation is missing. */
21
21
  GrantMissing = 'ClosureGrantMissing',
22
22
  /** Class 3: The grant exists but is not yet active at the message's timestamp. */
23
23
  GrantNotYetActive = 'ClosureGrantNotYetActive',
@@ -37,6 +37,18 @@ export enum ClosureFailureCode {
37
37
  DepthExceeded = 'ClosureDepthExceeded',
38
38
  }
39
39
 
40
+ const TERMINAL_CLOSURE_FAILURE_CODES = new Set<string>([
41
+ ClosureFailureCode.DependencyForbidden,
42
+ ClosureFailureCode.GrantExpired,
43
+ ClosureFailureCode.GrantNotYetActive,
44
+ ClosureFailureCode.GrantRevocationMissing,
45
+ ]);
46
+
47
+ /** Returns true when repair cannot make the closure failure complete. */
48
+ export function isTerminalClosureFailureCode(failureCode: string): boolean {
49
+ return TERMINAL_CLOSURE_FAILURE_CODES.has(failureCode);
50
+ }
51
+
40
52
  // ---------------------------------------------------------------------------
41
53
  // Closure dependency edge
42
54
  // ---------------------------------------------------------------------------
@@ -57,6 +69,13 @@ export type ClosureDependencyEdge = {
57
69
  identifier: string;
58
70
  /** The type of identifier — determines the fetch strategy. */
59
71
  identifierType: 'messageCid' | 'recordId' | 'protocol' | 'grantId' | 'filter';
72
+ /**
73
+ * Protocol the resolved dependency must belong to when the dependency is
74
+ * expected to resolve outside the current primary sync scope. This is used
75
+ * for protocol-derived support records such as cross-protocol `$ref` parents
76
+ * and role records.
77
+ */
78
+ expectedProtocol?: string;
60
79
  /**
61
80
  * When `identifierType` is `'filter'`, this carries the full query filter
62
81
  * as a structured object. Used for dependencies that require multi-field
@@ -111,8 +130,10 @@ export type ClosureEvaluationContext = {
111
130
  grantCache: Map<string, GenericMessage | null>;
112
131
  /**
113
132
  * Set of dependency identifiers already known to be locally present.
114
- * Keyed by `${identifierType}:${identifier}` to prevent cross-namespace
115
- * collisions (e.g., a recordId and a grantId with the same string value).
133
+ * Keys include the sync scope, expected dependency protocol when one is
134
+ * known, and `${label}:${identifierType}:${identifier}` so dependencies
135
+ * proven under one scoped link cannot satisfy another link's closure check
136
+ * without being revalidated for that scope and dependency policy.
116
137
  */
117
138
  satisfiedDeps: Set<string>;
118
139
  /**
@@ -196,8 +217,8 @@ export function invalidateClosureCache(
196
217
  if (protocolPath === 'grant' && recordId) {
197
218
  // Grant write → invalidate the grant cache and dep keys.
198
219
  context.grantCache.delete(recordId);
199
- context.satisfiedDeps.delete(`permissionGrant:grantId:${recordId}`);
200
- context.missingDeps.delete(`permissionGrant:grantId:${recordId}`);
220
+ deleteDependencyCacheEntries(context.satisfiedDeps, `permissionGrant:grantId:${recordId}`);
221
+ deleteDependencyCacheEntries(context.missingDeps, `permissionGrant:grantId:${recordId}`);
201
222
  }
202
223
 
203
224
  if (protocolPath === 'grant/revocation') {
@@ -205,11 +226,11 @@ export function invalidateClosureCache(
205
226
  const parentId = desc.parentId as string | undefined;
206
227
  if (parentId) {
207
228
  context.grantCache.delete(`revocation:${parentId}`);
208
- context.satisfiedDeps.delete(`grantRevocation:grantId:${parentId}`);
209
- context.missingDeps.delete(`grantRevocation:grantId:${parentId}`);
229
+ deleteDependencyCacheEntries(context.satisfiedDeps, `grantRevocation:grantId:${parentId}`);
230
+ deleteDependencyCacheEntries(context.missingDeps, `grantRevocation:grantId:${parentId}`);
210
231
  // Also invalidate the grant itself since its revocation state changed.
211
- context.satisfiedDeps.delete(`permissionGrant:grantId:${parentId}`);
212
- context.missingDeps.delete(`permissionGrant:grantId:${parentId}`);
232
+ deleteDependencyCacheEntries(context.satisfiedDeps, `permissionGrant:grantId:${parentId}`);
233
+ deleteDependencyCacheEntries(context.missingDeps, `permissionGrant:grantId:${parentId}`);
213
234
  }
214
235
  }
215
236
  }
@@ -291,3 +312,11 @@ export function invalidateClosureCache(
291
312
  }
292
313
  }
293
314
  }
315
+
316
+ function deleteDependencyCacheEntries(cache: Set<string>, dependencyKeySuffix: string): void {
317
+ for (const key of cache) {
318
+ if (key.endsWith(dependencyKeySuffix)) {
319
+ cache.delete(key);
320
+ }
321
+ }
322
+ }