@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.
@@ -7,6 +7,7 @@ import {
7
7
  PolicySnapshotBodyV2,
8
8
  PolicySubjectBindingV2,
9
9
  TRUSTED_NETWORK_V2_KNOWN_ROLE_BITS,
10
+ TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES,
10
11
  assertNetworkDescriptorV2,
11
12
  decodePolicySnapshotBodyV2,
12
13
  digestPolicySnapshotBodyV2,
@@ -22,8 +23,14 @@ import {
22
23
  */
23
24
 
24
25
  const DEFAULT_MAX_PENDING_POLICIES_V2 = 64;
25
- const DEFAULT_MAX_PENDING_POLICY_BYTES_V2 = 256 * 1024;
26
26
  const PENDING_POLICY_ACCOUNTING_OVERHEAD_V2 = 64;
27
+ const MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2 =
28
+ TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES * 2 +
29
+ PENDING_POLICY_ACCOUNTING_OVERHEAD_V2;
30
+ const DEFAULT_MAX_PENDING_POLICY_BYTES_V2 =
31
+ MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2;
32
+ const DEFAULT_POLICY_RESOLUTION_TIMEOUT_MS_V2 = 10 * 1000;
33
+ const MAX_TIMER_DELAY_MS_V2 = 0x7fffffff;
27
34
  const MAX_UNAVAILABLE_REASON_LENGTH_V2 = 512;
28
35
 
29
36
  const copyBytes = (bytes: Uint8Array): Uint8Array => Uint8Array.from(bytes);
@@ -76,7 +83,8 @@ const copySnapshot = (
76
83
 
77
84
  export type PolicySnapshotResolverV2 = (
78
85
  digest: Uint8Array,
79
- ) => EntryV0<Uint8Array> | undefined | Promise<EntryV0<Uint8Array> | undefined>;
86
+ options: { signal: AbortSignal },
87
+ ) => Uint8Array | undefined | Promise<Uint8Array | undefined>;
80
88
 
81
89
  export type PolicyParentFetchHintV2 = {
82
90
  kind: "policy-parent";
@@ -114,12 +122,50 @@ export type PolicyAdmissionResultV2 = {
114
122
  status: PolicyAdmissionStatusV2;
115
123
  reason?: string;
116
124
  head?: PolicyHeadProjectionV2;
125
+ /**
126
+ * Authenticated direct children surfaced while entering or already in
127
+ * FORKED. The outer durable layer can persist these proofs without decoding
128
+ * or verifying them a second time. They are bounded by one admission plus the
129
+ * pending working set and are not accumulated by this reducer.
130
+ */
131
+ forkObservations?: PolicyForkChildProofV2[];
117
132
  fetchHints: PolicyParentFetchHintV2[];
118
133
  pendingCount: number;
119
134
  pendingBytes: number;
120
135
  evictedPolicyDigests?: Uint8Array[];
121
136
  };
122
137
 
138
+ export type PolicyReducerDurableStateV2 =
139
+ | { formatVersion: 1; state: "EMPTY" }
140
+ | {
141
+ formatVersion: 1;
142
+ state: "ACTIVE";
143
+ acceptedHeadEntryBytes: Uint8Array;
144
+ }
145
+ | {
146
+ formatVersion: 1;
147
+ state: "UNAVAILABLE";
148
+ acceptedHeadEntryBytes: Uint8Array;
149
+ comparisonCandidateEntryBytes: Uint8Array;
150
+ acceptedAncestorDigest: Uint8Array;
151
+ reason: string;
152
+ }
153
+ | {
154
+ formatVersion: 1;
155
+ state: "FORKED";
156
+ commonParentEntryBytes: Uint8Array;
157
+ childEntryBytes: [Uint8Array, Uint8Array];
158
+ };
159
+
160
+ export type TrustedNetworkV2PolicyReducerProperties = {
161
+ descriptor: NetworkDescriptorV2;
162
+ resolvePolicyEntry: PolicySnapshotResolverV2;
163
+ resolveTimeoutMs?: number;
164
+ signal?: AbortSignal;
165
+ maxPending?: number;
166
+ maxPendingPolicyBytes?: number;
167
+ };
168
+
123
169
  type PendingPolicySnapshotV2 = {
124
170
  snapshot: ValidatedPolicySnapshotV2;
125
171
  missingParentDigest: Uint8Array;
@@ -127,13 +173,14 @@ type PendingPolicySnapshotV2 = {
127
173
 
128
174
  type UnavailablePolicyComparisonV2 = {
129
175
  acceptedAncestorDigest: Uint8Array;
130
- candidateDigestKey: string;
176
+ comparisonCandidate: ValidatedPolicySnapshotV2;
131
177
  reason: string;
132
178
  };
133
179
 
134
180
  type ParentResolutionV2 =
135
181
  | { status: "found"; parent: ValidatedPolicySnapshotV2 }
136
182
  | { status: "missing"; digest: Uint8Array }
183
+ | { status: "unavailable"; digest: Uint8Array; reason: string }
137
184
  | { status: "reject"; digest: Uint8Array; reason: string };
138
185
 
139
186
  type SnapshotResolutionCacheV2 = Map<
@@ -144,7 +191,7 @@ type SnapshotResolutionCacheV2 = Map<
144
191
  type EvaluationV2 =
145
192
  | { status: "accept" }
146
193
  | { status: "duplicate" }
147
- | { status: "missing"; digest: Uint8Array }
194
+ | { status: "missing"; digest: Uint8Array; reason?: string }
148
195
  | { status: "reject"; reason: string }
149
196
  | { status: "unavailable"; digest: Uint8Array; reason: string }
150
197
  | {
@@ -156,7 +203,8 @@ type EvaluationV2 =
156
203
 
157
204
  type PendingDrainOutcomeV2 =
158
205
  | { status: "accepted" }
159
- | { status: "forked" }
206
+ | { status: "forked"; forkObservations: PolicyForkChildProofV2[] }
207
+ | { status: "halted" }
160
208
  | {
161
209
  status: "unavailable";
162
210
  retained: boolean;
@@ -169,34 +217,61 @@ const validationMessage = (error: unknown): string =>
169
217
  const boundedUnavailableReason = (reason: string): string =>
170
218
  reason.slice(0, MAX_UNAVAILABLE_REASON_LENGTH_V2);
171
219
 
172
- export const authenticatePolicySnapshotEntryV2 = async (
173
- entry: unknown,
220
+ class PolicyDependencyUnavailableErrorV2 extends Error {
221
+ constructor(message: string) {
222
+ super(message);
223
+ this.name = "PolicyDependencyUnavailableErrorV2";
224
+ }
225
+ }
226
+
227
+ const capturePolicySnapshotEntryBytesV2 = (
228
+ entryBytes: Uint8Array,
229
+ ): Uint8Array => {
230
+ if (!(entryBytes instanceof Uint8Array)) {
231
+ throw new Error("Policy snapshot must use canonical EntryV0 bytes");
232
+ }
233
+ if (entryBytes.byteLength === 0) {
234
+ throw new Error("Policy snapshot must use EntryV0");
235
+ }
236
+ if (entryBytes.byteLength > TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES) {
237
+ throw new Error(
238
+ `Policy snapshot entry must contain 1-${TRUSTED_NETWORK_V2_MAX_POLICY_ENTRY_BYTES} bytes`,
239
+ );
240
+ }
241
+
242
+ // Apply the protocol byte ceiling before this first copy, decode, or crypto
243
+ // operation. The retained copy also prevents caller mutation during awaits.
244
+ return copyBytes(entryBytes);
245
+ };
246
+
247
+ const authenticateCapturedPolicySnapshotEntryV2 = async (
248
+ canonicalEntryBytes: Uint8Array,
174
249
  descriptor: NetworkDescriptorV2,
175
250
  ): Promise<ValidatedPolicySnapshotV2> => {
176
- assertNetworkDescriptorV2(descriptor);
177
- if (!(entry instanceof EntryV0)) {
251
+ const authenticatedEntry = deserialize(canonicalEntryBytes, Entry);
252
+ if (!(authenticatedEntry instanceof EntryV0)) {
178
253
  throw new Error("Policy snapshot must use EntryV0");
179
254
  }
180
- if (!(entry._meta instanceof DecryptedThing)) {
255
+ if (!equals(canonicalEntryBytes, serialize(authenticatedEntry))) {
256
+ throw new Error("Policy snapshot entry encoding is not canonical");
257
+ }
258
+ if (!(authenticatedEntry._meta instanceof DecryptedThing)) {
181
259
  throw new Error("Policy snapshot metadata must be public");
182
260
  }
183
- if (!(entry._payload instanceof DecryptedThing)) {
261
+ if (!(authenticatedEntry._payload instanceof DecryptedThing)) {
184
262
  throw new Error("Policy snapshot payload must be public");
185
263
  }
186
264
  if (
187
- entry._signatures === undefined ||
188
- entry._signatures.signatures.length !== 1
265
+ authenticatedEntry._signatures === undefined ||
266
+ authenticatedEntry._signatures.signatures.length !== 1
189
267
  ) {
190
268
  throw new Error("Policy snapshot must contain exactly one signature");
191
269
  }
192
- if (!(entry._signatures.signatures[0] instanceof DecryptedThing)) {
270
+ if (
271
+ !(authenticatedEntry._signatures.signatures[0] instanceof DecryptedThing)
272
+ ) {
193
273
  throw new Error("Policy snapshot signature must be public");
194
274
  }
195
- const entryBytes = serialize(entry);
196
- const authenticatedEntry = deserialize(entryBytes, Entry);
197
- if (!(authenticatedEntry instanceof EntryV0)) {
198
- throw new Error("Policy snapshot must decode as EntryV0");
199
- }
200
275
  authenticatedEntry.init({ encoding: NO_ENCODING });
201
276
 
202
277
  const signatures = await authenticatedEntry.getSignatures();
@@ -228,14 +303,25 @@ export const authenticatePolicySnapshotEntryV2 = async (
228
303
  body: copyBody(body),
229
304
  digest: copyBytes(digest),
230
305
  digestKey: bytesKey(digest),
231
- entryBytes,
306
+ entryBytes: canonicalEntryBytes,
232
307
  accountedBytes:
233
- entryBytes.byteLength +
308
+ canonicalEntryBytes.byteLength +
234
309
  serialize(body).byteLength +
235
310
  PENDING_POLICY_ACCOUNTING_OVERHEAD_V2,
236
311
  };
237
312
  };
238
313
 
314
+ export const authenticatePolicySnapshotEntryV2 = async (
315
+ entryBytes: Uint8Array,
316
+ descriptor: NetworkDescriptorV2,
317
+ ): Promise<ValidatedPolicySnapshotV2> => {
318
+ assertNetworkDescriptorV2(descriptor);
319
+ return authenticateCapturedPolicySnapshotEntryV2(
320
+ capturePolicySnapshotEntryBytesV2(entryBytes),
321
+ descriptor,
322
+ );
323
+ };
324
+
239
325
  const projectionFromSnapshot = (
240
326
  snapshot: ValidatedPolicySnapshotV2,
241
327
  ): PolicyHeadProjectionV2 => ({
@@ -288,11 +374,93 @@ const copyForkEvidence = (
288
374
  ],
289
375
  });
290
376
 
377
+ const captureDurableStateV2 = (
378
+ durableState: PolicyReducerDurableStateV2,
379
+ ): PolicyReducerDurableStateV2 => {
380
+ if (
381
+ durableState === null ||
382
+ typeof durableState !== "object" ||
383
+ durableState.formatVersion !== 1
384
+ ) {
385
+ throw new Error("Unsupported TrustedNetwork v2 reducer state format");
386
+ }
387
+
388
+ switch (durableState.state) {
389
+ case "EMPTY":
390
+ return { formatVersion: 1, state: "EMPTY" };
391
+ case "ACTIVE":
392
+ return {
393
+ formatVersion: 1,
394
+ state: "ACTIVE",
395
+ acceptedHeadEntryBytes: capturePolicySnapshotEntryBytesV2(
396
+ durableState.acceptedHeadEntryBytes,
397
+ ),
398
+ };
399
+ case "UNAVAILABLE": {
400
+ if (
401
+ !(durableState.acceptedAncestorDigest instanceof Uint8Array) ||
402
+ durableState.acceptedAncestorDigest.byteLength !== 32
403
+ ) {
404
+ throw new Error(
405
+ "Unavailable accepted ancestor digest must contain exactly 32 bytes",
406
+ );
407
+ }
408
+ if (
409
+ typeof durableState.reason !== "string" ||
410
+ durableState.reason.length === 0 ||
411
+ durableState.reason.length > MAX_UNAVAILABLE_REASON_LENGTH_V2
412
+ ) {
413
+ throw new Error(
414
+ `Unavailable reason must contain 1-${MAX_UNAVAILABLE_REASON_LENGTH_V2} characters`,
415
+ );
416
+ }
417
+ return {
418
+ formatVersion: 1,
419
+ state: "UNAVAILABLE",
420
+ acceptedHeadEntryBytes: capturePolicySnapshotEntryBytesV2(
421
+ durableState.acceptedHeadEntryBytes,
422
+ ),
423
+ comparisonCandidateEntryBytes: capturePolicySnapshotEntryBytesV2(
424
+ durableState.comparisonCandidateEntryBytes,
425
+ ),
426
+ acceptedAncestorDigest: copyBytes(durableState.acceptedAncestorDigest),
427
+ reason: durableState.reason,
428
+ };
429
+ }
430
+ case "FORKED":
431
+ if (
432
+ !Array.isArray(durableState.childEntryBytes) ||
433
+ durableState.childEntryBytes.length !== 2
434
+ ) {
435
+ throw new Error(
436
+ "Forked reducer state must contain exactly two children",
437
+ );
438
+ }
439
+ return {
440
+ formatVersion: 1,
441
+ state: "FORKED",
442
+ commonParentEntryBytes: capturePolicySnapshotEntryBytesV2(
443
+ durableState.commonParentEntryBytes,
444
+ ),
445
+ childEntryBytes: [
446
+ capturePolicySnapshotEntryBytesV2(durableState.childEntryBytes[0]),
447
+ capturePolicySnapshotEntryBytesV2(durableState.childEntryBytes[1]),
448
+ ],
449
+ };
450
+ default:
451
+ throw new Error("Unsupported TrustedNetwork v2 reducer state");
452
+ }
453
+ };
454
+
291
455
  export class TrustedNetworkV2PolicyReducer {
292
456
  private readonly descriptor: NetworkDescriptorV2;
293
457
  private readonly resolvePolicyEntry: PolicySnapshotResolverV2;
458
+ private readonly resolveTimeoutMs: number;
294
459
  private readonly maxPending: number;
295
460
  private readonly maxPendingPolicyBytes: number;
461
+ private readonly lifecycleController = new AbortController();
462
+ private externalSignal?: AbortSignal;
463
+ private externalAbortListener?: () => void;
296
464
  private acceptedHead?: ValidatedPolicySnapshotV2;
297
465
  private projectedRoles = new Map<string, number>();
298
466
  private readonly pending = new Map<string, PendingPolicySnapshotV2>();
@@ -302,12 +470,7 @@ export class TrustedNetworkV2PolicyReducer {
302
470
  private fork?: PolicyForkEvidenceV2;
303
471
  private admissionTail: Promise<void> = Promise.resolve();
304
472
 
305
- constructor(properties: {
306
- descriptor: NetworkDescriptorV2;
307
- resolvePolicyEntry: PolicySnapshotResolverV2;
308
- maxPending?: number;
309
- maxPendingPolicyBytes?: number;
310
- }) {
473
+ constructor(properties: TrustedNetworkV2PolicyReducerProperties) {
311
474
  assertNetworkDescriptorV2(properties.descriptor);
312
475
  const maxPending = properties.maxPending ?? DEFAULT_MAX_PENDING_POLICIES_V2;
313
476
  if (!Number.isSafeInteger(maxPending) || maxPending < 1) {
@@ -317,20 +480,147 @@ export class TrustedNetworkV2PolicyReducer {
317
480
  properties.maxPendingPolicyBytes ?? DEFAULT_MAX_PENDING_POLICY_BYTES_V2;
318
481
  if (
319
482
  !Number.isSafeInteger(maxPendingPolicyBytes) ||
320
- maxPendingPolicyBytes < 1
483
+ maxPendingPolicyBytes < 1 ||
484
+ maxPendingPolicyBytes > MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2
321
485
  ) {
322
- throw new Error("maxPendingPolicyBytes must be a positive safe integer");
486
+ throw new Error(
487
+ `maxPendingPolicyBytes must be a positive safe integer no greater than ${MAX_PENDING_POLICY_ACCOUNTED_BYTES_V2}`,
488
+ );
489
+ }
490
+ const resolveTimeoutMs =
491
+ properties.resolveTimeoutMs ?? DEFAULT_POLICY_RESOLUTION_TIMEOUT_MS_V2;
492
+ if (
493
+ !Number.isSafeInteger(resolveTimeoutMs) ||
494
+ resolveTimeoutMs < 1 ||
495
+ resolveTimeoutMs > MAX_TIMER_DELAY_MS_V2
496
+ ) {
497
+ throw new Error(
498
+ `resolveTimeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS_V2}`,
499
+ );
323
500
  }
324
501
  this.descriptor = deserialize(
325
502
  serialize(properties.descriptor),
326
503
  NetworkDescriptorV2,
327
504
  );
328
505
  this.resolvePolicyEntry = properties.resolvePolicyEntry;
506
+ this.resolveTimeoutMs = resolveTimeoutMs;
329
507
  this.maxPending = maxPending;
330
508
  this.maxPendingPolicyBytes = maxPendingPolicyBytes;
509
+
510
+ if (properties.signal?.aborted) {
511
+ this.lifecycleController.abort();
512
+ } else if (properties.signal !== undefined) {
513
+ this.externalSignal = properties.signal;
514
+ this.externalAbortListener = (): void => {
515
+ this.externalSignal = undefined;
516
+ this.externalAbortListener = undefined;
517
+ this.lifecycleController.abort();
518
+ };
519
+ this.externalSignal.addEventListener(
520
+ "abort",
521
+ this.externalAbortListener,
522
+ { once: true },
523
+ );
524
+ }
525
+ }
526
+
527
+ static async restore(
528
+ properties: TrustedNetworkV2PolicyReducerProperties & {
529
+ durableState: PolicyReducerDurableStateV2;
530
+ },
531
+ ): Promise<TrustedNetworkV2PolicyReducer> {
532
+ // Capture the complete checkpoint before the first await. Persistence
533
+ // adapters commonly reuse read buffers, and mutation during authentication
534
+ // must not change what is restored.
535
+ const durableState = captureDurableStateV2(properties.durableState);
536
+ const reducer = new TrustedNetworkV2PolicyReducer(properties);
537
+ const authenticate = (
538
+ entryBytes: Uint8Array,
539
+ ): Promise<ValidatedPolicySnapshotV2> =>
540
+ authenticateCapturedPolicySnapshotEntryV2(entryBytes, reducer.descriptor);
541
+
542
+ try {
543
+ switch (durableState.state) {
544
+ case "EMPTY":
545
+ return reducer;
546
+ case "ACTIVE": {
547
+ const acceptedHead = await authenticate(
548
+ durableState.acceptedHeadEntryBytes,
549
+ );
550
+ // A durable ACTIVE head is a trusted prior-validation checkpoint. Its
551
+ // authority signature and network binding are re-authenticated above,
552
+ // but restore deliberately does not require historical resolver data.
553
+ reducer.project(acceptedHead);
554
+ return reducer;
555
+ }
556
+ case "UNAVAILABLE": {
557
+ const [acceptedHead, comparisonCandidate] = await Promise.all([
558
+ authenticate(durableState.acceptedHeadEntryBytes),
559
+ authenticate(durableState.comparisonCandidateEntryBytes),
560
+ ]);
561
+ if (equals(acceptedHead.digest, comparisonCandidate.digest)) {
562
+ throw new Error(
563
+ "Unavailable comparison candidate must differ from the accepted head",
564
+ );
565
+ }
566
+ reducer.project(acceptedHead);
567
+ reducer.addPending(
568
+ comparisonCandidate,
569
+ durableState.acceptedAncestorDigest,
570
+ );
571
+ reducer.unavailable = {
572
+ acceptedAncestorDigest: copyBytes(
573
+ durableState.acceptedAncestorDigest,
574
+ ),
575
+ comparisonCandidate: copySnapshot(comparisonCandidate),
576
+ reason: durableState.reason,
577
+ };
578
+ return reducer;
579
+ }
580
+ case "FORKED": {
581
+ const [commonParent, firstChild, secondChild] = await Promise.all([
582
+ authenticate(durableState.commonParentEntryBytes),
583
+ authenticate(durableState.childEntryBytes[0]),
584
+ authenticate(durableState.childEntryBytes[1]),
585
+ ]);
586
+ for (const child of [firstChild, secondChild]) {
587
+ if (
588
+ child.body.sequence !== commonParent.body.sequence + 1n ||
589
+ !equals(child.body.previousPolicyDigest, commonParent.digest)
590
+ ) {
591
+ throw new Error(
592
+ "Fork child must be a direct successor of the common parent",
593
+ );
594
+ }
595
+ }
596
+ if (equals(firstChild.digest, secondChild.digest)) {
597
+ throw new Error("Fork children must have distinct policy digests");
598
+ }
599
+
600
+ const children = [
601
+ forkProofFromSnapshot(firstChild),
602
+ forkProofFromSnapshot(secondChild),
603
+ ].sort(compareForkChildProofs) as [
604
+ PolicyForkChildProofV2,
605
+ PolicyForkChildProofV2,
606
+ ];
607
+ reducer.project(commonParent);
608
+ reducer.fork = {
609
+ commonParent: projectionFromSnapshot(commonParent),
610
+ children,
611
+ };
612
+ return reducer;
613
+ }
614
+ }
615
+ } catch (error) {
616
+ // Do not retain a caller-owned AbortSignal listener when restore rejects.
617
+ reducer.abort();
618
+ throw error;
619
+ }
331
620
  }
332
621
 
333
- get state(): "EMPTY" | "ACTIVE" | "UNAVAILABLE" | "FORKED" {
622
+ get state(): "EMPTY" | "ACTIVE" | "UNAVAILABLE" | "FORKED" | "HALTED" {
623
+ if (this.lifecycleController.signal.aborted) return "HALTED";
334
624
  if (this.fork !== undefined) return "FORKED";
335
625
  if (this.unavailable !== undefined) return "UNAVAILABLE";
336
626
  return this.acceptedHead === undefined ? "EMPTY" : "ACTIVE";
@@ -364,6 +654,50 @@ export class TrustedNetworkV2PolicyReducer {
364
654
  .map(({ snapshot }) => copyBytes(snapshot.digest));
365
655
  }
366
656
 
657
+ exportDurableState(): PolicyReducerDurableStateV2 {
658
+ // Lifecycle cancellation is process-local. Export the underlying protocol
659
+ // safety state so a replacement process cannot erase UNAVAILABLE/FORKED.
660
+ if (this.fork !== undefined) {
661
+ if (this.acceptedHead === undefined) {
662
+ throw new Error("Forked reducer is missing its common-parent entry");
663
+ }
664
+ return {
665
+ formatVersion: 1,
666
+ state: "FORKED",
667
+ commonParentEntryBytes: copyBytes(this.acceptedHead.entryBytes),
668
+ childEntryBytes: [
669
+ copyBytes(this.fork.children[0].entryBytes),
670
+ copyBytes(this.fork.children[1].entryBytes),
671
+ ],
672
+ };
673
+ }
674
+ if (this.unavailable !== undefined) {
675
+ if (this.acceptedHead === undefined) {
676
+ throw new Error("Unavailable reducer is missing its accepted head");
677
+ }
678
+ return {
679
+ formatVersion: 1,
680
+ state: "UNAVAILABLE",
681
+ acceptedHeadEntryBytes: copyBytes(this.acceptedHead.entryBytes),
682
+ comparisonCandidateEntryBytes: copyBytes(
683
+ this.unavailable.comparisonCandidate.entryBytes,
684
+ ),
685
+ acceptedAncestorDigest: copyBytes(
686
+ this.unavailable.acceptedAncestorDigest,
687
+ ),
688
+ reason: this.unavailable.reason,
689
+ };
690
+ }
691
+ if (this.acceptedHead !== undefined) {
692
+ return {
693
+ formatVersion: 1,
694
+ state: "ACTIVE",
695
+ acceptedHeadEntryBytes: copyBytes(this.acceptedHead.entryBytes),
696
+ };
697
+ }
698
+ return { formatVersion: 1, state: "EMPTY" };
699
+ }
700
+
367
701
  rolesFor(subject: PublicSignKey): number {
368
702
  return this.projectedRoles.get(publicKeyId(subject)) ?? 0;
369
703
  }
@@ -380,6 +714,21 @@ export class TrustedNetworkV2PolicyReducer {
380
714
  return (this.rolesFor(subject) & roles) === roles;
381
715
  }
382
716
 
717
+ abort(): void {
718
+ if (
719
+ this.externalSignal !== undefined &&
720
+ this.externalAbortListener !== undefined
721
+ ) {
722
+ this.externalSignal.removeEventListener(
723
+ "abort",
724
+ this.externalAbortListener,
725
+ );
726
+ }
727
+ this.externalSignal = undefined;
728
+ this.externalAbortListener = undefined;
729
+ this.lifecycleController.abort();
730
+ }
731
+
383
732
  private fetchHints(): PolicyParentFetchHintV2[] {
384
733
  const unique = new Map<string, Uint8Array>();
385
734
  for (const { missingParentDigest } of this.pending.values()) {
@@ -403,11 +752,16 @@ export class TrustedNetworkV2PolicyReducer {
403
752
  status: PolicyAdmissionStatusV2,
404
753
  reason?: string,
405
754
  evictedPolicyDigests?: Uint8Array[],
755
+ forkObservations?: PolicyForkChildProofV2[],
406
756
  ): PolicyAdmissionResultV2 {
407
757
  return {
408
758
  status,
409
759
  reason,
410
760
  head: this.head,
761
+ forkObservations:
762
+ forkObservations === undefined
763
+ ? undefined
764
+ : forkObservations.map(copyForkChildProof),
411
765
  fetchHints: this.fetchHints(),
412
766
  pendingCount: this.pending.size,
413
767
  pendingBytes: this.pendingBytes,
@@ -418,8 +772,19 @@ export class TrustedNetworkV2PolicyReducer {
418
772
  };
419
773
  }
420
774
 
421
- private forkedResult(): PolicyAdmissionResultV2 {
422
- return this.result("forked", "Policy authority signed competing children");
775
+ private forkedResult(
776
+ forkObservations?: PolicyForkChildProofV2[],
777
+ ): PolicyAdmissionResultV2 {
778
+ return this.result(
779
+ "forked",
780
+ "Policy authority signed competing children",
781
+ undefined,
782
+ forkObservations,
783
+ );
784
+ }
785
+
786
+ private haltedResult(): PolicyAdmissionResultV2 {
787
+ return this.result("halted", "Policy reducer lifecycle is aborted");
423
788
  }
424
789
 
425
790
  private unavailableResult(retention?: {
@@ -428,7 +793,7 @@ export class TrustedNetworkV2PolicyReducer {
428
793
  }): PolicyAdmissionResultV2 {
429
794
  const blockedCandidateRetained =
430
795
  this.unavailable !== undefined &&
431
- this.pending.has(this.unavailable.candidateDigestKey);
796
+ this.pending.has(this.unavailable.comparisonCandidate.digestKey);
432
797
  const recoverable =
433
798
  (retention?.retained ?? true) && blockedCandidateRetained;
434
799
  const reason = recoverable
@@ -446,7 +811,10 @@ export class TrustedNetworkV2PolicyReducer {
446
811
  private completedDrainResult(
447
812
  outcome: PendingDrainOutcomeV2 | undefined,
448
813
  ): PolicyAdmissionResultV2 | undefined {
449
- if (outcome?.status === "forked") return this.forkedResult();
814
+ if (outcome?.status === "forked") {
815
+ return this.forkedResult(outcome.forkObservations);
816
+ }
817
+ if (outcome?.status === "halted") return this.haltedResult();
450
818
  return outcome?.status === "unavailable"
451
819
  ? this.unavailableResult(outcome)
452
820
  : undefined;
@@ -471,6 +839,88 @@ export class TrustedNetworkV2PolicyReducer {
471
839
  }
472
840
  }
473
841
 
842
+ private async resolveExternalSnapshot(
843
+ digest: Uint8Array,
844
+ ): Promise<ValidatedPolicySnapshotV2 | undefined> {
845
+ if (this.lifecycleController.signal.aborted) {
846
+ throw new PolicyDependencyUnavailableErrorV2(
847
+ "Policy resolver lifecycle is aborted",
848
+ );
849
+ }
850
+
851
+ const attemptController = new AbortController();
852
+ let timedOut = false;
853
+ const abortFromLifecycle = (): void => attemptController.abort();
854
+ this.lifecycleController.signal.addEventListener(
855
+ "abort",
856
+ abortFromLifecycle,
857
+ { once: true },
858
+ );
859
+ const timeout = setTimeout(() => {
860
+ timedOut = true;
861
+ attemptController.abort();
862
+ }, this.resolveTimeoutMs);
863
+ const abortError = (): PolicyDependencyUnavailableErrorV2 =>
864
+ new PolicyDependencyUnavailableErrorV2(
865
+ timedOut
866
+ ? `Policy resolver timed out after ${this.resolveTimeoutMs} ms`
867
+ : "Policy resolver attempt was aborted",
868
+ );
869
+
870
+ let rejectOnAbort: (() => void) | undefined;
871
+ const abortPromise = new Promise<never>((_resolve, reject) => {
872
+ rejectOnAbort = (): void => {
873
+ reject(abortError());
874
+ };
875
+ attemptController.signal.addEventListener("abort", rejectOnAbort, {
876
+ once: true,
877
+ });
878
+ });
879
+
880
+ const resolution = Promise.resolve().then(async () => {
881
+ if (attemptController.signal.aborted) throw abortError();
882
+ const entryBytes = await this.resolvePolicyEntry(copyBytes(digest), {
883
+ signal: attemptController.signal,
884
+ });
885
+ // A resolver may ignore cancellation. Do not spend decode or signature
886
+ // verification work on bytes that arrive after this attempt expired.
887
+ if (attemptController.signal.aborted) throw abortError();
888
+ if (entryBytes === undefined) return undefined;
889
+ const snapshot = await authenticatePolicySnapshotEntryV2(
890
+ entryBytes,
891
+ this.descriptor,
892
+ );
893
+ if (!equals(snapshot.digest, digest)) {
894
+ throw new Error("Policy resolver returned the wrong body digest");
895
+ }
896
+ return snapshot;
897
+ });
898
+ // Promise.race installs handlers, but this explicit observer documents and
899
+ // preserves consumption if the resolver settles after its deadline.
900
+ void resolution.then(
901
+ (): void => undefined,
902
+ (): void => undefined,
903
+ );
904
+
905
+ try {
906
+ return await Promise.race([resolution, abortPromise]);
907
+ } catch (error) {
908
+ if (error instanceof PolicyDependencyUnavailableErrorV2) throw error;
909
+ throw new PolicyDependencyUnavailableErrorV2(
910
+ `Policy resolver dependency is unavailable: ${validationMessage(error)}`,
911
+ );
912
+ } finally {
913
+ clearTimeout(timeout);
914
+ this.lifecycleController.signal.removeEventListener(
915
+ "abort",
916
+ abortFromLifecycle,
917
+ );
918
+ if (rejectOnAbort !== undefined) {
919
+ attemptController.signal.removeEventListener("abort", rejectOnAbort);
920
+ }
921
+ }
922
+ }
923
+
474
924
  private async resolveSnapshot(
475
925
  digest: Uint8Array,
476
926
  cache?: SnapshotResolutionCacheV2,
@@ -483,18 +933,7 @@ export class TrustedNetworkV2PolicyReducer {
483
933
  if (pending !== undefined) return copySnapshot(pending.snapshot);
484
934
  let resolution = cache?.get(digestKey);
485
935
  if (resolution === undefined) {
486
- resolution = (async () => {
487
- const entry = await this.resolvePolicyEntry(copyBytes(digest));
488
- if (entry === undefined) return undefined;
489
- const snapshot = await authenticatePolicySnapshotEntryV2(
490
- entry,
491
- this.descriptor,
492
- );
493
- if (!equals(snapshot.digest, digest)) {
494
- throw new Error("Policy resolver returned the wrong body digest");
495
- }
496
- return snapshot;
497
- })();
936
+ resolution = this.resolveExternalSnapshot(digest);
498
937
  cache?.set(digestKey, resolution);
499
938
  }
500
939
  const snapshot = await resolution;
@@ -520,9 +959,9 @@ export class TrustedNetworkV2PolicyReducer {
520
959
  );
521
960
  } catch (error) {
522
961
  return {
523
- status: "reject",
962
+ status: "unavailable",
524
963
  digest: copyBytes(child.body.previousPolicyDigest),
525
- reason: `Policy parent validation failed: ${validationMessage(error)}`,
964
+ reason: `Policy parent dependency is unavailable: ${validationMessage(error)}`,
526
965
  };
527
966
  }
528
967
  if (parent === undefined) {
@@ -541,6 +980,18 @@ export class TrustedNetworkV2PolicyReducer {
541
980
  return { status: "found", parent };
542
981
  }
543
982
 
983
+ private candidateAncestryResult(
984
+ resolution: Exclude<ParentResolutionV2, { status: "found" }>,
985
+ ): EvaluationV2 {
986
+ return resolution.status === "unavailable"
987
+ ? {
988
+ status: "missing",
989
+ digest: copyBytes(resolution.digest),
990
+ reason: resolution.reason,
991
+ }
992
+ : resolution;
993
+ }
994
+
544
995
  private acceptedAncestryUnavailable(
545
996
  resolution: Exclude<ParentResolutionV2, { status: "found" }>,
546
997
  ): Extract<EvaluationV2, { status: "unavailable" }> {
@@ -550,7 +1001,9 @@ export class TrustedNetworkV2PolicyReducer {
550
1001
  reason:
551
1002
  resolution.status === "missing"
552
1003
  ? "Accepted policy ancestry is unavailable from the resolver"
553
- : `Accepted policy ancestry validation failed: ${resolution.reason}`,
1004
+ : resolution.status === "unavailable"
1005
+ ? `Accepted policy ancestry is unavailable: ${resolution.reason}`
1006
+ : `Accepted policy ancestry validation failed: ${resolution.reason}`,
554
1007
  };
555
1008
  }
556
1009
 
@@ -562,7 +1015,9 @@ export class TrustedNetworkV2PolicyReducer {
562
1015
  let cursor = candidate;
563
1016
  while (cursor.body.sequence !== 0n) {
564
1017
  const parent = await this.parentOf(cursor, resolutionCache);
565
- if (parent.status !== "found") return parent;
1018
+ if (parent.status !== "found") {
1019
+ return this.candidateAncestryResult(parent);
1020
+ }
566
1021
  cursor = parent.parent;
567
1022
  }
568
1023
  return { status: "accept" };
@@ -576,7 +1031,9 @@ export class TrustedNetworkV2PolicyReducer {
576
1031
  while (candidateCursor.body.sequence > acceptedCursor.body.sequence) {
577
1032
  candidateChild = candidateCursor;
578
1033
  const parent = await this.parentOf(candidateCursor, resolutionCache);
579
- if (parent.status !== "found") return parent;
1034
+ if (parent.status !== "found") {
1035
+ return this.candidateAncestryResult(parent);
1036
+ }
580
1037
  candidateCursor = parent.parent;
581
1038
  }
582
1039
  while (acceptedCursor.body.sequence > candidateCursor.body.sequence) {
@@ -607,7 +1064,9 @@ export class TrustedNetworkV2PolicyReducer {
607
1064
  if (acceptedParent.status !== "found") {
608
1065
  return this.acceptedAncestryUnavailable(acceptedParent);
609
1066
  }
610
- if (candidateParent.status !== "found") return candidateParent;
1067
+ if (candidateParent.status !== "found") {
1068
+ return this.candidateAncestryResult(candidateParent);
1069
+ }
611
1070
  candidateCursor = candidateParent.parent;
612
1071
  acceptedCursor = acceptedParent.parent;
613
1072
  }
@@ -629,7 +1088,39 @@ export class TrustedNetworkV2PolicyReducer {
629
1088
  };
630
1089
  }
631
1090
 
632
- private setFork(evaluation: Extract<EvaluationV2, { status: "fork" }>): void {
1091
+ private setFork(
1092
+ evaluation: Extract<EvaluationV2, { status: "fork" }>,
1093
+ ): PolicyForkChildProofV2[] {
1094
+ // Pending snapshots were authenticated when admitted. Combine every one
1095
+ // that is already provably a direct child with the pair that first exposed
1096
+ // the fork. Canonical selection below may displace either initial child, so
1097
+ // the durable layer needs the complete bounded observation set and removes
1098
+ // whichever final pair is carried by exportDurableState().
1099
+ const pendingForkObservationSnapshots = [...this.pending.values()]
1100
+ .map(({ snapshot }) => snapshot)
1101
+ .filter(
1102
+ (snapshot) =>
1103
+ snapshot.body.sequence ===
1104
+ evaluation.commonParent.body.sequence + 1n &&
1105
+ equals(
1106
+ snapshot.body.previousPolicyDigest,
1107
+ evaluation.commonParent.digest,
1108
+ ),
1109
+ );
1110
+ const forkObservationSnapshots = [
1111
+ evaluation.candidateChild,
1112
+ evaluation.acceptedChild,
1113
+ ...pendingForkObservationSnapshots,
1114
+ ];
1115
+ const forkObservations = forkObservationSnapshots
1116
+ .map(forkProofFromSnapshot)
1117
+ .sort(compareForkChildProofs)
1118
+ .filter(
1119
+ (proof, index, observations) =>
1120
+ index === 0 ||
1121
+ !equals(proof.entryBytes, observations[index - 1]!.entryBytes),
1122
+ );
1123
+
633
1124
  this.project(evaluation.commonParent);
634
1125
  const children = [
635
1126
  forkProofFromSnapshot(evaluation.candidateChild),
@@ -642,8 +1133,12 @@ export class TrustedNetworkV2PolicyReducer {
642
1133
  commonParent: projectionFromSnapshot(evaluation.commonParent),
643
1134
  children,
644
1135
  };
1136
+ for (const snapshot of forkObservationSnapshots) {
1137
+ this.retainCanonicalForkChild(snapshot);
1138
+ }
645
1139
  this.unavailable = undefined;
646
1140
  this.pending.clear();
1141
+ return forkObservations;
647
1142
  }
648
1143
 
649
1144
  private retainCanonicalForkChild(snapshot: ValidatedPolicySnapshotV2): void {
@@ -669,20 +1164,26 @@ export class TrustedNetworkV2PolicyReducer {
669
1164
  this.fork.children = [canonical[0]!, canonical[1]!];
670
1165
  }
671
1166
 
672
- private observeAfterFork(snapshot: ValidatedPolicySnapshotV2): void {
673
- if (this.fork === undefined || this.acceptedHead === undefined) return;
1167
+ private observeAfterFork(
1168
+ snapshot: ValidatedPolicySnapshotV2,
1169
+ ): PolicyForkChildProofV2 | undefined {
1170
+ if (this.fork === undefined || this.acceptedHead === undefined) {
1171
+ return undefined;
1172
+ }
674
1173
  const commonParent = this.acceptedHead;
675
1174
  if (
676
1175
  snapshot.body.sequence !== commonParent.body.sequence + 1n ||
677
1176
  !equals(snapshot.body.previousPolicyDigest, commonParent.digest)
678
1177
  ) {
679
- return;
1178
+ return undefined;
680
1179
  }
681
1180
 
682
- // This bounded kernel deliberately retains only the canonical two direct
683
- // child proofs. Durable storage of every authenticated fork observation is
684
- // an outer-layer responsibility for a later integration slice.
1181
+ // This bounded kernel retains only the canonical two direct child proofs.
1182
+ // Return this already-authenticated observation so the durable outer layer
1183
+ // can retain every proof without verifying it twice.
1184
+ const observation = forkProofFromSnapshot(snapshot);
685
1185
  this.retainCanonicalForkChild(snapshot);
1186
+ return observation;
686
1187
  }
687
1188
 
688
1189
  private addPending(
@@ -735,13 +1236,16 @@ export class TrustedNetworkV2PolicyReducer {
735
1236
  const retention = this.addPending(snapshot, evaluation.digest);
736
1237
  this.unavailable = {
737
1238
  acceptedAncestorDigest: copyBytes(evaluation.digest),
738
- candidateDigestKey: snapshot.digestKey,
1239
+ comparisonCandidate: copySnapshot(snapshot),
739
1240
  reason: boundedUnavailableReason(evaluation.reason),
740
1241
  };
741
1242
  return retention;
742
1243
  }
743
1244
 
744
1245
  private async drainPending(): Promise<PendingDrainOutcomeV2 | undefined> {
1246
+ if (this.lifecycleController.signal.aborted) {
1247
+ return { status: "halted" };
1248
+ }
745
1249
  let accepted = false;
746
1250
  let progress = true;
747
1251
  while (
@@ -757,6 +1261,9 @@ export class TrustedNetworkV2PolicyReducer {
757
1261
  for (const pending of ordered) {
758
1262
  if (!this.pending.has(pending.snapshot.digestKey)) continue;
759
1263
  const evaluation = await this.evaluate(pending.snapshot);
1264
+ if (this.lifecycleController.signal.aborted) {
1265
+ return { status: "halted" };
1266
+ }
760
1267
  if (evaluation.status === "missing") {
761
1268
  pending.missingParentDigest = copyBytes(evaluation.digest);
762
1269
  continue;
@@ -771,8 +1278,10 @@ export class TrustedNetworkV2PolicyReducer {
771
1278
  this.project(pending.snapshot);
772
1279
  accepted = true;
773
1280
  } else if (evaluation.status === "fork") {
774
- this.setFork(evaluation);
775
- return { status: "forked" };
1281
+ return {
1282
+ status: "forked",
1283
+ forkObservations: this.setFork(evaluation),
1284
+ };
776
1285
  }
777
1286
  }
778
1287
  }
@@ -782,7 +1291,15 @@ export class TrustedNetworkV2PolicyReducer {
782
1291
  private enqueueAdmission(
783
1292
  operation: () => Promise<PolicyAdmissionResultV2>,
784
1293
  ): Promise<PolicyAdmissionResultV2> {
785
- const result = this.admissionTail.then(operation);
1294
+ const result = this.admissionTail.then(async () => {
1295
+ if (this.lifecycleController.signal.aborted) {
1296
+ return this.haltedResult();
1297
+ }
1298
+ const admission = await operation();
1299
+ return this.lifecycleController.signal.aborted
1300
+ ? this.haltedResult()
1301
+ : admission;
1302
+ });
786
1303
  this.admissionTail = result.then(
787
1304
  (): void => {},
788
1305
  (_reason: unknown): void => {},
@@ -790,30 +1307,47 @@ export class TrustedNetworkV2PolicyReducer {
790
1307
  return result;
791
1308
  }
792
1309
 
793
- ingest(entry: unknown): Promise<PolicyAdmissionResultV2> {
794
- return this.enqueueAdmission(() => this.ingestOne(entry));
1310
+ ingest(entryBytes: Uint8Array): Promise<PolicyAdmissionResultV2> {
1311
+ if (this.lifecycleController.signal.aborted) {
1312
+ return Promise.resolve(this.haltedResult());
1313
+ }
1314
+ let capturedEntryBytes: Uint8Array;
1315
+ try {
1316
+ // Capture at the API boundary, before this admission waits behind earlier
1317
+ // work. Otherwise a caller could mutate a queued entry before validation.
1318
+ capturedEntryBytes = capturePolicySnapshotEntryBytesV2(entryBytes);
1319
+ } catch (error) {
1320
+ const reason = validationMessage(error);
1321
+ return this.enqueueAdmission(async () => this.result("rejected", reason));
1322
+ }
1323
+ return this.enqueueAdmission(() => this.ingestOne(capturedEntryBytes));
795
1324
  }
796
1325
 
797
1326
  retryUnavailable(): Promise<PolicyAdmissionResultV2> {
798
1327
  return this.enqueueAdmission(() => this.retryUnavailableOne());
799
1328
  }
800
1329
 
801
- private async ingestOne(entry: unknown): Promise<PolicyAdmissionResultV2> {
1330
+ private async ingestOne(
1331
+ entryBytes: Uint8Array,
1332
+ ): Promise<PolicyAdmissionResultV2> {
802
1333
  let snapshot: ValidatedPolicySnapshotV2;
803
1334
  try {
804
- snapshot = await authenticatePolicySnapshotEntryV2(
805
- entry,
1335
+ snapshot = await authenticateCapturedPolicySnapshotEntryV2(
1336
+ entryBytes,
806
1337
  this.descriptor,
807
1338
  );
808
1339
  } catch (error) {
809
1340
  return this.result("rejected", validationMessage(error));
810
1341
  }
1342
+ if (this.lifecycleController.signal.aborted) return this.haltedResult();
811
1343
 
812
1344
  if (this.fork !== undefined) {
813
- this.observeAfterFork(snapshot);
1345
+ const forkObservation = this.observeAfterFork(snapshot);
814
1346
  return this.result(
815
1347
  "halted",
816
1348
  "Policy reducer is halted by authority equivocation",
1349
+ undefined,
1350
+ forkObservation === undefined ? undefined : [forkObservation],
817
1351
  );
818
1352
  }
819
1353
  this.retainCanonicalHeadEntry(snapshot);
@@ -822,6 +1356,15 @@ export class TrustedNetworkV2PolicyReducer {
822
1356
  if (this.acceptedHead?.digestKey === snapshot.digestKey) {
823
1357
  return this.result("unavailable", this.unavailable.reason);
824
1358
  }
1359
+ if (
1360
+ this.unavailable.comparisonCandidate.digestKey === snapshot.digestKey &&
1361
+ compare(
1362
+ snapshot.entryBytes,
1363
+ this.unavailable.comparisonCandidate.entryBytes,
1364
+ ) < 0
1365
+ ) {
1366
+ this.unavailable.comparisonCandidate = copySnapshot(snapshot);
1367
+ }
825
1368
  const existingPending = this.pending.get(snapshot.digestKey);
826
1369
  if (existingPending !== undefined) {
827
1370
  this.addPending(snapshot, existingPending.missingParentDigest);
@@ -841,6 +1384,7 @@ export class TrustedNetworkV2PolicyReducer {
841
1384
  }
842
1385
 
843
1386
  const evaluation = await this.evaluate(snapshot);
1387
+ if (this.lifecycleController.signal.aborted) return this.haltedResult();
844
1388
  if (evaluation.status === "reject") {
845
1389
  return this.result("rejected", evaluation.reason);
846
1390
  }
@@ -848,8 +1392,7 @@ export class TrustedNetworkV2PolicyReducer {
848
1392
  return this.result("duplicate");
849
1393
  }
850
1394
  if (evaluation.status === "fork") {
851
- this.setFork(evaluation);
852
- return this.forkedResult();
1395
+ return this.forkedResult(this.setFork(evaluation));
853
1396
  }
854
1397
  if (evaluation.status === "unavailable") {
855
1398
  const retention = this.enterUnavailable(snapshot, evaluation);
@@ -860,7 +1403,7 @@ export class TrustedNetworkV2PolicyReducer {
860
1403
  return this.result(
861
1404
  pending.retained ? "pending" : "capacity",
862
1405
  pending.retained
863
- ? "Policy parent is missing"
1406
+ ? (evaluation.reason ?? "Policy parent is missing")
864
1407
  : "Policy pending capacity did not retain this candidate",
865
1408
  pending.evictedPolicyDigests,
866
1409
  );
@@ -874,6 +1417,7 @@ export class TrustedNetworkV2PolicyReducer {
874
1417
  }
875
1418
 
876
1419
  private async retryUnavailableOne(): Promise<PolicyAdmissionResultV2> {
1420
+ if (this.lifecycleController.signal.aborted) return this.haltedResult();
877
1421
  if (this.fork !== undefined) {
878
1422
  return this.result(
879
1423
  "halted",
@@ -884,7 +1428,7 @@ export class TrustedNetworkV2PolicyReducer {
884
1428
  if (unavailable === undefined) {
885
1429
  return this.result("duplicate", "Policy reducer is not unavailable");
886
1430
  }
887
- const pending = this.pending.get(unavailable.candidateDigestKey);
1431
+ const pending = this.pending.get(unavailable.comparisonCandidate.digestKey);
888
1432
  if (pending === undefined) {
889
1433
  return this.result(
890
1434
  "capacity",
@@ -893,11 +1437,12 @@ export class TrustedNetworkV2PolicyReducer {
893
1437
  }
894
1438
 
895
1439
  const evaluation = await this.evaluate(pending.snapshot);
1440
+ if (this.lifecycleController.signal.aborted) return this.haltedResult();
896
1441
  if (evaluation.status === "unavailable") {
897
1442
  pending.missingParentDigest = copyBytes(evaluation.digest);
898
1443
  this.unavailable = {
899
1444
  acceptedAncestorDigest: copyBytes(evaluation.digest),
900
- candidateDigestKey: pending.snapshot.digestKey,
1445
+ comparisonCandidate: copySnapshot(pending.snapshot),
901
1446
  reason: boundedUnavailableReason(evaluation.reason),
902
1447
  };
903
1448
  return this.result("unavailable", this.unavailable.reason);
@@ -909,12 +1454,11 @@ export class TrustedNetworkV2PolicyReducer {
909
1454
  if (evaluation.status === "missing") {
910
1455
  pending.missingParentDigest = copyBytes(evaluation.digest);
911
1456
  status = "pending";
912
- reason = "Policy parent is missing";
1457
+ reason = evaluation.reason ?? "Policy parent is missing";
913
1458
  } else {
914
1459
  this.pending.delete(pending.snapshot.digestKey);
915
1460
  if (evaluation.status === "fork") {
916
- this.setFork(evaluation);
917
- return this.forkedResult();
1461
+ return this.forkedResult(this.setFork(evaluation));
918
1462
  }
919
1463
  if (evaluation.status === "accept") {
920
1464
  this.project(pending.snapshot);