@peerbit/trusted-network 6.0.104 → 6.0.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { deserialize, serialize } from "@dao-xyz/borsh";
2
2
  import { DecryptedThing, PublicSignKey, verify } from "@peerbit/crypto";
3
3
  import { Entry, EntryV0, NO_ENCODING } from "@peerbit/log";
4
4
  import { compare, equals } from "uint8arrays";
5
- import { NetworkDescriptorV2, PolicySnapshotBodyV2, PolicySubjectBindingV2, TRUSTED_NETWORK_V2_KNOWN_ROLE_BITS, assertNetworkDescriptorV2, decodePolicySnapshotBodyV2, digestPolicySnapshotBodyV2, } from "./v2.js";
5
+ import { NetworkDescriptorV2, PolicySnapshotBodyV2, PolicySubjectBindingV2, TRUSTED_NETWORK_V2_KNOWN_ROLE_BITS, TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES, assertNetworkDescriptorV2, decodePolicySnapshotBodyV2, digestPolicySnapshotBodyV2, } from "./v2.js";
6
6
  /**
7
7
  * Internal policy reducer for the non-activatable TrustedNetwork v2 scaffold.
8
8
  *
@@ -12,8 +12,12 @@ import { NetworkDescriptorV2, PolicySnapshotBodyV2, PolicySubjectBindingV2, TRUS
12
12
  * resolver instead of being accumulated in memory.
13
13
  */
14
14
  const DEFAULT_MAX_PENDING_POLICIES_V2 = 64;
15
- const DEFAULT_MAX_PENDING_POLICY_BYTES_V2 = 256 * 1024;
16
15
  const PENDING_POLICY_ACCOUNTING_OVERHEAD_V2 = 64;
16
+ const MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2 = TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES * 2 +
17
+ PENDING_POLICY_ACCOUNTING_OVERHEAD_V2;
18
+ const DEFAULT_MAX_PENDING_POLICY_BYTES_V2 = MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2;
19
+ const DEFAULT_POLICY_RESOLUTION_TIMEOUT_MS_V2 = 10 * 1000;
20
+ const MAX_TIMER_DELAY_MS_V2 = 0x7fffffff;
17
21
  const MAX_UNAVAILABLE_REASON_LENGTH_V2 = 512;
18
22
  const copyBytes = (bytes) => Uint8Array.from(bytes);
19
23
  const bytesKey = (bytes) => {
@@ -44,29 +48,47 @@ const copySnapshot = (snapshot) => ({
44
48
  });
45
49
  const validationMessage = (error) => error instanceof Error ? error.message : String(error);
46
50
  const boundedUnavailableReason = (reason) => reason.slice(0, MAX_UNAVAILABLE_REASON_LENGTH_V2);
47
- export const authenticatePolicySnapshotEntryV2 = async (entry, descriptor) => {
48
- assertNetworkDescriptorV2(descriptor);
49
- if (!(entry instanceof EntryV0)) {
51
+ class PolicyDependencyUnavailableErrorV2 extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "PolicyDependencyUnavailableErrorV2";
55
+ }
56
+ }
57
+ const capturePolicySnapshotEntryBytesV2 = (entryBytes) => {
58
+ if (!(entryBytes instanceof Uint8Array)) {
59
+ throw new Error("Policy snapshot must use canonical EntryV0 bytes");
60
+ }
61
+ if (entryBytes.byteLength === 0) {
62
+ throw new Error("Policy snapshot must use EntryV0");
63
+ }
64
+ if (entryBytes.byteLength > TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES) {
65
+ throw new Error(`Policy snapshot entry must contain 1-${TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES} bytes`);
66
+ }
67
+ // Apply the protocol byte ceiling before this first copy, decode, or crypto
68
+ // operation. The retained copy also prevents caller mutation during awaits.
69
+ return copyBytes(entryBytes);
70
+ };
71
+ const authenticateCapturedPolicySnapshotEntryV2 = async (canonicalEntryBytes, descriptor) => {
72
+ const authenticatedEntry = deserialize(canonicalEntryBytes, Entry);
73
+ if (!(authenticatedEntry instanceof EntryV0)) {
50
74
  throw new Error("Policy snapshot must use EntryV0");
51
75
  }
52
- if (!(entry._meta instanceof DecryptedThing)) {
76
+ if (!equals(canonicalEntryBytes, serialize(authenticatedEntry))) {
77
+ throw new Error("Policy snapshot entry encoding is not canonical");
78
+ }
79
+ if (!(authenticatedEntry._meta instanceof DecryptedThing)) {
53
80
  throw new Error("Policy snapshot metadata must be public");
54
81
  }
55
- if (!(entry._payload instanceof DecryptedThing)) {
82
+ if (!(authenticatedEntry._payload instanceof DecryptedThing)) {
56
83
  throw new Error("Policy snapshot payload must be public");
57
84
  }
58
- if (entry._signatures === undefined ||
59
- entry._signatures.signatures.length !== 1) {
85
+ if (authenticatedEntry._signatures === undefined ||
86
+ authenticatedEntry._signatures.signatures.length !== 1) {
60
87
  throw new Error("Policy snapshot must contain exactly one signature");
61
88
  }
62
- if (!(entry._signatures.signatures[0] instanceof DecryptedThing)) {
89
+ if (!(authenticatedEntry._signatures.signatures[0] instanceof DecryptedThing)) {
63
90
  throw new Error("Policy snapshot signature must be public");
64
91
  }
65
- const entryBytes = serialize(entry);
66
- const authenticatedEntry = deserialize(entryBytes, Entry);
67
- if (!(authenticatedEntry instanceof EntryV0)) {
68
- throw new Error("Policy snapshot must decode as EntryV0");
69
- }
70
92
  authenticatedEntry.init({ encoding: NO_ENCODING });
71
93
  const signatures = await authenticatedEntry.getSignatures();
72
94
  if (signatures.length !== 1) {
@@ -89,12 +111,16 @@ export const authenticatePolicySnapshotEntryV2 = async (entry, descriptor) => {
89
111
  body: copyBody(body),
90
112
  digest: copyBytes(digest),
91
113
  digestKey: bytesKey(digest),
92
- entryBytes,
93
- accountedBytes: entryBytes.byteLength +
114
+ entryBytes: canonicalEntryBytes,
115
+ accountedBytes: canonicalEntryBytes.byteLength +
94
116
  serialize(body).byteLength +
95
117
  PENDING_POLICY_ACCOUNTING_OVERHEAD_V2,
96
118
  };
97
119
  };
120
+ export const authenticatePolicySnapshotEntryV2 = async (entryBytes, descriptor) => {
121
+ assertNetworkDescriptorV2(descriptor);
122
+ return authenticateCapturedPolicySnapshotEntryV2(capturePolicySnapshotEntryBytesV2(entryBytes), descriptor);
123
+ };
98
124
  const projectionFromSnapshot = (snapshot) => ({
99
125
  sequence: snapshot.body.sequence,
100
126
  digest: copyBytes(snapshot.digest),
@@ -128,11 +154,67 @@ const copyForkEvidence = (evidence) => ({
128
154
  copyForkChildProof(evidence.children[1]),
129
155
  ],
130
156
  });
157
+ const captureDurableStateV2 = (durableState) => {
158
+ if (durableState === null ||
159
+ typeof durableState !== "object" ||
160
+ durableState.formatVersion !== 1) {
161
+ throw new Error("Unsupported TrustedNetwork v2 reducer state format");
162
+ }
163
+ switch (durableState.state) {
164
+ case "EMPTY":
165
+ return { formatVersion: 1, state: "EMPTY" };
166
+ case "ACTIVE":
167
+ return {
168
+ formatVersion: 1,
169
+ state: "ACTIVE",
170
+ acceptedHeadEntryBytes: capturePolicySnapshotEntryBytesV2(durableState.acceptedHeadEntryBytes),
171
+ };
172
+ case "UNAVAILABLE": {
173
+ if (!(durableState.acceptedAncestorDigest instanceof Uint8Array) ||
174
+ durableState.acceptedAncestorDigest.byteLength !== 32) {
175
+ throw new Error("Unavailable accepted ancestor digest must contain exactly 32 bytes");
176
+ }
177
+ if (typeof durableState.reason !== "string" ||
178
+ durableState.reason.length === 0 ||
179
+ durableState.reason.length > MAX_UNAVAILABLE_REASON_LENGTH_V2) {
180
+ throw new Error(`Unavailable reason must contain 1-${MAX_UNAVAILABLE_REASON_LENGTH_V2} characters`);
181
+ }
182
+ return {
183
+ formatVersion: 1,
184
+ state: "UNAVAILABLE",
185
+ acceptedHeadEntryBytes: capturePolicySnapshotEntryBytesV2(durableState.acceptedHeadEntryBytes),
186
+ comparisonCandidateEntryBytes: capturePolicySnapshotEntryBytesV2(durableState.comparisonCandidateEntryBytes),
187
+ acceptedAncestorDigest: copyBytes(durableState.acceptedAncestorDigest),
188
+ reason: durableState.reason,
189
+ };
190
+ }
191
+ case "FORKED":
192
+ if (!Array.isArray(durableState.childEntryBytes) ||
193
+ durableState.childEntryBytes.length !== 2) {
194
+ throw new Error("Forked reducer state must contain exactly two children");
195
+ }
196
+ return {
197
+ formatVersion: 1,
198
+ state: "FORKED",
199
+ commonParentEntryBytes: capturePolicySnapshotEntryBytesV2(durableState.commonParentEntryBytes),
200
+ childEntryBytes: [
201
+ capturePolicySnapshotEntryBytesV2(durableState.childEntryBytes[0]),
202
+ capturePolicySnapshotEntryBytesV2(durableState.childEntryBytes[1]),
203
+ ],
204
+ };
205
+ default:
206
+ throw new Error("Unsupported TrustedNetwork v2 reducer state");
207
+ }
208
+ };
131
209
  export class TrustedNetworkV2PolicyReducer {
132
210
  descriptor;
133
211
  resolvePolicyEntry;
212
+ resolveTimeoutMs;
134
213
  maxPending;
135
214
  maxPendingPolicyBytes;
215
+ lifecycleController = new AbortController();
216
+ externalSignal;
217
+ externalAbortListener;
136
218
  acceptedHead;
137
219
  projectedRoles = new Map();
138
220
  pending = new Map();
@@ -149,15 +231,107 @@ export class TrustedNetworkV2PolicyReducer {
149
231
  }
150
232
  const maxPendingPolicyBytes = properties.maxPendingPolicyBytes ?? DEFAULT_MAX_PENDING_POLICY_BYTES_V2;
151
233
  if (!Number.isSafeInteger(maxPendingPolicyBytes) ||
152
- maxPendingPolicyBytes < 1) {
153
- throw new Error("maxPendingPolicyBytes must be a positive safe integer");
234
+ maxPendingPolicyBytes < 1 ||
235
+ maxPendingPolicyBytes > MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2) {
236
+ throw new Error(`maxPendingPolicyBytes must be a positive safe integer no greater than ${MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2}`);
237
+ }
238
+ const resolveTimeoutMs = properties.resolveTimeoutMs ?? DEFAULT_POLICY_RESOLUTION_TIMEOUT_MS_V2;
239
+ if (!Number.isSafeInteger(resolveTimeoutMs) ||
240
+ resolveTimeoutMs < 1 ||
241
+ resolveTimeoutMs > MAX_TIMER_DELAY_MS_V2) {
242
+ throw new Error(`resolveTimeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS_V2}`);
154
243
  }
155
244
  this.descriptor = deserialize(serialize(properties.descriptor), NetworkDescriptorV2);
156
245
  this.resolvePolicyEntry = properties.resolvePolicyEntry;
246
+ this.resolveTimeoutMs = resolveTimeoutMs;
157
247
  this.maxPending = maxPending;
158
248
  this.maxPendingPolicyBytes = maxPendingPolicyBytes;
249
+ if (properties.signal?.aborted) {
250
+ this.lifecycleController.abort();
251
+ }
252
+ else if (properties.signal !== undefined) {
253
+ this.externalSignal = properties.signal;
254
+ this.externalAbortListener = () => {
255
+ this.externalSignal = undefined;
256
+ this.externalAbortListener = undefined;
257
+ this.lifecycleController.abort();
258
+ };
259
+ this.externalSignal.addEventListener("abort", this.externalAbortListener, { once: true });
260
+ }
261
+ }
262
+ static async restore(properties) {
263
+ // Capture the complete checkpoint before the first await. Persistence
264
+ // adapters commonly reuse read buffers, and mutation during authentication
265
+ // must not change what is restored.
266
+ const durableState = captureDurableStateV2(properties.durableState);
267
+ const reducer = new TrustedNetworkV2PolicyReducer(properties);
268
+ const authenticate = (entryBytes) => authenticateCapturedPolicySnapshotEntryV2(entryBytes, reducer.descriptor);
269
+ try {
270
+ switch (durableState.state) {
271
+ case "EMPTY":
272
+ return reducer;
273
+ case "ACTIVE": {
274
+ const acceptedHead = await authenticate(durableState.acceptedHeadEntryBytes);
275
+ // A durable ACTIVE head is a trusted prior-validation checkpoint. Its
276
+ // authority signature and network binding are re-authenticated above,
277
+ // but restore deliberately does not require historical resolver data.
278
+ reducer.project(acceptedHead);
279
+ return reducer;
280
+ }
281
+ case "UNAVAILABLE": {
282
+ const [acceptedHead, comparisonCandidate] = await Promise.all([
283
+ authenticate(durableState.acceptedHeadEntryBytes),
284
+ authenticate(durableState.comparisonCandidateEntryBytes),
285
+ ]);
286
+ if (equals(acceptedHead.digest, comparisonCandidate.digest)) {
287
+ throw new Error("Unavailable comparison candidate must differ from the accepted head");
288
+ }
289
+ reducer.project(acceptedHead);
290
+ reducer.addPending(comparisonCandidate, durableState.acceptedAncestorDigest);
291
+ reducer.unavailable = {
292
+ acceptedAncestorDigest: copyBytes(durableState.acceptedAncestorDigest),
293
+ comparisonCandidate: copySnapshot(comparisonCandidate),
294
+ reason: durableState.reason,
295
+ };
296
+ return reducer;
297
+ }
298
+ case "FORKED": {
299
+ const [commonParent, firstChild, secondChild] = await Promise.all([
300
+ authenticate(durableState.commonParentEntryBytes),
301
+ authenticate(durableState.childEntryBytes[0]),
302
+ authenticate(durableState.childEntryBytes[1]),
303
+ ]);
304
+ for (const child of [firstChild, secondChild]) {
305
+ if (child.body.sequence !== commonParent.body.sequence + 1n ||
306
+ !equals(child.body.previousPolicyDigest, commonParent.digest)) {
307
+ throw new Error("Fork child must be a direct successor of the common parent");
308
+ }
309
+ }
310
+ if (equals(firstChild.digest, secondChild.digest)) {
311
+ throw new Error("Fork children must have distinct policy digests");
312
+ }
313
+ const children = [
314
+ forkProofFromSnapshot(firstChild),
315
+ forkProofFromSnapshot(secondChild),
316
+ ].sort(compareForkChildProofs);
317
+ reducer.project(commonParent);
318
+ reducer.fork = {
319
+ commonParent: projectionFromSnapshot(commonParent),
320
+ children,
321
+ };
322
+ return reducer;
323
+ }
324
+ }
325
+ }
326
+ catch (error) {
327
+ // Do not retain a caller-owned AbortSignal listener when restore rejects.
328
+ reducer.abort();
329
+ throw error;
330
+ }
159
331
  }
160
332
  get state() {
333
+ if (this.lifecycleController.signal.aborted)
334
+ return "HALTED";
161
335
  if (this.fork !== undefined)
162
336
  return "FORKED";
163
337
  if (this.unavailable !== undefined)
@@ -187,6 +361,45 @@ export class TrustedNetworkV2PolicyReducer {
187
361
  .sort((a, b) => compareKeys(a.snapshot.digestKey, b.snapshot.digestKey))
188
362
  .map(({ snapshot }) => copyBytes(snapshot.digest));
189
363
  }
364
+ exportDurableState() {
365
+ // Lifecycle cancellation is process-local. Export the underlying protocol
366
+ // safety state so a replacement process cannot erase UNAVAILABLE/FORKED.
367
+ if (this.fork !== undefined) {
368
+ if (this.acceptedHead === undefined) {
369
+ throw new Error("Forked reducer is missing its common-parent entry");
370
+ }
371
+ return {
372
+ formatVersion: 1,
373
+ state: "FORKED",
374
+ commonParentEntryBytes: copyBytes(this.acceptedHead.entryBytes),
375
+ childEntryBytes: [
376
+ copyBytes(this.fork.children[0].entryBytes),
377
+ copyBytes(this.fork.children[1].entryBytes),
378
+ ],
379
+ };
380
+ }
381
+ if (this.unavailable !== undefined) {
382
+ if (this.acceptedHead === undefined) {
383
+ throw new Error("Unavailable reducer is missing its accepted head");
384
+ }
385
+ return {
386
+ formatVersion: 1,
387
+ state: "UNAVAILABLE",
388
+ acceptedHeadEntryBytes: copyBytes(this.acceptedHead.entryBytes),
389
+ comparisonCandidateEntryBytes: copyBytes(this.unavailable.comparisonCandidate.entryBytes),
390
+ acceptedAncestorDigest: copyBytes(this.unavailable.acceptedAncestorDigest),
391
+ reason: this.unavailable.reason,
392
+ };
393
+ }
394
+ if (this.acceptedHead !== undefined) {
395
+ return {
396
+ formatVersion: 1,
397
+ state: "ACTIVE",
398
+ acceptedHeadEntryBytes: copyBytes(this.acceptedHead.entryBytes),
399
+ };
400
+ }
401
+ return { formatVersion: 1, state: "EMPTY" };
402
+ }
190
403
  rolesFor(subject) {
191
404
  return this.projectedRoles.get(publicKeyId(subject)) ?? 0;
192
405
  }
@@ -199,6 +412,15 @@ export class TrustedNetworkV2PolicyReducer {
199
412
  }
200
413
  return (this.rolesFor(subject) & roles) === roles;
201
414
  }
415
+ abort() {
416
+ if (this.externalSignal !== undefined &&
417
+ this.externalAbortListener !== undefined) {
418
+ this.externalSignal.removeEventListener("abort", this.externalAbortListener);
419
+ }
420
+ this.externalSignal = undefined;
421
+ this.externalAbortListener = undefined;
422
+ this.lifecycleController.abort();
423
+ }
202
424
  fetchHints() {
203
425
  const unique = new Map();
204
426
  for (const { missingParentDigest } of this.pending.values()) {
@@ -214,11 +436,14 @@ export class TrustedNetworkV2PolicyReducer {
214
436
  digest: copyBytes(digest),
215
437
  }));
216
438
  }
217
- result(status, reason, evictedPolicyDigests) {
439
+ result(status, reason, evictedPolicyDigests, forkObservations) {
218
440
  return {
219
441
  status,
220
442
  reason,
221
443
  head: this.head,
444
+ forkObservations: forkObservations === undefined
445
+ ? undefined
446
+ : forkObservations.map(copyForkChildProof),
222
447
  fetchHints: this.fetchHints(),
223
448
  pendingCount: this.pending.size,
224
449
  pendingBytes: this.pendingBytes,
@@ -227,12 +452,15 @@ export class TrustedNetworkV2PolicyReducer {
227
452
  : evictedPolicyDigests.map(copyBytes),
228
453
  };
229
454
  }
230
- forkedResult() {
231
- return this.result("forked", "Policy authority signed competing children");
455
+ forkedResult(forkObservations) {
456
+ return this.result("forked", "Policy authority signed competing children", undefined, forkObservations);
457
+ }
458
+ haltedResult() {
459
+ return this.result("halted", "Policy reducer lifecycle is aborted");
232
460
  }
233
461
  unavailableResult(retention) {
234
462
  const blockedCandidateRetained = this.unavailable !== undefined &&
235
- this.pending.has(this.unavailable.candidateDigestKey);
463
+ this.pending.has(this.unavailable.comparisonCandidate.digestKey);
236
464
  const recoverable = (retention?.retained ?? true) && blockedCandidateRetained;
237
465
  const reason = recoverable
238
466
  ? this.unavailable.reason
@@ -242,8 +470,11 @@ export class TrustedNetworkV2PolicyReducer {
242
470
  return this.result(recoverable ? "unavailable" : "capacity", reason, retention?.evictedPolicyDigests);
243
471
  }
244
472
  completedDrainResult(outcome) {
245
- if (outcome?.status === "forked")
246
- return this.forkedResult();
473
+ if (outcome?.status === "forked") {
474
+ return this.forkedResult(outcome.forkObservations);
475
+ }
476
+ if (outcome?.status === "halted")
477
+ return this.haltedResult();
247
478
  return outcome?.status === "unavailable"
248
479
  ? this.unavailableResult(outcome)
249
480
  : undefined;
@@ -261,6 +492,67 @@ export class TrustedNetworkV2PolicyReducer {
261
492
  this.acceptedHead = copySnapshot(snapshot);
262
493
  }
263
494
  }
495
+ async resolveExternalSnapshot(digest) {
496
+ if (this.lifecycleController.signal.aborted) {
497
+ throw new PolicyDependencyUnavailableErrorV2("Policy resolver lifecycle is aborted");
498
+ }
499
+ const attemptController = new AbortController();
500
+ let timedOut = false;
501
+ const abortFromLifecycle = () => attemptController.abort();
502
+ this.lifecycleController.signal.addEventListener("abort", abortFromLifecycle, { once: true });
503
+ const timeout = setTimeout(() => {
504
+ timedOut = true;
505
+ attemptController.abort();
506
+ }, this.resolveTimeoutMs);
507
+ const abortError = () => new PolicyDependencyUnavailableErrorV2(timedOut
508
+ ? `Policy resolver timed out after ${this.resolveTimeoutMs} ms`
509
+ : "Policy resolver attempt was aborted");
510
+ let rejectOnAbort;
511
+ const abortPromise = new Promise((_resolve, reject) => {
512
+ rejectOnAbort = () => {
513
+ reject(abortError());
514
+ };
515
+ attemptController.signal.addEventListener("abort", rejectOnAbort, {
516
+ once: true,
517
+ });
518
+ });
519
+ const resolution = Promise.resolve().then(async () => {
520
+ if (attemptController.signal.aborted)
521
+ throw abortError();
522
+ const entryBytes = await this.resolvePolicyEntry(copyBytes(digest), {
523
+ signal: attemptController.signal,
524
+ });
525
+ // A resolver may ignore cancellation. Do not spend decode or signature
526
+ // verification work on bytes that arrive after this attempt expired.
527
+ if (attemptController.signal.aborted)
528
+ throw abortError();
529
+ if (entryBytes === undefined)
530
+ return undefined;
531
+ const snapshot = await authenticatePolicySnapshotEntryV2(entryBytes, this.descriptor);
532
+ if (!equals(snapshot.digest, digest)) {
533
+ throw new Error("Policy resolver returned the wrong body digest");
534
+ }
535
+ return snapshot;
536
+ });
537
+ // Promise.race installs handlers, but this explicit observer documents and
538
+ // preserves consumption if the resolver settles after its deadline.
539
+ void resolution.then(() => undefined, () => undefined);
540
+ try {
541
+ return await Promise.race([resolution, abortPromise]);
542
+ }
543
+ catch (error) {
544
+ if (error instanceof PolicyDependencyUnavailableErrorV2)
545
+ throw error;
546
+ throw new PolicyDependencyUnavailableErrorV2(`Policy resolver dependency is unavailable: ${validationMessage(error)}`);
547
+ }
548
+ finally {
549
+ clearTimeout(timeout);
550
+ this.lifecycleController.signal.removeEventListener("abort", abortFromLifecycle);
551
+ if (rejectOnAbort !== undefined) {
552
+ attemptController.signal.removeEventListener("abort", rejectOnAbort);
553
+ }
554
+ }
555
+ }
264
556
  async resolveSnapshot(digest, cache) {
265
557
  const digestKey = bytesKey(digest);
266
558
  if (this.acceptedHead?.digestKey === digestKey) {
@@ -271,16 +563,7 @@ export class TrustedNetworkV2PolicyReducer {
271
563
  return copySnapshot(pending.snapshot);
272
564
  let resolution = cache?.get(digestKey);
273
565
  if (resolution === undefined) {
274
- resolution = (async () => {
275
- const entry = await this.resolvePolicyEntry(copyBytes(digest));
276
- if (entry === undefined)
277
- return undefined;
278
- const snapshot = await authenticatePolicySnapshotEntryV2(entry, this.descriptor);
279
- if (!equals(snapshot.digest, digest)) {
280
- throw new Error("Policy resolver returned the wrong body digest");
281
- }
282
- return snapshot;
283
- })();
566
+ resolution = this.resolveExternalSnapshot(digest);
284
567
  cache?.set(digestKey, resolution);
285
568
  }
286
569
  const snapshot = await resolution;
@@ -300,9 +583,9 @@ export class TrustedNetworkV2PolicyReducer {
300
583
  }
301
584
  catch (error) {
302
585
  return {
303
- status: "reject",
586
+ status: "unavailable",
304
587
  digest: copyBytes(child.body.previousPolicyDigest),
305
- reason: `Policy parent validation failed: ${validationMessage(error)}`,
588
+ reason: `Policy parent dependency is unavailable: ${validationMessage(error)}`,
306
589
  };
307
590
  }
308
591
  if (parent === undefined) {
@@ -320,13 +603,24 @@ export class TrustedNetworkV2PolicyReducer {
320
603
  }
321
604
  return { status: "found", parent };
322
605
  }
606
+ candidateAncestryResult(resolution) {
607
+ return resolution.status === "unavailable"
608
+ ? {
609
+ status: "missing",
610
+ digest: copyBytes(resolution.digest),
611
+ reason: resolution.reason,
612
+ }
613
+ : resolution;
614
+ }
323
615
  acceptedAncestryUnavailable(resolution) {
324
616
  return {
325
617
  status: "unavailable",
326
618
  digest: copyBytes(resolution.digest),
327
619
  reason: resolution.status === "missing"
328
620
  ? "Accepted policy ancestry is unavailable from the resolver"
329
- : `Accepted policy ancestry validation failed: ${resolution.reason}`,
621
+ : resolution.status === "unavailable"
622
+ ? `Accepted policy ancestry is unavailable: ${resolution.reason}`
623
+ : `Accepted policy ancestry validation failed: ${resolution.reason}`,
330
624
  };
331
625
  }
332
626
  async evaluate(candidate) {
@@ -335,8 +629,9 @@ export class TrustedNetworkV2PolicyReducer {
335
629
  let cursor = candidate;
336
630
  while (cursor.body.sequence !== 0n) {
337
631
  const parent = await this.parentOf(cursor, resolutionCache);
338
- if (parent.status !== "found")
339
- return parent;
632
+ if (parent.status !== "found") {
633
+ return this.candidateAncestryResult(parent);
634
+ }
340
635
  cursor = parent.parent;
341
636
  }
342
637
  return { status: "accept" };
@@ -348,8 +643,9 @@ export class TrustedNetworkV2PolicyReducer {
348
643
  while (candidateCursor.body.sequence > acceptedCursor.body.sequence) {
349
644
  candidateChild = candidateCursor;
350
645
  const parent = await this.parentOf(candidateCursor, resolutionCache);
351
- if (parent.status !== "found")
352
- return parent;
646
+ if (parent.status !== "found") {
647
+ return this.candidateAncestryResult(parent);
648
+ }
353
649
  candidateCursor = parent.parent;
354
650
  }
355
651
  while (acceptedCursor.body.sequence > candidateCursor.body.sequence) {
@@ -377,8 +673,9 @@ export class TrustedNetworkV2PolicyReducer {
377
673
  if (acceptedParent.status !== "found") {
378
674
  return this.acceptedAncestryUnavailable(acceptedParent);
379
675
  }
380
- if (candidateParent.status !== "found")
381
- return candidateParent;
676
+ if (candidateParent.status !== "found") {
677
+ return this.candidateAncestryResult(candidateParent);
678
+ }
382
679
  candidateCursor = candidateParent.parent;
383
680
  acceptedCursor = acceptedParent.parent;
384
681
  }
@@ -399,6 +696,26 @@ export class TrustedNetworkV2PolicyReducer {
399
696
  };
400
697
  }
401
698
  setFork(evaluation) {
699
+ // Pending snapshots were authenticated when admitted. Combine every one
700
+ // that is already provably a direct child with the pair that first exposed
701
+ // the fork. Canonical selection below may displace either initial child, so
702
+ // the durable layer needs the complete bounded observation set and removes
703
+ // whichever final pair is carried by exportDurableState().
704
+ const pendingForkObservationSnapshots = [...this.pending.values()]
705
+ .map(({ snapshot }) => snapshot)
706
+ .filter((snapshot) => snapshot.body.sequence ===
707
+ evaluation.commonParent.body.sequence + 1n &&
708
+ equals(snapshot.body.previousPolicyDigest, evaluation.commonParent.digest));
709
+ const forkObservationSnapshots = [
710
+ evaluation.candidateChild,
711
+ evaluation.acceptedChild,
712
+ ...pendingForkObservationSnapshots,
713
+ ];
714
+ const forkObservations = forkObservationSnapshots
715
+ .map(forkProofFromSnapshot)
716
+ .sort(compareForkChildProofs)
717
+ .filter((proof, index, observations) => index === 0 ||
718
+ !equals(proof.entryBytes, observations[index - 1].entryBytes));
402
719
  this.project(evaluation.commonParent);
403
720
  const children = [
404
721
  forkProofFromSnapshot(evaluation.candidateChild),
@@ -408,8 +725,12 @@ export class TrustedNetworkV2PolicyReducer {
408
725
  commonParent: projectionFromSnapshot(evaluation.commonParent),
409
726
  children,
410
727
  };
728
+ for (const snapshot of forkObservationSnapshots) {
729
+ this.retainCanonicalForkChild(snapshot);
730
+ }
411
731
  this.unavailable = undefined;
412
732
  this.pending.clear();
733
+ return forkObservations;
413
734
  }
414
735
  retainCanonicalForkChild(snapshot) {
415
736
  if (this.fork === undefined)
@@ -434,17 +755,20 @@ export class TrustedNetworkV2PolicyReducer {
434
755
  this.fork.children = [canonical[0], canonical[1]];
435
756
  }
436
757
  observeAfterFork(snapshot) {
437
- if (this.fork === undefined || this.acceptedHead === undefined)
438
- return;
758
+ if (this.fork === undefined || this.acceptedHead === undefined) {
759
+ return undefined;
760
+ }
439
761
  const commonParent = this.acceptedHead;
440
762
  if (snapshot.body.sequence !== commonParent.body.sequence + 1n ||
441
763
  !equals(snapshot.body.previousPolicyDigest, commonParent.digest)) {
442
- return;
764
+ return undefined;
443
765
  }
444
- // This bounded kernel deliberately retains only the canonical two direct
445
- // child proofs. Durable storage of every authenticated fork observation is
446
- // an outer-layer responsibility for a later integration slice.
766
+ // This bounded kernel retains only the canonical two direct child proofs.
767
+ // Return this already-authenticated observation so the durable outer layer
768
+ // can retain every proof without verifying it twice.
769
+ const observation = forkProofFromSnapshot(snapshot);
447
770
  this.retainCanonicalForkChild(snapshot);
771
+ return observation;
448
772
  }
449
773
  addPending(snapshot, missingParentDigest) {
450
774
  const existing = this.pending.get(snapshot.digestKey);
@@ -484,12 +808,15 @@ export class TrustedNetworkV2PolicyReducer {
484
808
  const retention = this.addPending(snapshot, evaluation.digest);
485
809
  this.unavailable = {
486
810
  acceptedAncestorDigest: copyBytes(evaluation.digest),
487
- candidateDigestKey: snapshot.digestKey,
811
+ comparisonCandidate: copySnapshot(snapshot),
488
812
  reason: boundedUnavailableReason(evaluation.reason),
489
813
  };
490
814
  return retention;
491
815
  }
492
816
  async drainPending() {
817
+ if (this.lifecycleController.signal.aborted) {
818
+ return { status: "halted" };
819
+ }
493
820
  let accepted = false;
494
821
  let progress = true;
495
822
  while (progress &&
@@ -502,6 +829,9 @@ export class TrustedNetworkV2PolicyReducer {
502
829
  if (!this.pending.has(pending.snapshot.digestKey))
503
830
  continue;
504
831
  const evaluation = await this.evaluate(pending.snapshot);
832
+ if (this.lifecycleController.signal.aborted) {
833
+ return { status: "halted" };
834
+ }
505
835
  if (evaluation.status === "missing") {
506
836
  pending.missingParentDigest = copyBytes(evaluation.digest);
507
837
  continue;
@@ -517,41 +847,70 @@ export class TrustedNetworkV2PolicyReducer {
517
847
  accepted = true;
518
848
  }
519
849
  else if (evaluation.status === "fork") {
520
- this.setFork(evaluation);
521
- return { status: "forked" };
850
+ return {
851
+ status: "forked",
852
+ forkObservations: this.setFork(evaluation),
853
+ };
522
854
  }
523
855
  }
524
856
  }
525
857
  return accepted ? { status: "accepted" } : undefined;
526
858
  }
527
859
  enqueueAdmission(operation) {
528
- const result = this.admissionTail.then(operation);
860
+ const result = this.admissionTail.then(async () => {
861
+ if (this.lifecycleController.signal.aborted) {
862
+ return this.haltedResult();
863
+ }
864
+ const admission = await operation();
865
+ return this.lifecycleController.signal.aborted
866
+ ? this.haltedResult()
867
+ : admission;
868
+ });
529
869
  this.admissionTail = result.then(() => { }, (_reason) => { });
530
870
  return result;
531
871
  }
532
- ingest(entry) {
533
- return this.enqueueAdmission(() => this.ingestOne(entry));
872
+ ingest(entryBytes) {
873
+ if (this.lifecycleController.signal.aborted) {
874
+ return Promise.resolve(this.haltedResult());
875
+ }
876
+ let capturedEntryBytes;
877
+ try {
878
+ // Capture at the API boundary, before this admission waits behind earlier
879
+ // work. Otherwise a caller could mutate a queued entry before validation.
880
+ capturedEntryBytes = capturePolicySnapshotEntryBytesV2(entryBytes);
881
+ }
882
+ catch (error) {
883
+ const reason = validationMessage(error);
884
+ return this.enqueueAdmission(async () => this.result("rejected", reason));
885
+ }
886
+ return this.enqueueAdmission(() => this.ingestOne(capturedEntryBytes));
534
887
  }
535
888
  retryUnavailable() {
536
889
  return this.enqueueAdmission(() => this.retryUnavailableOne());
537
890
  }
538
- async ingestOne(entry) {
891
+ async ingestOne(entryBytes) {
539
892
  let snapshot;
540
893
  try {
541
- snapshot = await authenticatePolicySnapshotEntryV2(entry, this.descriptor);
894
+ snapshot = await authenticateCapturedPolicySnapshotEntryV2(entryBytes, this.descriptor);
542
895
  }
543
896
  catch (error) {
544
897
  return this.result("rejected", validationMessage(error));
545
898
  }
899
+ if (this.lifecycleController.signal.aborted)
900
+ return this.haltedResult();
546
901
  if (this.fork !== undefined) {
547
- this.observeAfterFork(snapshot);
548
- return this.result("halted", "Policy reducer is halted by authority equivocation");
902
+ const forkObservation = this.observeAfterFork(snapshot);
903
+ return this.result("halted", "Policy reducer is halted by authority equivocation", undefined, forkObservation === undefined ? undefined : [forkObservation]);
549
904
  }
550
905
  this.retainCanonicalHeadEntry(snapshot);
551
906
  if (this.unavailable !== undefined) {
552
907
  if (this.acceptedHead?.digestKey === snapshot.digestKey) {
553
908
  return this.result("unavailable", this.unavailable.reason);
554
909
  }
910
+ if (this.unavailable.comparisonCandidate.digestKey === snapshot.digestKey &&
911
+ compare(snapshot.entryBytes, this.unavailable.comparisonCandidate.entryBytes) < 0) {
912
+ this.unavailable.comparisonCandidate = copySnapshot(snapshot);
913
+ }
555
914
  const existingPending = this.pending.get(snapshot.digestKey);
556
915
  if (existingPending !== undefined) {
557
916
  this.addPending(snapshot, existingPending.missingParentDigest);
@@ -566,6 +925,8 @@ export class TrustedNetworkV2PolicyReducer {
566
925
  return this.result("pending", "Policy snapshot is already pending");
567
926
  }
568
927
  const evaluation = await this.evaluate(snapshot);
928
+ if (this.lifecycleController.signal.aborted)
929
+ return this.haltedResult();
569
930
  if (evaluation.status === "reject") {
570
931
  return this.result("rejected", evaluation.reason);
571
932
  }
@@ -573,8 +934,7 @@ export class TrustedNetworkV2PolicyReducer {
573
934
  return this.result("duplicate");
574
935
  }
575
936
  if (evaluation.status === "fork") {
576
- this.setFork(evaluation);
577
- return this.forkedResult();
937
+ return this.forkedResult(this.setFork(evaluation));
578
938
  }
579
939
  if (evaluation.status === "unavailable") {
580
940
  const retention = this.enterUnavailable(snapshot, evaluation);
@@ -583,7 +943,7 @@ export class TrustedNetworkV2PolicyReducer {
583
943
  if (evaluation.status === "missing") {
584
944
  const pending = this.addPending(snapshot, evaluation.digest);
585
945
  return this.result(pending.retained ? "pending" : "capacity", pending.retained
586
- ? "Policy parent is missing"
946
+ ? (evaluation.reason ?? "Policy parent is missing")
587
947
  : "Policy pending capacity did not retain this candidate", pending.evictedPolicyDigests);
588
948
  }
589
949
  this.project(snapshot);
@@ -591,6 +951,8 @@ export class TrustedNetworkV2PolicyReducer {
591
951
  this.result("accepted"));
592
952
  }
593
953
  async retryUnavailableOne() {
954
+ if (this.lifecycleController.signal.aborted)
955
+ return this.haltedResult();
594
956
  if (this.fork !== undefined) {
595
957
  return this.result("halted", "Policy reducer is halted by authority equivocation");
596
958
  }
@@ -598,16 +960,18 @@ export class TrustedNetworkV2PolicyReducer {
598
960
  if (unavailable === undefined) {
599
961
  return this.result("duplicate", "Policy reducer is not unavailable");
600
962
  }
601
- const pending = this.pending.get(unavailable.candidateDigestKey);
963
+ const pending = this.pending.get(unavailable.comparisonCandidate.digestKey);
602
964
  if (pending === undefined) {
603
965
  return this.result("capacity", "Unavailable comparison candidate is not retained; re-ingest it before retrying");
604
966
  }
605
967
  const evaluation = await this.evaluate(pending.snapshot);
968
+ if (this.lifecycleController.signal.aborted)
969
+ return this.haltedResult();
606
970
  if (evaluation.status === "unavailable") {
607
971
  pending.missingParentDigest = copyBytes(evaluation.digest);
608
972
  this.unavailable = {
609
973
  acceptedAncestorDigest: copyBytes(evaluation.digest),
610
- candidateDigestKey: pending.snapshot.digestKey,
974
+ comparisonCandidate: copySnapshot(pending.snapshot),
611
975
  reason: boundedUnavailableReason(evaluation.reason),
612
976
  };
613
977
  return this.result("unavailable", this.unavailable.reason);
@@ -618,13 +982,12 @@ export class TrustedNetworkV2PolicyReducer {
618
982
  if (evaluation.status === "missing") {
619
983
  pending.missingParentDigest = copyBytes(evaluation.digest);
620
984
  status = "pending";
621
- reason = "Policy parent is missing";
985
+ reason = evaluation.reason ?? "Policy parent is missing";
622
986
  }
623
987
  else {
624
988
  this.pending.delete(pending.snapshot.digestKey);
625
989
  if (evaluation.status === "fork") {
626
- this.setFork(evaluation);
627
- return this.forkedResult();
990
+ return this.forkedResult(this.setFork(evaluation));
628
991
  }
629
992
  if (evaluation.status === "accept") {
630
993
  this.project(pending.snapshot);