@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
@@ -1,14 +1,71 @@
1
- import type { GenericMessage } from '@enbox/dwn-sdk-js';
1
+ import type { GenericMessage, ProtocolDefinition } from '@enbox/dwn-sdk-js';
2
2
 
3
+ import { getInvokedPermissionGrantIds } from './sync-permission-grants.js';
3
4
  import { DwnInterfaceName, DwnMethodName, PermissionsProtocol } from '@enbox/dwn-sdk-js';
4
5
 
6
+ type DescriptorWithRecordId = GenericMessage['descriptor'] & {
7
+ recordId?: string;
8
+ };
9
+
10
+ type RecordsDescriptor = GenericMessage['descriptor'] & {
11
+ dateCreated?: string;
12
+ parentId?: string;
13
+ protocol?: string;
14
+ protocolPath?: string;
15
+ };
16
+
17
+ type ProtocolsConfigureDescriptor = GenericMessage['descriptor'] & {
18
+ definition?: Partial<ProtocolDefinition>;
19
+ };
20
+
21
+ function isRecordsDescriptor(descriptor: GenericMessage['descriptor']): descriptor is RecordsDescriptor {
22
+ return descriptor.interface === DwnInterfaceName.Records;
23
+ }
24
+
25
+ function isRecordsWriteDescriptor(descriptor: GenericMessage['descriptor']): descriptor is RecordsDescriptor {
26
+ return descriptor.interface === DwnInterfaceName.Records && descriptor.method === DwnMethodName.Write;
27
+ }
28
+
29
+ function isRecordsDeleteDescriptor(descriptor: GenericMessage['descriptor']): descriptor is DescriptorWithRecordId {
30
+ return descriptor.interface === DwnInterfaceName.Records && descriptor.method === DwnMethodName.Delete;
31
+ }
32
+
33
+ function isProtocolsConfigureDescriptor(
34
+ descriptor: GenericMessage['descriptor']
35
+ ): descriptor is ProtocolsConfigureDescriptor {
36
+ return descriptor.interface === DwnInterfaceName.Protocols && descriptor.method === DwnMethodName.Configure;
37
+ }
38
+
39
+ function getRecordId(message: GenericMessage): string | undefined {
40
+ return (message as GenericMessage & { recordId?: string }).recordId;
41
+ }
42
+
43
+ function getProtocolDefinition(message: GenericMessage): Partial<ProtocolDefinition> | undefined {
44
+ const { descriptor } = message;
45
+ return isProtocolsConfigureDescriptor(descriptor) ? descriptor.definition : undefined;
46
+ }
47
+
48
+ function getConfiguredProtocol(message: GenericMessage): string | undefined {
49
+ const protocol = getProtocolDefinition(message)?.protocol;
50
+ return typeof protocol === 'string' ? protocol : undefined;
51
+ }
52
+
53
+ function getComposedProtocolDependencies(message: GenericMessage): string[] {
54
+ const uses = getProtocolDefinition(message)?.uses;
55
+ if (!uses) {
56
+ return [];
57
+ }
58
+
59
+ return Object.values(uses).filter((protocol): protocol is string => typeof protocol === 'string');
60
+ }
61
+
5
62
  /**
6
63
  * Checks whether a message is an initial RecordsWrite (not an update).
7
64
  * An initial write has dateCreated === messageTimestamp (first write for this recordId).
8
65
  */
9
66
  function isInitialWrite(message: GenericMessage): boolean {
10
- const desc = message.descriptor as any;
11
- if (desc.interface !== DwnInterfaceName.Records || desc.method !== DwnMethodName.Write) {
67
+ const desc = message.descriptor;
68
+ if (!isRecordsWriteDescriptor(desc)) {
12
69
  return false;
13
70
  }
14
71
  // A RecordsWrite is initial if dateCreated === messageTimestamp (first write for this recordId).
@@ -21,9 +78,10 @@ function isInitialWrite(message: GenericMessage): boolean {
21
78
  *
22
79
  * Dependencies:
23
80
  * - ProtocolsConfigure must come before any RecordsWrite using that protocol
81
+ * - Composed ProtocolsConfigure must come after ProtocolsConfigure messages for protocols in `uses`
24
82
  * - Parent record must come before child record (via parentId)
25
83
  * - Initial write must come before update writes (same recordId, not initial)
26
- * - Permission grant must come before records using that permissionGrantId
84
+ * - Permission grants must come before messages that invoke them
27
85
  */
28
86
  export function topologicalSort<T extends { message: GenericMessage }>(
29
87
  messages: T[]
@@ -44,14 +102,14 @@ export function topologicalSort<T extends { message: GenericMessage }>(
44
102
  const desc = entry.message.descriptor;
45
103
 
46
104
  if (desc.interface === DwnInterfaceName.Protocols && desc.method === DwnMethodName.Configure) {
47
- const protocolUrl = (desc as any).definition?.protocol;
105
+ const protocolUrl = getConfiguredProtocol(entry.message);
48
106
  if (protocolUrl) {
49
107
  protocolConfigureIndex.set(protocolUrl, i);
50
108
  }
51
109
  }
52
110
 
53
- if (desc.interface === DwnInterfaceName.Records && desc.method === DwnMethodName.Write) {
54
- const recordId = (entry.message as any).recordId;
111
+ if (isRecordsWriteDescriptor(desc)) {
112
+ const recordId = getRecordId(entry.message);
55
113
  const initial = isInitialWrite(entry.message);
56
114
  if (initial && recordId) {
57
115
  initialWriteIndex.set(recordId, i);
@@ -59,8 +117,8 @@ export function topologicalSort<T extends { message: GenericMessage }>(
59
117
 
60
118
  // Index permission grants by recordId so dependents can reference them.
61
119
  if (
62
- (desc as any).protocol === PermissionsProtocol.uri &&
63
- (desc as any).protocolPath === PermissionsProtocol.grantPath &&
120
+ desc.protocol === PermissionsProtocol.uri &&
121
+ desc.protocolPath === PermissionsProtocol.grantPath &&
64
122
  recordId
65
123
  ) {
66
124
  grantIndex.set(recordId, i);
@@ -89,42 +147,52 @@ export function topologicalSort<T extends { message: GenericMessage }>(
89
147
  for (let i = 0; i < messages.length; i++) {
90
148
  const desc = messages[i].message.descriptor;
91
149
 
150
+ // Composition dependency: a composed protocol depends on its referenced protocol definitions.
151
+ if (isProtocolsConfigureDescriptor(desc)) {
152
+ for (const protocol of getComposedProtocolDependencies(messages[i].message)) {
153
+ if (protocolConfigureIndex.has(protocol)) {
154
+ addEdge(protocolConfigureIndex.get(protocol)!, i);
155
+ }
156
+ }
157
+ }
158
+
92
159
  // Protocol dependency: RecordsWrite depends on ProtocolsConfigure for its protocol.
93
- if (desc.interface === DwnInterfaceName.Records) {
94
- const protocol = (desc as any).protocol;
160
+ if (isRecordsDescriptor(desc)) {
161
+ const protocol = desc.protocol;
95
162
  if (protocol && protocolConfigureIndex.has(protocol)) {
96
163
  addEdge(protocolConfigureIndex.get(protocol)!, i);
97
164
  }
98
165
  }
99
166
 
100
167
  // Parent dependency: child record depends on parent record.
101
- if (desc.interface === DwnInterfaceName.Records && (desc as any).parentId) {
102
- const parentId = (desc as any).parentId;
168
+ if (isRecordsDescriptor(desc) && desc.parentId) {
169
+ const parentId = desc.parentId;
103
170
  if (initialWriteIndex.has(parentId)) {
104
171
  addEdge(initialWriteIndex.get(parentId)!, i);
105
172
  }
106
173
  }
107
174
 
108
175
  // Initial write dependency: update depends on initial write.
109
- if (desc.interface === DwnInterfaceName.Records && desc.method === DwnMethodName.Write) {
110
- const recordId = (messages[i].message as any).recordId;
176
+ if (isRecordsWriteDescriptor(desc)) {
177
+ const recordId = getRecordId(messages[i].message);
111
178
  if (recordId && !isInitialWrite(messages[i].message) && initialWriteIndex.has(recordId)) {
112
179
  addEdge(initialWriteIndex.get(recordId)!, i);
113
180
  }
114
181
  }
115
182
 
116
183
  // Delete depends on initial write.
117
- if (desc.interface === DwnInterfaceName.Records && desc.method === DwnMethodName.Delete) {
118
- const recordId = (desc as any).recordId;
184
+ if (isRecordsDeleteDescriptor(desc)) {
185
+ const recordId = desc.recordId;
119
186
  if (recordId && initialWriteIndex.has(recordId)) {
120
187
  addEdge(initialWriteIndex.get(recordId)!, i);
121
188
  }
122
189
  }
123
190
 
124
- // Permission grant dependency: message depends on the grant it references.
125
- const permissionGrantId = (desc as any).permissionGrantId;
126
- if (permissionGrantId && grantIndex.has(permissionGrantId)) {
127
- addEdge(grantIndex.get(permissionGrantId)!, i);
191
+ // Permission grant dependency: message depends on each grant it references.
192
+ for (const permissionGrantId of getInvokedPermissionGrantIds(messages[i].message)) {
193
+ if (grantIndex.has(permissionGrantId)) {
194
+ addEdge(grantIndex.get(permissionGrantId)!, i);
195
+ }
128
196
  }
129
197
  }
130
198
 
@@ -79,6 +79,8 @@ export type GetPermissionParams = {
79
79
  delegateDid: string;
80
80
  messageType: DwnInterface;
81
81
  protocol?: string;
82
+ protocolPath?: string;
83
+ contextId?: string;
82
84
  cached?: boolean;
83
85
  delegate?: boolean;
84
86
  };
package/src/types/sync.ts CHANGED
@@ -1,7 +1,16 @@
1
- import type { ProgressToken } from '@enbox/dwn-sdk-js';
1
+ import type { NormalizedRecordsProjectionScope, ProgressToken, RecordsProjectionScope } from '@enbox/dwn-sdk-js';
2
2
 
3
3
  import type { EnboxPlatformAgent } from './agent.js';
4
4
 
5
+ import { RECORDS_PROJECTION_ROOT_VERSION, RecordsProjection } from '@enbox/dwn-sdk-js';
6
+
7
+ /** Deterministic bytewise string comparator for hash inputs and canonical IDs. */
8
+ export function lexicographicalCompare(a: string, b: string): number {
9
+ if (a > b) { return 1; }
10
+ if (a < b) { return -1; }
11
+ return 0;
12
+ }
13
+
5
14
  /**
6
15
  * The SyncEngine is responsible for syncing messages between the agent and the platform.
7
16
  */
@@ -14,6 +23,10 @@ export type SyncIdentityOptions = {
14
23
  * The protocols that should be synced for this identity.
15
24
  * - `'all'` — sync all protocols (full replica).
16
25
  * - `string[]` — sync only the listed protocol URIs.
26
+ *
27
+ * Composed protocols are not expanded automatically. If a protocol definition
28
+ * declares `uses`, include every referenced protocol that the local DWN must
29
+ * install or use while applying that projection.
17
30
  */
18
31
  protocols: 'all' | [string, ...string[]];
19
32
  };
@@ -34,64 +47,204 @@ export type SyncMode = 'poll' | 'live';
34
47
  // ---------------------------------------------------------------------------
35
48
 
36
49
  /**
37
- * Describes what a replication link syncs. Currently whole-tenant only
38
- * (`kind: 'full'`). Scoped subset sync (`kind: 'protocol'` with
39
- * `protocolPathPrefixes` / `contextIdPrefixes`) is deferred to Phase 3.
50
+ * Projection-root algorithm used by StateIndex full/protocol sync scopes.
51
+ *
52
+ * Sync compares either the existing full-tenant StateIndex root or existing
53
+ * per-protocol StateIndex roots. Records-primary projection scopes use the
54
+ * DWN SDK's `RECORDS_PROJECTION_ROOT_VERSION` instead.
55
+ */
56
+ export const SYNC_PROJECTION_ROOT_VERSION = 'state-index-full-protocol-root-v1';
57
+
58
+ /**
59
+ * Authorization-epoch algorithm used by delegated Messages.Read sync links.
60
+ *
61
+ * The authorization epoch is separate from the projection ID: re-granting the
62
+ * same scope or changing delegate grants must invalidate in-flight work without
63
+ * changing the primary CID set being compared.
64
+ */
65
+ export const SYNC_AUTHORIZATION_EPOCH_VERSION = 'messages-read-grants-v1';
66
+
67
+ /** A non-empty, sorted, duplicate-free string list. */
68
+ export type NonEmptyStringArray = [string, ...string[]];
69
+
70
+ /** A non-empty, sorted, duplicate-free, subsumption-reduced Records projection scope union. */
71
+ export type NonEmptyRecordsProjectionScopes = [NormalizedRecordsProjectionScope, ...NormalizedRecordsProjectionScope[]];
72
+
73
+ /**
74
+ * Describes the primary CID set a replication link syncs.
75
+ *
76
+ * Full and protocol-set sync use the existing StateIndex roots. Records
77
+ * projections use the `records-primary-scope-root-v1` root over latest Records
78
+ * primary messages selected by protocol, exact protocolPath, or context
79
+ * subtree. For composed protocols, the scope must include the composed protocol
80
+ * and any `uses` targets required for local protocol installation and closure
81
+ * evaluation.
40
82
  */
41
83
  export type SyncScope = {
42
- /** Scope kind. Only `'full'` is implemented in Phase 1. */
84
+ /** Full-tenant projection. Valid only for owner sync or unscoped delegated grants. */
43
85
  kind: 'full';
44
86
  } | {
45
- /**
46
- * Protocol-scoped sync. Deferred to Phase 3 — included here for type
47
- * forward-compatibility only.
48
- */
49
- kind: 'protocol';
50
- protocol: string;
51
- protocolPathPrefixes?: string[];
52
- contextIdPrefixes?: string[];
87
+ /** Protocol-set projection over one or more protocol roots. */
88
+ kind: 'protocolSet';
89
+ protocols: NonEmptyStringArray;
90
+ } | {
91
+ /** Records-primary projected root over protocol/path/context scope entries. */
92
+ kind: 'recordsProjection';
93
+ scopes: NonEmptyRecordsProjectionScopes;
53
94
  };
54
95
 
55
96
  /**
56
- * Computes a deterministic, collision-resistant identifier for a {@link SyncScope}.
57
- *
58
- * The ID is `base64url(SHA-256(canonicalJSON))` where `canonicalJSON` is the
59
- * scope object with keys sorted alphabetically and array values sorted
60
- * lexicographically.
61
- *
62
- * Used as part of the LevelDB key for the replication ledger:
63
- * `{tenantDid}^{remoteEndpoint}^{scopeId}`.
97
+ * Authorization context for a link. Owner links do not invoke grants. Delegate
98
+ * links carry the active Messages.Read grants that authorize the scope union.
64
99
  */
65
- export async function computeScopeId(scope: SyncScope): Promise<string> {
66
- const canonical: Record<string, unknown> = { kind: scope.kind };
67
- if (scope.kind === 'protocol') {
68
- canonical.protocol = scope.protocol;
69
- if (scope.protocolPathPrefixes !== undefined) {
70
- canonical.protocolPathPrefixes = [...new Set(scope.protocolPathPrefixes)].sort((a, b) => a.localeCompare(b));
71
- }
72
- if (scope.contextIdPrefixes !== undefined) {
73
- canonical.contextIdPrefixes = [...new Set(scope.contextIdPrefixes)].sort((a, b) => a.localeCompare(b));
74
- }
100
+ export type SyncAuthorization =
101
+ | { kind: 'owner' }
102
+ | {
103
+ kind: 'delegate';
104
+ delegateDid: string;
105
+ permissionGrantIds: NonEmptyStringArray;
106
+ };
107
+
108
+ /** Grant metadata that participates in delegated authorization-epoch hashing. */
109
+ export type SyncAuthorizationGrant = {
110
+ id: string;
111
+ dateExpires: string;
112
+ dateGranted?: string;
113
+ };
114
+
115
+ /**
116
+ * Normalizes a protocol list into canonical scope-union order.
117
+ */
118
+ export function normalizeSyncProtocols(protocols: [string, ...string[]] | string[]): NonEmptyStringArray {
119
+ const unique = [...new Set(protocols)].sort(lexicographicalCompare);
120
+ if (unique.length === 0) {
121
+ throw new Error('SyncScope: protocol-set scope requires at least one protocol URI.');
75
122
  }
123
+ return unique as NonEmptyStringArray;
124
+ }
125
+
126
+ /** Converts persisted identity options into the canonical sync scope. */
127
+ export function syncScopeFromProtocols(protocols: SyncIdentityOptions['protocols']): SyncScope {
128
+ return protocols === 'all'
129
+ ? { kind: 'full' }
130
+ : { kind: 'protocolSet', protocols: normalizeSyncProtocols(protocols) };
131
+ }
132
+
133
+ /** Converts Records projection scopes into the canonical sync scope shape. */
134
+ export function syncScopeFromRecordsProjectionScopes(
135
+ scopes: readonly [RecordsProjectionScope, ...RecordsProjectionScope[]],
136
+ ): Extract<SyncScope, { kind: 'recordsProjection' }> {
137
+ return {
138
+ kind : 'recordsProjection',
139
+ scopes : RecordsProjection.normalizeScopes(scopes),
140
+ };
141
+ }
142
+
143
+ /** Returns the protocol list covered by a scope, or `undefined` for full scope. */
144
+ export function protocolsForSyncScope(scope: SyncScope): NonEmptyStringArray | undefined {
145
+ if (scope.kind === 'full') {
146
+ return undefined;
147
+ }
148
+
149
+ if (scope.kind === 'protocolSet') {
150
+ return scope.protocols;
151
+ }
152
+
153
+ return normalizeSyncProtocols(scope.scopes.map(scope => scope.protocol));
154
+ }
76
155
 
77
- // Stable JSON: keys sorted by construction order (kind < protocol < protocolPathPrefixes).
78
- const json = JSON.stringify(canonical);
156
+ /** Returns the single protocol root covered by a protocol-set scope, if any. */
157
+ export function singleProtocolForSyncScope(scope: SyncScope): string | undefined {
158
+ return scope.kind === 'protocolSet' && scope.protocols.length === 1 ? scope.protocols[0] : undefined;
159
+ }
160
+
161
+ /** Stable base64url SHA-256 hash for canonical JSON objects. */
162
+ async function hashCanonicalObject(value: Record<string, unknown>): Promise<string> {
163
+ const json = JSON.stringify(value);
79
164
  const bytes = new TextEncoder().encode(json);
80
165
  const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
81
166
  const hashArray = new Uint8Array(hashBuffer);
82
167
 
83
- // base64url encode (no padding).
84
168
  let base64 = '';
85
169
  for (const b of hashArray) {
86
170
  base64 += String.fromCharCode(b);
87
171
  }
88
172
  const result = btoa(base64).replaceAll('+', '-').replaceAll('/', '_');
89
- // Strip trailing '=' padding without regex quantifiers (avoids ReDoS scanners).
90
173
  let end = result.length;
91
- while (end > 0 && result.codePointAt(end - 1) === 61) { end--; } // 61 === '='
174
+ while (end > 0 && result.codePointAt(end - 1) === 61) { end--; }
92
175
  return end === result.length ? result : result.slice(0, end);
93
176
  }
94
177
 
178
+ /** Returns a canonical JSON-ready representation of a sync scope. */
179
+ export function canonicalizeSyncScope(scope: SyncScope): SyncScope {
180
+ if (scope.kind === 'full') {
181
+ return { kind: 'full' };
182
+ }
183
+
184
+ if (scope.kind === 'protocolSet') {
185
+ return { kind: 'protocolSet', protocols: normalizeSyncProtocols(scope.protocols) };
186
+ }
187
+
188
+ return {
189
+ kind : 'recordsProjection',
190
+ scopes : RecordsProjection.normalizeScopes(scope.scopes),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Computes a deterministic, collision-resistant projection ID.
196
+ *
197
+ * The projection ID is derived from tenant DID, normalized scope, and the
198
+ * projection-root algorithm version. It intentionally excludes endpoint,
199
+ * grant IDs, authorization epoch, and remote diff contents.
200
+ */
201
+ export async function computeProjectionId(tenantDid: string, scope: SyncScope): Promise<string> {
202
+ const canonicalScope = canonicalizeSyncScope(scope);
203
+ const version = canonicalScope.kind === 'recordsProjection'
204
+ ? RECORDS_PROJECTION_ROOT_VERSION
205
+ : SYNC_PROJECTION_ROOT_VERSION;
206
+
207
+ return hashCanonicalObject({
208
+ scope: canonicalScope,
209
+ tenantDid,
210
+ version,
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Computes the authorization epoch for a link.
216
+ *
217
+ * Owner epochs are stable for the owner projection. Delegate epochs are derived
218
+ * from the delegate DID plus the active grant IDs and expiry metadata. A changed
219
+ * grant set creates a new link key even when the projection ID is unchanged.
220
+ */
221
+ export async function computeAuthorizationEpoch(input:
222
+ | { kind: 'owner' }
223
+ | { kind: 'delegate'; delegateDid: string; grants: [SyncAuthorizationGrant, ...SyncAuthorizationGrant[]] }
224
+ ): Promise<string> {
225
+ if (input.kind === 'owner') {
226
+ return hashCanonicalObject({
227
+ kind : 'owner',
228
+ version : SYNC_AUTHORIZATION_EPOCH_VERSION,
229
+ });
230
+ }
231
+
232
+ const grants = [...input.grants]
233
+ .sort((a, b) => lexicographicalCompare(a.id, b.id))
234
+ .map(grant => ({
235
+ dateExpires : grant.dateExpires,
236
+ dateGranted : grant.dateGranted,
237
+ id : grant.id,
238
+ }));
239
+
240
+ return hashCanonicalObject({
241
+ delegateDid : input.delegateDid,
242
+ grants,
243
+ kind : 'delegate',
244
+ version : SYNC_AUTHORIZATION_EPOCH_VERSION,
245
+ });
246
+ }
247
+
95
248
  // ---------------------------------------------------------------------------
96
249
  // Replication checkpoint types
97
250
  // ---------------------------------------------------------------------------
@@ -137,16 +290,18 @@ export type DirectionCheckpoint = {
137
290
  *
138
291
  * - `initializing` — link created, no subscriptions open yet.
139
292
  * - `live` — actively receiving events via subscription.
293
+ * - `polling` — current link is reconciled by periodic SMT sync; live subscription is not supported for its scope.
140
294
  * - `repairing` — gap detected or pending overflow; running SMT reconciliation.
141
295
  * - `degraded_poll` — subscription failed; polling at reduced frequency.
296
+ * - `terminal_incomplete` — closure failed with a terminal dependency error; requires a new scope/authorization epoch.
142
297
  * - `paused` — explicitly paused by the application.
143
298
  */
144
- export type LinkStatus = 'initializing' | 'live' | 'repairing' | 'degraded_poll' | 'paused';
299
+ export type LinkStatus = 'initializing' | 'live' | 'polling' | 'repairing' | 'degraded_poll' | 'terminal_incomplete' | 'paused';
145
300
 
146
301
  /**
147
302
  * Durable state of a single replication link. Persisted to LevelDB and
148
303
  * loaded on startup. Each link is identified by the tuple
149
- * `(tenantDid, remoteEndpoint, scopeId)`.
304
+ * `(tenantDid, remoteEndpoint, projectionId, authorizationEpoch)`.
150
305
  */
151
306
  export type ReplicationLinkState = {
152
307
  /** The tenant DID this link syncs for. */
@@ -155,12 +310,18 @@ export type ReplicationLinkState = {
155
310
  /** The remote DWN endpoint URL. */
156
311
  remoteEndpoint: string;
157
312
 
158
- /** Deterministic hash of the {@link SyncScope}. See {@link computeScopeId}. */
159
- scopeId: string;
313
+ /** Deterministic hash of tenant DID, normalized scope, and root algorithm version. */
314
+ projectionId: string;
315
+
316
+ /** Deterministic hash of owner/delegate grant context for this link. */
317
+ authorizationEpoch: string;
160
318
 
161
319
  /** The scope definition this link covers. */
162
320
  scope: SyncScope;
163
321
 
322
+ /** Owner/delegate authorization context used to sign sync messages. */
323
+ authorization: SyncAuthorization;
324
+
164
325
  /** Current link status. */
165
326
  status: LinkStatus;
166
327
 
@@ -181,15 +342,6 @@ export type ReplicationLinkState = {
181
342
  /** Delegate DID used to sign sync messages, if any. */
182
343
  delegateDid?: string;
183
344
 
184
- /**
185
- * Protocol filter for this link, if any. Duplicates the protocol in `scope`
186
- * for operational convenience — used by permission lookups and cursor key
187
- * building. The scope is the source of truth for what to sync; this field
188
- * is the source of truth for how to authenticate. To be consolidated in
189
- * Phase 3 when scope resolution is more complex.
190
- */
191
- protocol?: string;
192
-
193
345
  /** ISO-8601 timestamp of last successful sync activity. */
194
346
  lastActivityAt?: string;
195
347
  };
@@ -230,7 +382,7 @@ export type StartSyncParams = {
230
382
  * push. Falls back to SMT reconciliation on cold start or long disconnect.
231
383
  * An infrequent SMT integrity check still runs at `interval`.
232
384
  *
233
- * - `'poll'`: Legacy mode. Performs a full SMT set-reconciliation sync on a
385
+ * - `'poll'`: Performs a full SMT set-reconciliation sync on a
234
386
  * fixed interval. No WebSocket subscriptions are used.
235
387
  */
236
388
  mode?: SyncMode;
@@ -251,22 +403,35 @@ export type StartSyncParams = {
251
403
  // Sync observability events
252
404
  // ---------------------------------------------------------------------------
253
405
 
406
+ /** Sync scope metadata attached to observability events. */
407
+ export type SyncEventScope = {
408
+ /** Present only when the event belongs to a single-protocol link. */
409
+ protocol?: string;
410
+ /** Present when the event belongs to a protocol-set link. */
411
+ protocols?: NonEmptyStringArray;
412
+ };
413
+
414
+ type SyncEventBase = {
415
+ tenantDid: string;
416
+ remoteEndpoint: string;
417
+ } & SyncEventScope;
418
+
254
419
  /**
255
420
  * Events emitted by the sync engine at key state transitions.
256
421
  * Consumers subscribe via `SyncEngine.on('event', handler)` and can
257
422
  * hook these into metrics, logging, or UI state.
258
423
  */
259
424
  export type SyncEvent =
260
- | { type: 'link:status-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: LinkStatus; to: LinkStatus }
261
- | { type: 'link:connectivity-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: SyncConnectivityState; to: SyncConnectivityState }
262
- | { type: 'checkpoint:pull-advance'; tenantDid: string; remoteEndpoint: string; protocol?: string; position: string; messageCid: string }
263
- | { type: 'reconcile:needed'; tenantDid: string; remoteEndpoint: string; protocol?: string; reason: string }
264
- | { type: 'reconcile:completed'; tenantDid: string; remoteEndpoint: string; protocol?: string }
265
- | { type: 'repair:started'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number }
266
- | { type: 'repair:completed'; tenantDid: string; remoteEndpoint: string; protocol?: string }
267
- | { type: 'repair:failed'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number; error: string }
268
- | { type: 'degraded-poll:entered'; tenantDid: string; remoteEndpoint: string; protocol?: string }
269
- | { type: 'gap:detected'; tenantDid: string; remoteEndpoint: string; protocol?: string; reason: string };
425
+ | SyncEventBase & { type: 'link:status-change'; from: LinkStatus; to: LinkStatus }
426
+ | SyncEventBase & { type: 'link:connectivity-change'; from: SyncConnectivityState; to: SyncConnectivityState }
427
+ | SyncEventBase & { type: 'checkpoint:pull-advance'; position: string; messageCid: string }
428
+ | SyncEventBase & { type: 'reconcile:needed'; reason: string }
429
+ | SyncEventBase & { type: 'reconcile:completed' }
430
+ | SyncEventBase & { type: 'repair:started'; attempt: number }
431
+ | SyncEventBase & { type: 'repair:completed' }
432
+ | SyncEventBase & { type: 'repair:failed'; attempt: number; error: string }
433
+ | SyncEventBase & { type: 'degraded-poll:entered' }
434
+ | SyncEventBase & { type: 'gap:detected'; reason: string };
270
435
 
271
436
  export type SyncEventListener = (event: SyncEvent) => void;
272
437
 
@@ -275,7 +440,7 @@ export type SyncEventListener = (event: SyncEvent) => void;
275
440
  // ---------------------------------------------------------------------------
276
441
 
277
442
  /** Category of sync failure for dead letter entries. */
278
- export type DeadLetterCategory = 'push-permanent' | 'push-exhausted' | 'pull-processing' | 'closure';
443
+ export type DeadLetterCategory = 'push-permanent' | 'push-exhausted' | 'pull-processing' | 'pull-scope-rejected' | 'closure';
279
444
 
280
445
  /** A message that permanently failed to sync. */
281
446
  export type DeadLetterEntry = {
@@ -312,8 +477,16 @@ export type SyncHealthSummary = {
312
477
  * the engine self-heals — entries are auto-cleared on later success.
313
478
  */
314
479
  failedMessageCount: number;
315
- /** Number of links currently in 'repairing' or 'degraded_poll' status. */
480
+ /**
481
+ * Number of current closure failures. A link can have matching sync roots
482
+ * while still being unusable because required dependencies are missing; this
483
+ * count keeps that state visible to callers.
484
+ */
485
+ closureFailureCount: number;
486
+ /** Number of current sync links in 'repairing', 'degraded_poll', or terminal-incomplete status. */
316
487
  degradedLinkCount: number;
488
+ /** True only when there are no failed messages and no degraded links. */
489
+ syncHealthy: boolean;
317
490
  };
318
491
 
319
492
  export interface SyncEngine {