keri-ts 2.2.5 → 2.2.6

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 (47) hide show
  1. package/esm/keri/npm/src/acdc/dhking.js +29 -2
  2. package/esm/keri/npm/src/acdc/observing.js +10 -3
  3. package/esm/keri/npm/src/acdc/query-resend.js +74 -0
  4. package/esm/keri/npm/src/acdc/regengine.js +39 -9
  5. package/esm/keri/npm/src/acdc/regeventing.js +5 -3
  6. package/esm/keri/npm/src/acdc/unblinding.js +38 -3
  7. package/esm/keri/npm/src/app/delivery/forwarding.js +28 -1
  8. package/esm/keri/npm/src/app/ipex/group-issuance.js +28 -14
  9. package/esm/keri/npm/src/app/ipex/ipex-discloser.js +58 -9
  10. package/esm/keri/npm/src/app/ipex/issuance.js +16 -9
  11. package/esm/keri/npm/src/app/ipex/unblinding-workflow.js +23 -8
  12. package/esm/keri/npm/src/app/runtime/agent-runtime.js +1 -1
  13. package/esm/keri/npm/src/app/runtime/ipex-auxiliaries.js +8 -1
  14. package/esm/keri/npm/src/core/mailbox/kram-policy.js +8 -0
  15. package/esm/keri/npm/src/core/missing-anchor-query.js +20 -0
  16. package/esm/keri/npm/src/vdr/eventing.js +43 -9
  17. package/esm/keri/npm/src/version.js +2 -2
  18. package/package.json +1 -1
  19. package/types/acdc/dhking.d.ts +8 -0
  20. package/types/acdc/dhking.d.ts.map +1 -1
  21. package/types/acdc/observing.d.ts +5 -1
  22. package/types/acdc/observing.d.ts.map +1 -1
  23. package/types/acdc/query-resend.d.ts +24 -0
  24. package/types/acdc/query-resend.d.ts.map +1 -0
  25. package/types/acdc/regengine.d.ts +19 -1
  26. package/types/acdc/regengine.d.ts.map +1 -1
  27. package/types/acdc/regeventing.d.ts.map +1 -1
  28. package/types/acdc/unblinding.d.ts +13 -1
  29. package/types/acdc/unblinding.d.ts.map +1 -1
  30. package/types/app/delivery/forwarding.d.ts +21 -0
  31. package/types/app/delivery/forwarding.d.ts.map +1 -1
  32. package/types/app/ipex/group-issuance.d.ts +6 -2
  33. package/types/app/ipex/group-issuance.d.ts.map +1 -1
  34. package/types/app/ipex/ipex-discloser.d.ts +25 -0
  35. package/types/app/ipex/ipex-discloser.d.ts.map +1 -1
  36. package/types/app/ipex/issuance.d.ts +11 -5
  37. package/types/app/ipex/issuance.d.ts.map +1 -1
  38. package/types/app/ipex/unblinding-workflow.d.ts +9 -1
  39. package/types/app/ipex/unblinding-workflow.d.ts.map +1 -1
  40. package/types/app/runtime/ipex-auxiliaries.d.ts.map +1 -1
  41. package/types/core/mailbox/kram-policy.d.ts +6 -0
  42. package/types/core/mailbox/kram-policy.d.ts.map +1 -1
  43. package/types/core/missing-anchor-query.d.ts +34 -0
  44. package/types/core/missing-anchor-query.d.ts.map +1 -0
  45. package/types/vdr/eventing.d.ts +32 -1
  46. package/types/vdr/eventing.d.ts.map +1 -1
  47. package/types/version.d.ts +2 -2
@@ -17,6 +17,7 @@ import { query, reply } from "../core/protocol-eventing.js";
17
17
  import { messagize } from "../core/protocol-serialization.js";
18
18
  import { fetchStoredTsgs, unverifiedReplyDecision } from "../core/routing.js";
19
19
  import { timestampMicros } from "../time/mod.js";
20
+ import { QUERY_RESEND_MS } from "./query-resend.js";
20
21
  import { validateUpdate, verifyRegistryAnchor } from "./regeventing.js";
21
22
  import { publicOpeningAttachment } from "./registering.js";
22
23
  const domain = new TextEncoder().encode("KERI/ipex/dhke/X25519-HKDF-SHA256-Salt128/v1");
@@ -343,6 +344,8 @@ export class Dhke {
343
344
  peer: null,
344
345
  query: null,
345
346
  reply: null,
347
+ accepted: null,
348
+ repliedAt: 0,
346
349
  proposal: null,
347
350
  contribution: null,
348
351
  custody: null,
@@ -493,6 +496,12 @@ export class Dhke {
493
496
  if (own && (!op.proposal || !bytesEqual(op.proposal, serder.raw))) {
494
497
  throw new ValidationError("Own DH query lacks exact local proposal approval");
495
498
  }
499
+ // A group re-sends its frozen query unchanged (one member cannot re-date it), and KERIpy resends identical
500
+ // bytes. The wire, attachments included, is compared against the one already authenticated, so a copy is
501
+ // never trusted on its body or SAID alone.
502
+ const wire = auxiliaryQueryWire(envelope);
503
+ if (!own && op.accepted && bytesEqual(op.accepted, wire))
504
+ return this.duplicateQuery(op);
496
505
  if (!verifyQueryAttachments(this.hab.db, envelope)) {
497
506
  return { kind: "drop", reason: "missingAuthentication" };
498
507
  }
@@ -513,6 +522,7 @@ export class Dhke {
513
522
  }
514
523
  if (this.group && !this.custody(op)) {
515
524
  op.peer = { ...peer };
525
+ op.accepted = wire;
516
526
  return { kind: "accept" };
517
527
  }
518
528
  if (!op.reply) {
@@ -537,10 +547,12 @@ export class Dhke {
537
547
  }
538
548
  if (this.group) {
539
549
  this.outgoing.push({ kin: "sign", dest: this.hab.pre, msg: op.contribution.slice() });
550
+ op.accepted = wire;
540
551
  return { kind: "accept" };
541
552
  }
542
553
  }
543
- this.outgoing.push({ kin: "reply", dest: op.issuer, msg: op.reply.slice() });
554
+ this.emitReply(op);
555
+ op.accepted = wire;
544
556
  return { kind: "accept" };
545
557
  }
546
558
  catch (error) {
@@ -549,6 +561,21 @@ export class Dhke {
549
561
  throw error;
550
562
  }
551
563
  }
564
+ /**
565
+ * A copy of the accepted query: nothing is verified, derived or contributed again, so a member round that
566
+ * already holds its `sign` contribution is not asked for another. If the reply exists it is queued again for a
567
+ * peer that lost it, at most once per {@link QUERY_RESEND_MS}.
568
+ */
569
+ duplicateQuery(op) {
570
+ if (op.reply && this.now() - op.repliedAt >= QUERY_RESEND_MS)
571
+ this.emitReply(op);
572
+ return { kind: "accept" };
573
+ }
574
+ /** Queue the stored reply for the issuer and note when. */
575
+ emitReply(op) {
576
+ this.outgoing.push({ kin: "reply", dest: op.issuer, msg: op.reply.slice() });
577
+ op.repliedAt = this.now();
578
+ }
552
579
  /** Revery decides reply threshold acceptance before any issuer derivation or retention. */
553
580
  processReply({ serder, diger, route, cigars = [], tsgs = [] }) {
554
581
  try {
@@ -574,7 +601,7 @@ export class Dhke {
574
601
  });
575
602
  }
576
603
  if (this.custody(op))
577
- this.outgoing.push({ kin: "reply", dest: op.issuer, msg: op.reply.slice() });
604
+ this.emitReply(op);
578
605
  }
579
606
  else if (this.custody(op) && !op.completed)
580
607
  this.retain(op, peer, this.derive(op, peer, "issuer"));
@@ -11,9 +11,10 @@ import { publicOpeningAttachment } from "./registering.js";
11
11
  * event commits independently; a later failure preserves the already accepted prefix.
12
12
  * Parsed evidence stays borrowed. Composition supplies this owner to v2 protocol dispatch;
13
13
  * no local habitat, alias or application disclosure custody is created by public intake.
14
+ * `cues` receives the issuer KEL `logs` query of each event that first waits for its anchoring KEL event.
14
15
  */
15
16
  export class RegistryObserver {
16
- constructor(db, store) {
17
+ constructor(db, store, cues = null) {
17
18
  Object.defineProperty(this, "db", {
18
19
  enumerable: true,
19
20
  configurable: true,
@@ -26,6 +27,12 @@ export class RegistryObserver {
26
27
  writable: true,
27
28
  value: store
28
29
  });
30
+ Object.defineProperty(this, "cues", {
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ value: cues
35
+ });
29
36
  }
30
37
  /** Stage parsed events before inception, otherwise delegate ordinary validation and acceptance. */
31
38
  processEvent(serder, locators = {}) {
@@ -62,7 +69,7 @@ export class RegistryObserver {
62
69
  if (event.ilk === Ilks.rip) {
63
70
  if (!event.issuer)
64
71
  return { kind: "reject", reason: "issuer" };
65
- engine ??= new RegistryEventer(this.db, event.issuer, this.store, event.regid);
72
+ engine ??= new RegistryEventer(this.db, event.issuer, this.store, event.regid, this.cues);
66
73
  }
67
74
  if (engine) {
68
75
  const decision = engine.processEvent(serder, locators);
@@ -124,6 +131,6 @@ export class RegistryObserver {
124
131
  if (result.kind !== "valid" || result.event.regid !== regid || !result.event.issuer) {
125
132
  throw new Error("Corrupt observer registry inception.");
126
133
  }
127
- return new RegistryEventer(this.db, result.event.issuer, this.store, regid);
134
+ return new RegistryEventer(this.db, result.event.issuer, this.store, regid, this.cues);
128
135
  }
129
136
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Resend schedule for a group's threshold DH or Unblind query.
3
+ *
4
+ * A single signer re-dates every resend (`Dhke.requery`, `Unblind.requery`), so a resend is a new query the peer's
5
+ * KRAM accepts. A group cannot: re-dating needs the members' signatures, so the frozen query is resent as the same
6
+ * bytes. The peer's KRAM drops identical copies cheaply only while its replay cache row lives, and once the query's
7
+ * `dt` is older than the multi-key acceptance window it refuses every copy as untimely. This schedule therefore
8
+ * backs off and stops resending an unchanged query when the peer can no longer accept it. It never ends the
9
+ * operation: the owner keeps waiting until its own deadline, exactly as before.
10
+ */
11
+ import { parseBytes } from "cesr-ts";
12
+ import { ValidationError } from "../core/errors.js";
13
+ import { timestampMicros } from "../time/mod.js";
14
+ /** Milliseconds between the first resends of a query, and the shortest gap a receiver re-sends a stored reply. */
15
+ export const QUERY_RESEND_MS = 1000;
16
+ /** The longest gap the backoff reaches, in milliseconds. */
17
+ export const QUERY_RESEND_CAP_MS = 8000;
18
+ /** One group operation's frozen threshold query and when it may next be sent; memory only. */
19
+ export class ThresholdQueryResend {
20
+ constructor() {
21
+ /** The frozen signed query, unchanged on every resend; absent until the members' contributions complete it. */
22
+ Object.defineProperty(this, "query", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ Object.defineProperty(this, "queryAt", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: 0
33
+ });
34
+ Object.defineProperty(this, "next", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: 0
39
+ });
40
+ Object.defineProperty(this, "wait", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: 0
45
+ });
46
+ }
47
+ /** Take the frozen query and restart the schedule; its own `dt` is what the acceptance window is measured from. */
48
+ set(query) {
49
+ const body = parseBytes(query)[0]?.body;
50
+ const micros = timestampMicros(body?.sad?.dt);
51
+ if (micros === null)
52
+ throw new ValidationError("Threshold query has no valid date");
53
+ this.query = query;
54
+ this.queryAt = Number(micros / 1000n);
55
+ this.next = 0;
56
+ this.wait = 0;
57
+ }
58
+ /** Whether a query is frozen and the running gap has elapsed at `now` (epoch ms). */
59
+ ready(now) {
60
+ return !!this.query && now >= this.next;
61
+ }
62
+ /**
63
+ * Whether the query is still younger than `windowMs` at `now`; past it a receiver can no longer accept the
64
+ * unchanged query, so it is not sent again. A null window (this store never ran KRAM) never closes.
65
+ */
66
+ fresh(now, windowMs) {
67
+ return windowMs === null || now - this.queryAt < windowMs;
68
+ }
69
+ /** Record a send at `now`: the next gap doubles from `baseMs` up to {@link QUERY_RESEND_CAP_MS}. */
70
+ sent(now, baseMs) {
71
+ this.wait = this.wait ? Math.max(baseMs, Math.min(this.wait * 2, QUERY_RESEND_CAP_MS)) : baseMs;
72
+ this.next = now + this.wait;
73
+ }
74
+ }
@@ -2,14 +2,18 @@
2
2
  import { Diger, Ilks, NumberPrimitive, } from "cesr-ts";
3
3
  import { DatabaseNotOpenError, DatabaseOperationError } from "../core/errors.js";
4
4
  import { consoleLogger } from "../core/logger.js";
5
+ import { missingAnchorCue, missingAnchorQuery } from "../core/missing-anchor-query.js";
5
6
  import { findRegistryAnchor, validateRegistrySourceLocators, validateRip, validateUpdate, verifyRegistryAnchor, } from "./regeventing.js";
6
7
  /**
7
8
  * Borrow the issuer's accepted KEL and registry store; no keys or handles are acquired.
8
9
  * The local facade and foreign observer share this accepted-state engine.
9
10
  * Publication is atomic; binding this instance happens only after successful commit.
11
+ * An event that first enters the anchor escrow publishes one issuer KEL `logs` query to `cues` after that write.
10
12
  */
11
13
  export class RegistryEventer {
12
- constructor(db, issuer, store, regk = null) {
14
+ constructor(db, issuer, store, regk = null,
15
+ /** Application cue flow for missing-anchor queries; null for a local issuer, whose own KEL holds every anchor. */
16
+ cues = null) {
13
17
  Object.defineProperty(this, "db", {
14
18
  enumerable: true,
15
19
  configurable: true,
@@ -34,6 +38,12 @@ export class RegistryEventer {
34
38
  writable: true,
35
39
  value: regk
36
40
  });
41
+ Object.defineProperty(this, "cues", {
42
+ enumerable: true,
43
+ configurable: true,
44
+ writable: true,
45
+ value: cues
46
+ });
37
47
  }
38
48
  /** Validated accepted head metadata; local construction may inspect it without duplicating acceptance checks. */
39
49
  get acceptedHeadEvent() {
@@ -234,6 +244,9 @@ export class RegistryEventer {
234
244
  const decision = this.processOne(serder, locators);
235
245
  if (decision.kind === "accept")
236
246
  this.processQueued();
247
+ // The escrow row is committed; ask the issuer's KEL holders for what the anchor needs through the ordinary cue path.
248
+ if (decision.kind === "escrow" && decision.query)
249
+ this.cues?.push(missingAnchorCue(decision.query));
237
250
  return decision;
238
251
  }
239
252
  /** Decide and persist one event; callers own subsequent chain progression. */
@@ -269,10 +282,8 @@ export class RegistryEventer {
269
282
  return previous;
270
283
  this.store.putEvent(serder);
271
284
  this.discoverAnchor(event);
272
- if (!this.store.anchor(event.said)) {
273
- this.store.escrowMissingAnchor(event.regid, Number(event.sn), event.said);
274
- return { kind: "escrow", reason: "anchor", event };
275
- }
285
+ if (!this.store.anchor(event.said))
286
+ return this.escrowAnchorless(event, locators);
276
287
  this.store.baser.maes.rem(event.regid, Number(event.sn), [event.said]);
277
288
  if (!prior) {
278
289
  this.store.escrowOutOfOrder(event.regid, Number(event.sn), event.said);
@@ -314,10 +325,8 @@ export class RegistryEventer {
314
325
  throw new Error("Registry head exists without its accepted inception.");
315
326
  this.store.putEvent(serder);
316
327
  this.discoverAnchor(event);
317
- if (!this.store.anchor(event.said)) {
318
- this.store.escrowMissingAnchor(event.regid, 0, event.said);
319
- return { kind: "escrow", reason: "anchor", event };
320
- }
328
+ if (!this.store.anchor(event.said))
329
+ return this.escrowAnchorless(event, locators);
321
330
  this.commit(event);
322
331
  return { kind: "accept", event };
323
332
  });
@@ -325,6 +334,27 @@ export class RegistryEventer {
325
334
  this.regk = event.regid;
326
335
  return decision;
327
336
  }
337
+ /**
338
+ * Retain an event whose anchoring issuer KEL event is not accepted yet and decide whether to ask for it.
339
+ *
340
+ * The decision names the missing KEL only when this call put the event into the anchor escrow; retries and repeat
341
+ * arrivals find the row present. A source seal naming this issuer supplies the anchor's sequence number, and the
342
+ * highest one bounds the replay; without one the query names only where the recipient's KEL ends.
343
+ */
344
+ escrowAnchorless(event, locators) {
345
+ const added = this.store.escrowMissingAnchor(event.regid, Number(event.sn), event.said);
346
+ const decision = { kind: "escrow", reason: "anchor", event };
347
+ if (!added)
348
+ return decision;
349
+ const seals = [
350
+ ...(locators.sscs ?? []).map((seal) => seal.s.num),
351
+ ...(locators.ssts ?? []).filter((seal) => seal.i.qb64 === this.issuer).map((seal) => seal.s.num),
352
+ ];
353
+ const anchor = seals.length ? seals.reduce((high, sn) => (sn > high ? sn : high)) : null;
354
+ const accepted = this.db.getKever(this.issuer, { refresh: true })?.sner.num ?? null;
355
+ const query = missingAnchorQuery(this.issuer, accepted, anchor);
356
+ return query ? { ...decision, query } : decision;
357
+ }
328
358
  /** Replay is read-only; first acceptance repeats this check under its write transaction. */
329
359
  acceptedEvent(event) {
330
360
  const existing = this.acceptedAt(event.regid, Number(event.sn));
@@ -150,8 +150,9 @@ function sealDigests(event) {
150
150
  export function findRegistryAnchor(db, issuer, registry) {
151
151
  let earliest = null;
152
152
  for (const event of currentRegistryBranch(db, issuer)) {
153
- if (db.fullyWitnessed(event)
154
- && event.seals.some((seal) => matchesRegistrySeal(seal, registry))) {
153
+ // Seal test first: fullyWitnessed walks back to the governing establishment, so it runs only on matches.
154
+ if (event.seals.some((seal) => matchesRegistrySeal(seal, registry))
155
+ && db.fullyWitnessed(event)) {
155
156
  earliest = [event.sn, event.said];
156
157
  }
157
158
  }
@@ -161,7 +162,8 @@ export function findRegistryAnchor(db, issuer, registry) {
161
162
  function findDigestAnchor(db, issuer, digest) {
162
163
  let earliest = null;
163
164
  for (const event of currentRegistryBranch(db, issuer)) {
164
- if (db.fullyWitnessed(event) && sealDigests(event).includes(digest)) {
165
+ // Seal test first: fullyWitnessed walks back to the governing establishment, so it runs only on matches.
166
+ if (sealDigests(event).includes(digest) && db.fullyWitnessed(event)) {
165
167
  earliest = [event.sn, event.said];
166
168
  }
167
169
  }
@@ -25,6 +25,7 @@ import { query, reply } from "../core/protocol-eventing.js";
25
25
  import { messagize } from "../core/protocol-serialization.js";
26
26
  import { fetchStoredTsgs, unverifiedReplyDecision } from "../core/routing.js";
27
27
  import { prepareNests, publishNests } from "../db/exchange/exchange-nests.js";
28
+ import { QUERY_RESEND_MS } from "./query-resend.js";
28
29
  import { validateUpdate, verifyRegistryAnchor, vet, vetBlind, vetBound, } from "./regeventing.js";
29
30
  import { publicOpeningAttachment } from "./registering.js";
30
31
  /** Ordinary registry states an Unblind operation may reveal unless its approval names others. */
@@ -89,7 +90,7 @@ const KEL_ASK_MS = 5000;
89
90
  */
90
91
  export class Unblind {
91
92
  /** Bind the exact Grant verifier's store; the state vocabulary is chosen per approved operation. */
92
- constructor({ hab, exc, registry, rvy = hab.rvy, kram = true }) {
93
+ constructor({ hab, exc, registry, rvy = hab.rvy, now = Date.now, kram = true }) {
93
94
  Object.defineProperty(this, "resource", {
94
95
  enumerable: true,
95
96
  configurable: true,
@@ -162,7 +163,14 @@ export class Unblind {
162
163
  writable: true,
163
164
  value: void 0
164
165
  });
166
+ Object.defineProperty(this, "now", {
167
+ enumerable: true,
168
+ configurable: true,
169
+ writable: true,
170
+ value: void 0
171
+ });
165
172
  this.kram = kram;
173
+ this.now = now;
166
174
  if (!(hab instanceof Hab) || hab.db !== exc.hby.db || registry.db !== hab.db || rvy.db !== hab.db
167
175
  || exc.hby.habs.get(hab.pre) !== hab) {
168
176
  throw new ValidationError("Unblind requires a local signing habitat");
@@ -228,6 +236,8 @@ export class Unblind {
228
236
  scope: new Map([...context.registries].map(([regid, nodes]) => [regid, [...nodes]])),
229
237
  query: null,
230
238
  reply: null,
239
+ accepted: null,
240
+ repliedAt: 0,
231
241
  requested: null,
232
242
  selected: null,
233
243
  openings: new Map(),
@@ -389,6 +399,11 @@ export class Unblind {
389
399
  if (own && (!op.proposal || !bytesEqual(op.proposal, serder.raw))) {
390
400
  throw new ValidationError("Own Unblind query lacks its exact locally approved proposal");
391
401
  }
402
+ // A group re-sends its frozen query unchanged (one member cannot re-date it), and KERIpy resends identical
403
+ // bytes. The wire, attachments and evidence included, is compared against the one already authenticated, so
404
+ // a copy is never trusted on its body or SAID alone.
405
+ if (!own && op.accepted && bytesEqual(op.accepted, this.queryWire(envelope)))
406
+ return this.duplicateQuery(op);
392
407
  if (!verifyQueryAttachments(this.hab.db, envelope))
393
408
  return { kind: "drop", reason: "missingAuthentication" };
394
409
  if (op.requested && !sameSelection(op.requested, selected)) {
@@ -429,9 +444,11 @@ export class Unblind {
429
444
  op.requestedByPeer = true;
430
445
  // KRAM now holds this query as seen, so a resend of it is a replay and a group cannot re-date it: the
431
446
  // owed reply must outlive this owner. The accepted request is the durable record; the operation is a cache.
432
- this.hab.db.heldu.pin([this.hab.pre, op.xid], auxiliaryQueryWire(envelope, this.publicEvents(envelope.nests)));
447
+ const wire = this.queryWire(envelope);
448
+ this.hab.db.heldu.pin([this.hab.pre, op.xid], wire);
433
449
  if (!this.group)
434
450
  this.respond(op.xid);
451
+ op.accepted = wire;
435
452
  return { kind: "accept" };
436
453
  }
437
454
  catch (error) {
@@ -533,9 +550,27 @@ export class Unblind {
533
550
  }
534
551
  op.reply = signed;
535
552
  }
536
- this.outgoing.push({ kin: "reply", dest: op.recipient, msg: op.reply.slice() });
553
+ this.emitReply(op);
537
554
  return op.reply.slice();
538
555
  }
556
+ /** The query as held and compared: its frame, every attachment group, the public evidence and KRAM's normalized proof. */
557
+ queryWire(envelope) {
558
+ return auxiliaryQueryWire(envelope, this.publicEvents(envelope.nests));
559
+ }
560
+ /**
561
+ * A copy of the accepted query: nothing is verified or derived again. If the reply exists it is queued again for
562
+ * a peer that lost it, at most once per {@link QUERY_RESEND_MS}.
563
+ */
564
+ duplicateQuery(op) {
565
+ if (op.reply && this.now() - op.repliedAt >= QUERY_RESEND_MS)
566
+ this.emitReply(op);
567
+ return { kind: "accept" };
568
+ }
569
+ /** Queue the stored reply for the recipient and note when. */
570
+ emitReply(op) {
571
+ this.outgoing.push({ kin: "reply", dest: op.recipient, msg: op.reply.slice() });
572
+ op.repliedAt = this.now();
573
+ }
539
574
  /** Independently vet openings before caching them; Revery owns signature threshold and BADA. */
540
575
  processReply({ serder, diger, route, cigars = [], tsgs = [], attachments }) {
541
576
  try {
@@ -5,6 +5,7 @@ import { Deck } from "../../core/deck.js";
5
5
  import { ValidationError } from "../../core/errors.js";
6
6
  import { exchange } from "../../core/exchange/protocol-exchanging.js";
7
7
  import { Roles } from "../../core/roles.js";
8
+ import { holdsHeads, kelHeads } from "../mailbox/kel-publishing.js";
8
9
  import { mailboxTopicKey } from "../mailbox/mailboxing.js";
9
10
  import { transportMailboxTopic } from "../mailbox/topic-routing.js";
10
11
  import { localGroupMember } from "../multisig/group-members.js";
@@ -690,6 +691,32 @@ export class Poster {
690
691
  }
691
692
  return remoteCesrTarget(endpoint, evt.msg);
692
693
  }
694
+ /**
695
+ * Whether the configured relay is already known to hold what it needs to verify `signer`'s `/fwd` wrappers, so
696
+ * the whole-KEL introduction can be left out.
697
+ *
698
+ * The relay verifies a wrapper against the signer's current key state, which the signer's latest establishment
699
+ * event fixes; interaction events do not change it. The evidence is `Baser.kpub`, the record the KEL publisher
700
+ * keeps per (identifier, mailbox) of the establishment event whose KEL (delegation chain included, and only once a
701
+ * delegated establishment is anchored) the mailbox was last given. The relay is the signer's mailbox, so the
702
+ * publisher and the mailbox admin request keep that record. Any other endpoint, and any mismatch or missing
703
+ * record, keeps today's full introduction.
704
+ *
705
+ * Known limit: a 2xx is not acceptance, and a relay that lost its store answers a `/fwd` from a sender it no
706
+ * longer knows with the same 204 acknowledgement and drops it. Nothing in the `/fwd` path signals that today; the
707
+ * sender's next mailbox poll gets the relay's 403, which makes `KelPublisher` clear the record and repost the KEL.
708
+ * Wrappers sent in between are lost until then.
709
+ *
710
+ * @param signer Local habitat that signs the wrapper (a group's local member).
711
+ * @param eid Endpoint AID the prepared wire goes to.
712
+ * @returns True only when `eid` is the relay and the record holds `signer`'s current establishment heads.
713
+ */
714
+ relayHoldsKel(signer, eid) {
715
+ if (!this.relay || this.relay.aid !== eid || !signer.pre)
716
+ return false;
717
+ const heads = kelHeads(this.hby, signer.pre);
718
+ return !!heads && holdsHeads(this.hby.db.kpub.get([signer.pre, eid]), heads);
719
+ }
693
720
  /**
694
721
  * Prepare complete signed wire and its introduction before opening transport.
695
722
  *
@@ -726,7 +753,7 @@ export class Poster {
726
753
  const forwarded = [];
727
754
  for (const child of frames)
728
755
  forwarded.push(yield* buildForwardedDelivery(signer, { recipient, topic, child }));
729
- const intro = introduce(signer, endpoint.eid);
756
+ const intro = this.relayHoldsKel(signer, endpoint.eid) ? new Uint8Array() : introduce(signer, endpoint.eid);
730
757
  let message = concatBytes(intro, ...forwarded);
731
758
  if (evt.kin === "stream") {
732
759
  // This node's own wrappers around already-decoded children: a parse failure here is a fault.
@@ -46,10 +46,12 @@ import { call } from "effection";
46
46
  import { RegistryAuthor } from "../../acdc/authoring.js";
47
47
  import { RegistryCustody } from "../../acdc/custody.js";
48
48
  import { Dhke, grantTransactionId } from "../../acdc/dhking.js";
49
+ import { ThresholdQueryResend } from "../../acdc/query-resend.js";
49
50
  import { vet } from "../../acdc/regeventing.js";
50
51
  import { RegistryRoot } from "../../acdc/salting.js";
51
52
  import { ValidationError } from "../../core/errors.js";
52
53
  import { Kever } from "../../core/kel/kever.js";
54
+ import { multiKeyQueryWindow } from "../../core/mailbox/kram-policy.js";
53
55
  import { messagize } from "../../core/protocol-serialization.js";
54
56
  import { loadNests } from "../../db/exchange/exchange-nests.js";
55
57
  import { timestampMicros } from "../../time/mod.js";
@@ -613,17 +615,12 @@ export class GroupIssuance {
613
615
  writable: true,
614
616
  value: void 0
615
617
  });
616
- Object.defineProperty(this, "query", {
618
+ /** The frozen threshold query and its resend schedule; a group cannot re-date it (`Dhke.requery` refuses groups). */
619
+ Object.defineProperty(this, "resend", {
617
620
  enumerable: true,
618
621
  configurable: true,
619
622
  writable: true,
620
- value: void 0
621
- });
622
- Object.defineProperty(this, "nextQuery", {
623
- enumerable: true,
624
- configurable: true,
625
- writable: true,
626
- value: 0
623
+ value: new ThresholdQueryResend()
627
624
  });
628
625
  Object.defineProperty(this, "nextProofScan", {
629
626
  enumerable: true,
@@ -651,6 +648,13 @@ export class GroupIssuance {
651
648
  writable: true,
652
649
  value: void 0
653
650
  });
651
+ /** What the Grant send's proof support carried; absent when this operation did not send it (rebuilt after a restart). */
652
+ Object.defineProperty(this, "supportMark", {
653
+ enumerable: true,
654
+ configurable: true,
655
+ writable: true,
656
+ value: void 0
657
+ });
654
658
  Object.defineProperty(this, "mode", {
655
659
  enumerable: true,
656
660
  configurable: true,
@@ -886,7 +890,9 @@ export function* stepGroup(ops, op) {
886
890
  const input = deps.exc.retainedInput(grant, { pending: true });
887
891
  const frame = parseBytes(messagize(input.serder, { tsgs: input.tsgs, cigars: input.cigars, gvrsn: Vrsn_2_0 }))[0];
888
892
  frame.nests = [...input.nests ?? []];
889
- const full = concatBytes(ops.discloser.proofSupport(g.nodeBytes, hab), yield* wire(frame));
893
+ const support = ops.discloser.proofSupport(g.nodeBytes, hab);
894
+ g.supportMark = ops.discloser.proofSupportMark(g.nodeBytes, hab);
895
+ const full = concatBytes(support, yield* wire(frame));
890
896
  for (const peer of party.others())
891
897
  yield* send(peer, full);
892
898
  if (issuer) {
@@ -940,13 +946,17 @@ export function* stepGroup(ops, op) {
940
946
  yield* broadcast(yield* party.wrap(child, MULTISIG_RPY_ROUTE));
941
947
  }
942
948
  else if (message.kin === "query")
943
- g.query = message.msg;
949
+ g.resend.set(message.msg);
944
950
  else
945
951
  yield* send(message.dest, message.msg, "credential");
946
952
  }
947
- if (g.query && deps.clock.now() >= g.nextQuery && !dh.complete(xid)) {
948
- yield* send(g.grant.sad.ri, g.query, "credential");
949
- g.nextQuery = deps.clock.now() + (deps.queryResendMs ?? ISSUANCE_QUERY_RESEND_MS);
953
+ // The unchanged query is resent with backoff while the holder can still accept it; the operation keeps waiting
954
+ // for the holder past that, to its own deadline.
955
+ const now = deps.clock.now(), base = deps.queryResendMs ?? ISSUANCE_QUERY_RESEND_MS;
956
+ if (g.resend.ready(now) && g.resend.fresh(now, multiKeyQueryWindow(deps.hby.db, Dhke.Route))
957
+ && !dh.complete(xid)) {
958
+ yield* send(g.grant.sad.ri, g.resend.query, "credential");
959
+ g.resend.sent(now, base);
950
960
  }
951
961
  if (!lead || !dh.complete(xid))
952
962
  return false;
@@ -980,7 +990,11 @@ export function* stepGroup(ops, op) {
980
990
  if (!deps.exc.complete(grant))
981
991
  throw new ValidationError("Issued group Grant remains unaccepted");
982
992
  const completed = parseBytes(yield* storedBytes(deps.exc, grant))[0];
983
- const support = ops.discloser.proofSupport(g.nodeBytes, hab);
993
+ // The Grant send already carried the support; proving added the anchoring event and the registry update. An
994
+ // operation without a mark did not send it, so it sends in full.
995
+ const support = g.supportMark
996
+ ? ops.discloser.proofSupportSince(g.nodeBytes, hab, g.supportMark)
997
+ : ops.discloser.proofSupport(g.nodeBytes, hab);
984
998
  // KERIpy form: the elected member shares the proof with the other members by mailbox.
985
999
  const wrapped = yield* party.wrap(completed, MULTISIG_IPEX_PROOF_ROUTE);
986
1000
  // Kept locally, so the proof the members received is the one {@link groupIssuanceProof} exports.
@@ -235,6 +235,56 @@ export class IpexDiscloser {
235
235
  * name. Reads accepted state only and signs nothing, so it needs no keys.
236
236
  */
237
237
  proofSupportFor(raw, parties) {
238
+ const { ordered, registries } = this.supportSelection(raw, parties);
239
+ const messages = [Counter.makeGVC(Vrsn_2_0)];
240
+ // Keep first-seen authorizers and exact stored attachments, including superseded history.
241
+ // clonePreIter skips broken rows; finite disclosure must instead fail if an accepted row cannot be replayed.
242
+ for (const aid of ordered)
243
+ messages.push(...this.supportKel(aid, 0));
244
+ for (const regid of registries)
245
+ messages.push(this.registry.cloneTel(regid));
246
+ return concatBytes(...messages);
247
+ }
248
+ /**
249
+ * Record how far `proofSupport(raw, hab)` reaches right now: each KEL's last first-seen ordinal and each
250
+ * registry's last TEL ordinal. Call it in the same synchronous turn as the send it describes, so the mark is
251
+ * exactly what that send carried; {@link proofSupportSince} then sends what accepted state has gained.
252
+ */
253
+ proofSupportMark(raw, hab) {
254
+ const { ordered, registries } = this.supportSelection(raw, [hab.pre]);
255
+ const kels = new Map(), tels = new Map();
256
+ for (const aid of ordered) {
257
+ let last = -1;
258
+ for (const [, fn] of this.hby.db.fels.getAllItemIter(aid))
259
+ last = fn;
260
+ kels.set(aid, last);
261
+ }
262
+ for (const regid of registries) {
263
+ let last = -1;
264
+ for (const [, sn] of this.registry.baser.tels.getAllItemIter(regid))
265
+ last = sn;
266
+ tels.set(regid, last);
267
+ }
268
+ return { kels, tels };
269
+ }
270
+ /**
271
+ * The part of `proofSupport(raw, hab)` that accepted state gained after `mark` was taken, in the same order and
272
+ * with the same stored attachments: later first-seen KEL events of each identifier (the anchoring interaction
273
+ * event of a registry update, say) and later TEL events of each registry. An identifier or registry the mark does
274
+ * not name (support that grew a new party) is carried whole. A recipient that holds what `mark` describes holds
275
+ * the same state after this as after a second full `proofSupport`.
276
+ */
277
+ proofSupportSince(raw, hab, mark) {
278
+ const { ordered, registries } = this.supportSelection(raw, [hab.pre]);
279
+ const messages = [Counter.makeGVC(Vrsn_2_0)];
280
+ for (const aid of ordered)
281
+ messages.push(...this.supportKel(aid, (mark.kels.get(aid) ?? -1) + 1));
282
+ for (const regid of registries)
283
+ messages.push(this.registry.cloneTel(regid, (mark.tels.get(regid) ?? -1) + 1));
284
+ return concatBytes(...messages);
285
+ }
286
+ /** The identifiers (each after its delegation ancestry) and registries that support for `raw` and `parties` names. */
287
+ supportSelection(raw, parties) {
238
288
  const aids = new Set(parties), registries = new Set();
239
289
  for (const frame of parseBytes(raw)) {
240
290
  if (!(frame.body instanceof SerderACDC))
@@ -256,7 +306,6 @@ export class IpexDiscloser {
256
306
  aids.add(seal.i.qb64);
257
307
  }
258
308
  }
259
- const messages = [Counter.makeGVC(Vrsn_2_0)];
260
309
  const db = this.hby.db, ordered = new Set();
261
310
  // An ancestry claim selects supporting KELs; it does not establish delegation authority.
262
311
  for (const aid of aids) {
@@ -275,15 +324,15 @@ export class IpexDiscloser {
275
324
  for (const ancestor of ancestors.reverse())
276
325
  ordered.add(ancestor);
277
326
  }
278
- // Keep first-seen authorizers and exact stored attachments, including superseded history.
279
- // clonePreIter skips broken rows; finite disclosure must instead fail if an accepted row cannot be replayed.
280
- for (const aid of ordered) {
281
- for (const [, fn, said] of db.fels.getAllItemIter(aid))
282
- messages.push(db.cloneEvtMsg(aid, fn, said, Vrsn_2_0));
327
+ return { ordered, registries };
328
+ }
329
+ /** Stored first-seen events of `aid` from ordinal `from`, each with its exact stored attachments. */
330
+ supportKel(aid, from) {
331
+ const messages = [];
332
+ for (const [, fn, said] of this.hby.db.fels.getAllItemIter(aid, from)) {
333
+ messages.push(this.hby.db.cloneEvtMsg(aid, fn, said, Vrsn_2_0));
283
334
  }
284
- for (const regid of registries)
285
- messages.push(this.registry.cloneTel(regid));
286
- return concatBytes(...messages);
335
+ return messages;
287
336
  }
288
337
  /** Public replay includes actual stored witness receipts; it grants no new consent. */
289
338
  kel(hab) {