keri-ts 2.2.0 → 2.2.1

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 (44) hide show
  1. package/esm/keri/npm/src/acdc/custody.js +23 -0
  2. package/esm/keri/npm/src/app/ipex/group-ipex.js +14 -0
  3. package/esm/keri/npm/src/app/ipex/group-issuance.js +6 -3
  4. package/esm/keri/npm/src/app/ipex/ipex-grouping.js +16 -8
  5. package/esm/keri/npm/src/app/ipex/ipex-node-authentication.js +42 -18
  6. package/esm/keri/npm/src/app/ipex/issuance.js +9 -1
  7. package/esm/keri/npm/src/app/ipex/presenting.js +8 -1
  8. package/esm/keri/npm/src/app/ipex/unblinding-workflow.js +8 -1
  9. package/esm/keri/npm/src/app/multisig/counseling.js +7 -1
  10. package/esm/keri/npm/src/app/multisig/multisig-messaging.js +33 -6
  11. package/esm/keri/npm/src/app/multisig/multisig-workflows.js +5 -9
  12. package/esm/keri/npm/src/cli/credentials/ipex.js +7 -1
  13. package/esm/keri/npm/src/cli/messaging/group-join.js +7 -3
  14. package/esm/keri/npm/src/cli/messaging/multisig.js +37 -11
  15. package/esm/keri/npm/src/runtime/index.js +1 -1
  16. package/esm/keri/npm/src/version.js +2 -2
  17. package/package.json +1 -1
  18. package/types/acdc/custody.d.ts +7 -0
  19. package/types/acdc/custody.d.ts.map +1 -1
  20. package/types/app/ipex/group-ipex.d.ts +7 -0
  21. package/types/app/ipex/group-ipex.d.ts.map +1 -1
  22. package/types/app/ipex/group-issuance.d.ts +1 -1
  23. package/types/app/ipex/group-issuance.d.ts.map +1 -1
  24. package/types/app/ipex/ipex-grouping.d.ts.map +1 -1
  25. package/types/app/ipex/ipex-node-authentication.d.ts +9 -1
  26. package/types/app/ipex/ipex-node-authentication.d.ts.map +1 -1
  27. package/types/app/ipex/issuance.d.ts +2 -0
  28. package/types/app/ipex/issuance.d.ts.map +1 -1
  29. package/types/app/ipex/presenting.d.ts +2 -0
  30. package/types/app/ipex/presenting.d.ts.map +1 -1
  31. package/types/app/ipex/unblinding-workflow.d.ts +2 -0
  32. package/types/app/ipex/unblinding-workflow.d.ts.map +1 -1
  33. package/types/app/multisig/counseling.d.ts.map +1 -1
  34. package/types/app/multisig/multisig-messaging.d.ts +23 -6
  35. package/types/app/multisig/multisig-messaging.d.ts.map +1 -1
  36. package/types/app/multisig/multisig-workflows.d.ts.map +1 -1
  37. package/types/cli/credentials/ipex.d.ts.map +1 -1
  38. package/types/cli/messaging/group-join.d.ts +4 -1
  39. package/types/cli/messaging/group-join.d.ts.map +1 -1
  40. package/types/cli/messaging/multisig.d.ts +1 -0
  41. package/types/cli/messaging/multisig.d.ts.map +1 -1
  42. package/types/runtime/index.d.ts +2 -2
  43. package/types/runtime/index.d.ts.map +1 -1
  44. package/types/version.d.ts +2 -2
@@ -135,6 +135,29 @@ export class RegistryCustody {
135
135
  this.options.store.pin(event.said, value);
136
136
  return value;
137
137
  }
138
+ /**
139
+ * The opening of the accepted update that issued `credential`, for a holder of the registry root that did not
140
+ * author the update and so retained no coordinates (a group member that consented to it). The opening is derived
141
+ * from the credential and the `issued` state and must open the update's own commitment; it is then retained like
142
+ * an authored one. Only an unbound issuance update is derived here.
143
+ */
144
+ issuedOpening(event, credential) {
145
+ if (this.options.store.get(event.said))
146
+ return this.derivedOpening(event);
147
+ if (event.ilk !== "bup" || !event.verify())
148
+ throw new ValidationError("Exact accepted blinded update is required");
149
+ const ordinal = Number(BigInt(`0x${event.sad.n}`));
150
+ const stored = event.regid ? this.options.registry.seqEvent(event.regid, ordinal) : null;
151
+ if (!Number.isSafeInteger(ordinal) || !stored || !bytesEqual(stored.raw, event.raw)) {
152
+ throw new ValidationError("Exact accepted registry event is required");
153
+ }
154
+ const opening = this.derive(event.regid, ordinal, credential, "issued");
155
+ if (!vetBlind(opening, event.sad.b)) {
156
+ throw new ValidationError("The accepted update is not this credential's issuance");
157
+ }
158
+ this.retainDerivedUpdate(event, opening);
159
+ return opening;
160
+ }
138
161
  /** Reproduce only the exact accepted event, never today's head or a later credential's coordinates. */
139
162
  derivedOpening(event) {
140
163
  if (event.ilk !== "bup" || !event.verify())
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { concatBytes, Counter, Vrsn_2_0 } from "cesr-ts";
25
25
  import { ValidationError } from "../../core/errors.js";
26
+ import { loadNests } from "../../db/exchange/exchange-nests.js";
26
27
  import { registryEventStatus, settleRegistryEvent } from "../credentials/registry-settling.js";
27
28
  import { isLocalGroupHab } from "../multisig/group-members.js";
28
29
  import { MULTISIG_EXN_ROUTE } from "../multisig/grouping.js";
@@ -30,6 +31,8 @@ import { RetryBackoff } from "../runtime/retry-backoff.js";
30
31
  import { observeUntil } from "../runtime/workflow-contract.js";
31
32
  import { IpexAdmitter } from "./ipex-admitting.js";
32
33
  import { approveMultisigIpexLocally, approveStoredMultisigIpexWrappers, findStoredMultisigIpex, groupIpexApproved, prepareAcdcV2GroupIpex, publishGroupIpexProposal, publishInitialGroupIpex, requireAcdcV2GroupUse, sendCompletedGroupIpex, storedMultisigIpexBody, } from "./ipex-grouping.js";
34
+ import { isIssuanceOrigin } from "./ipex-node-authentication.js";
35
+ import { IPEX_GRANT_ROUTE } from "./ipexing.js";
33
36
  /** Milliseconds between collections of the other members' stored contributions. */
34
37
  export const GROUP_IPEX_COLLECT_MS = 250;
35
38
  /** How long delivery failures may last before an operation ends `failed`; a group IPEX message has no deadline. */
@@ -186,6 +189,7 @@ export class GroupIpexOperations {
186
189
  this.outcomes.set(op.said, {
187
190
  messages: op.publication?.messages ?? [],
188
191
  deliveries: op.publication?.deliveries ?? [],
192
+ members: op.publication?.members ?? [],
189
193
  sent: op.deliveries,
190
194
  });
191
195
  this.deps.changes?.push({ kind: "ipex", id: op.said });
@@ -257,6 +261,9 @@ export function groupIpexProposals(ops, hab) {
257
261
  continue;
258
262
  if (found.some((item) => item.said === embedded.said))
259
263
  continue;
264
+ if (embedded.route === IPEX_GRANT_ROUTE
265
+ && isIssuanceOrigin(group, loadNests(hby.db, wrapper.said)[0]?.nests?.[0]))
266
+ continue;
260
267
  found.push({
261
268
  said: embedded.said,
262
269
  route: embedded.route,
@@ -319,6 +326,13 @@ export function groupIpexStatus(ops, said) {
319
326
  }
320
327
  return { state: "active", phase: "awaitingConsent", needs: "approve" };
321
328
  }
329
+ /**
330
+ * The member wrappers this process has published for one message: from the live operation once it published, from
331
+ * the outcome after it ended. Empty before publication. A pure read.
332
+ */
333
+ export function groupIpexMessages(ops, said) {
334
+ return ops.live.get(said)?.publication?.messages ?? ops.outcomes.get(said)?.messages ?? [];
335
+ }
322
336
  /** Observe until the message is accepted and delivered, or ended, or the budget expires. Starts no worker. */
323
337
  export function* waitForGroupIpex(ops, said, { timeoutMs, clock = ops.deps.clock, until = (status) => status.state !== "active" }) {
324
338
  const settled = yield* observeUntil(() => until(groupIpexStatus(ops, said)), { timeoutMs, clock });
@@ -45,10 +45,11 @@ import { timestampMicros } from "../../time/mod.js";
45
45
  import { registryEventRef, registryEventStatus, settleRegistryEvent, } from "../credentials/registry-settling.js";
46
46
  import { groupElectedMember, groupSigningMembers, isLocalGroupHab, latestGroupContribution, localGroupMember, } from "../multisig/group-members.js";
47
47
  import { MULTISIG_EXN_ROUTE, MULTISIG_IPEX_PROOF_ROUTE, MULTISIG_QRY_ROUTE, MULTISIG_RPY_ROUTE, multisigExnBody, } from "../multisig/grouping.js";
48
+ import { remoteGroupMembers } from "../multisig/multisig-messaging.js";
48
49
  import { proposeRegistryEvent } from "../multisig/proposing.js";
49
50
  import { envelopeFromFrame } from "../protocol/parsering.js";
50
51
  import { exchangeWire } from "./ipex-discloser.js";
51
- import { acceptedRegistryEvidence } from "./ipex-node-authentication.js";
52
+ import { acceptedRegistryEvidence, isIssuanceOrigin } from "./ipex-node-authentication.js";
52
53
  import { ISSUANCE_QUERY_RESEND_MS, } from "./issuance.js";
53
54
  /** Milliseconds between scans of the stored wrappers for the final proof. */
54
55
  const PROOF_SCAN_MS = 1000;
@@ -119,9 +120,9 @@ export class GroupParty {
119
120
  get lead() {
120
121
  return this.member.pre === this.custodian;
121
122
  }
122
- /** The other current signing members of the group. */
123
+ /** The other current signing members of the group that are not held in this Habery; local ones share its stores. */
123
124
  others() {
124
- return groupSigningMembers(this.hby, this.group.pre).filter((peer) => peer !== this.member.pre);
125
+ return remoteGroupMembers(this.hby, this.member, groupSigningMembers(this.hby, this.group.pre));
125
126
  }
126
127
  /** The election this party stands for. */
127
128
  election() {
@@ -269,6 +270,8 @@ export function groupIssuanceProposals(ops, group) {
269
270
  ? child.pre !== group.pre
270
271
  : !hby.habs.get(child.pre) || !isLocalGroupHab(hby, hby.habs.get(child.pre))))
271
272
  continue;
273
+ if (!isIssuanceOrigin(child.pre, loadNests(hby.db, parent.said)[0]?.nests?.[0]))
274
+ continue;
272
275
  const self = localGroupMember(hby, child.pre).pre, known = found.get(child.said);
273
276
  if (known && (parent.pre === self || known.proposer !== self))
274
277
  continue;
@@ -36,15 +36,20 @@ function acdcV2GraphUse(hby, registry, origin, nests, walk) {
36
36
  const graph = walkIpexGraph(origin, nests, walk);
37
37
  if (!graph)
38
38
  throw new ValidationError("ACDC v2 group graph is malformed or incomplete.");
39
- const verdicts = new Map();
39
+ const verdicts = new Map(), why = [];
40
40
  for (const [said, node] of graph.nodes) {
41
- verdicts.set(said, issuedNodeVerdict(authenticateIpexNode(node.body, envelopeFromFrame(node), hby.db, registry)));
41
+ const authentication = authenticateIpexNode(node.body, envelopeFromFrame(node), hby.db, registry);
42
+ verdicts.set(said, issuedNodeVerdict(authentication));
43
+ if (authentication.kind !== "authenticated")
44
+ why.push(`${said} ${authentication.kind}: ${authentication.reason}`);
42
45
  }
43
46
  const verdict = evaluateChain(graph, verdicts, hby.db);
44
47
  if (verdict?.kind === "unknown" && verdict.retryable)
45
48
  return null;
46
- if (verdict?.kind !== "valid")
47
- throw new ValidationError("ACDC v2 group graph is false or unresolved.");
49
+ if (verdict?.kind !== "valid") {
50
+ const found = !verdict ? "no verdict" : verdict.kind === "unknown" ? `unknown: ${verdict.reason}` : verdict.kind;
51
+ throw new ValidationError(`ACDC v2 group graph is false or unresolved (${found}${why.length ? `; ${why.join("; ")}` : ""}).`);
52
+ }
48
53
  return true;
49
54
  }
50
55
  /** Current retained Grant authentication and graph truth are separate from protocol completion/history. */
@@ -176,12 +181,13 @@ export function* approveMultisigIpex(hby, protocol, postman, reger, wrapper, opt
176
181
  const local = yield* approveMultisigIpexLocally(hby, protocol, reger, wrapper);
177
182
  const { embedded, groupHab, genus, approved } = local;
178
183
  const embeddedSaid = embedded.said;
179
- if (!approved)
180
- return { approved: null, accepted: protocol.exc.complete(embeddedSaid), messages: [], deliveries: [] };
184
+ if (!approved) {
185
+ return { approved: null, accepted: protocol.exc.complete(embeddedSaid), messages: [], deliveries: [], members: [] };
186
+ }
181
187
  const publication = options.publish
182
188
  ? yield* publishGroupIpexProposal(hby, protocol, postman, groupHab, approved, genus)
183
- : { messages: [], deliveries: [] };
184
- const deliveries = publication.deliveries;
189
+ : { messages: [], deliveries: [], members: [] };
190
+ const deliveries = [...publication.deliveries];
185
191
  if ((options.sendLead ?? true)
186
192
  && hby.db.exns.get([embeddedSaid])?.said === embeddedSaid
187
193
  && protocol.exc.lead(groupHab, embeddedSaid)) {
@@ -191,6 +197,7 @@ export function* approveMultisigIpex(hby, protocol, postman, reger, wrapper, opt
191
197
  accepted: hby.db.exns.get([embeddedSaid])?.said === embeddedSaid,
192
198
  messages: publication.messages,
193
199
  deliveries,
200
+ members: publication.members,
194
201
  approved,
195
202
  };
196
203
  }
@@ -430,6 +437,7 @@ export function* publishInitialGroupIpex(hby, protocol, postman, groupHab, embed
430
437
  recipient: message.recipient,
431
438
  exnSaid: message.exnSaid,
432
439
  payloadSaid: message.payloadSaid,
440
+ outcome: "delivered",
433
441
  deliveries: outcome.deliveries,
434
442
  });
435
443
  }
@@ -1,9 +1,10 @@
1
1
  /** Node issuer authentication over public KEL/TEL evidence; no conversation or validity policy. */
2
- import { Diger, NumberPrimitive, Prefixer } from "cesr-ts";
2
+ import { Diger, NumberPrimitive, Prefixer, SerderACDC } from "cesr-ts";
3
3
  import { validateRip, validateUpdate, vetBinds, vetBlind, vetBound, } from "../../acdc/regeventing.js";
4
4
  import { TransIdxSigGroup } from "../../core/dispatch.js";
5
5
  import { DatabaseOperationError } from "../../core/errors.js";
6
6
  import { verifyExchangeProofs } from "../../core/exchange/exchange-proofing.js";
7
+ import { envelopeFromFrame } from "../protocol/parsering.js";
7
8
  /** Protocol admission authenticates registry observations without imposing an application state vocabulary. */
8
9
  export function authenticatedNodeVerdict(authentication) {
9
10
  switch (authentication.kind) {
@@ -65,35 +66,57 @@ export function acceptedRegistryEvidence(store, regid) {
65
66
  }
66
67
  return { rip, updates };
67
68
  }
69
+ /**
70
+ * Whether a Grant's origin node is a fresh issuance: a credential its own sender issues, disclosed with no registry
71
+ * proof because no registry update has issued it yet. A presentation of an issued credential carries the proof
72
+ * that opens its registry update, so it never matches. Both are Grants on the wire; only a fresh one is consented to
73
+ * as an issuance. The answer is the same before and after the registry update is approved.
74
+ */
75
+ export function isIssuanceOrigin(sender, origin) {
76
+ const body = origin?.body;
77
+ if (!(body instanceof SerderACDC) || body.issuer !== sender || !body.regid)
78
+ return false;
79
+ const { bsqs, bsss } = envelopeFromFrame(origin);
80
+ return !bsqs.length && !bsss.length;
81
+ }
68
82
  /** A declared registry, then supplied anchors, take precedence over current signatures. */
69
83
  export function authenticateIpexNode(serder, envelope, db, registry, historical = false) {
70
84
  if (serder.regid) {
71
85
  const proofs = [...envelope.bsqs, ...envelope.bsss];
72
- if (!proofs.length || !registry)
73
- return { kind: "reject" };
86
+ if (!proofs.length || !registry) {
87
+ return {
88
+ kind: "reject",
89
+ reason: "The node declares a registry but carries no issuer proof or no registry store is open.",
90
+ };
91
+ }
74
92
  const chain = acceptedRegistryEvidence(registry, serder.regid);
75
93
  if (!chain)
76
94
  return { kind: "missing", reason: `Missing local TEL evidence for ${serder.regid}.` };
77
95
  // A RIP alone authenticates ownership, never a disclosed credential state.
78
- if (!chain.updates.length)
79
- return { kind: "reject" };
96
+ if (!chain.updates.length) {
97
+ return {
98
+ kind: "reject",
99
+ reason: "The registry has only its inception; a RIP alone authenticates ownership, never a disclosed credential state.",
100
+ };
101
+ }
80
102
  let at;
81
103
  if (historical) {
82
104
  if (!serder.verify() || serder.issuer !== chain.rip.issuer || serder.regid !== chain.rip.said) {
83
- return { kind: "reject" };
105
+ return { kind: "reject", reason: "The node differs from the registry inception it declares." };
84
106
  }
85
107
  // Authenticate all supplied bodies/duplicates and known bound contradictions
86
108
  // before missing evidence wins, independently of attachment order.
87
109
  if (proofs.some((proof) => !vetBlind(proof, proof.d.nonce))
88
110
  || new Set(proofs.map((proof) => proof.d.nonce)).size !== proofs.length)
89
- return { kind: "reject" };
111
+ return { kind: "reject", reason: "The disclosed issuer proofs are malformed or repeat a nonce." };
90
112
  let unresolved = false;
91
113
  for (const proof of proofs) {
92
114
  if (proof.td.nonce && proof.td.nonce !== serder.said)
93
115
  continue;
94
116
  const bound = vetBound(proof, serder, db);
95
- if (bound?.kind === "reject")
96
- return { kind: "reject" };
117
+ if (bound?.kind === "reject") {
118
+ return { kind: "reject", reason: "A disclosed issuer proof contradicts the node." };
119
+ }
97
120
  if (bound)
98
121
  unresolved = true;
99
122
  }
@@ -103,7 +126,7 @@ export function authenticateIpexNode(serder, envelope, db, registry, historical
103
126
  continue;
104
127
  const matches = chain.updates.filter((event) => event.sad.b === proof.d.nonce);
105
128
  if (matches.length > 1)
106
- return { kind: "reject" };
129
+ return { kind: "reject", reason: "Several registry updates match one issuer proof." };
107
130
  if (matches.length)
108
131
  selected.push(matches[0]);
109
132
  else
@@ -111,8 +134,9 @@ export function authenticateIpexNode(serder, envelope, db, registry, historical
111
134
  }
112
135
  if (unresolved)
113
136
  return { kind: "missing", reason: `Missing retained issuer proof evidence for ${serder.said}.` };
114
- if (!selected.length)
115
- return { kind: "reject" };
137
+ if (!selected.length) {
138
+ return { kind: "reject", reason: "No registry update matches the disclosed issuer proofs." };
139
+ }
116
140
  const candidates = new Set(selected.map((event) => event.said));
117
141
  // Accepted history is ascending. Vacuous and other-target openings cannot
118
142
  // advance this issuer prefix; generic source locators never select it.
@@ -131,7 +155,7 @@ export function authenticateIpexNode(serder, envelope, db, registry, historical
131
155
  }
132
156
  return result.kind === "verified" && result.state.regid === serder.regid
133
157
  ? { kind: "authenticated", registration: result.state }
134
- : { kind: "reject" };
158
+ : { kind: "reject", reason: "The registry update does not bind the node." };
135
159
  }
136
160
  // Disclosed inner rd without top-level rd is an unsupported hidden issuer registry.
137
161
  // Compact aggregate entries expose no declaration and remain opaque here.
@@ -139,15 +163,15 @@ export function authenticateIpexNode(serder, envelope, db, registry, historical
139
163
  const sections = [serder.sad.a, ...(Array.isArray(aggregate) ? aggregate.slice(1) : [aggregate])];
140
164
  if (sections.some(section => section && typeof section === "object" && !Array.isArray(section)
141
165
  && section.rd))
142
- return { kind: "reject" };
166
+ return { kind: "reject", reason: "A disclosed inner registry has no top-level declaration." };
143
167
  if (!serder.issuer)
144
- return { kind: "reject" };
168
+ return { kind: "reject", reason: "The node names no issuer." };
145
169
  let issuer;
146
170
  try {
147
171
  issuer = new Prefixer({ qb64: serder.issuer });
148
172
  }
149
173
  catch {
150
- return { kind: "reject" };
174
+ return { kind: "reject", reason: "The node names a malformed issuer." };
151
175
  }
152
176
  const sourceSeals = [
153
177
  ...envelope.sscs.map((seal) => ({ i: issuer, ...seal })),
@@ -159,7 +183,7 @@ export function authenticateIpexNode(serder, envelope, db, registry, historical
159
183
  ? { kind: "missing", reason: `Missing issuer KEL anchor for ${serder.said}.` }
160
184
  : result.sourceSeals.length > 0
161
185
  ? { kind: "authenticated", registration: null }
162
- : { kind: "reject" };
186
+ : { kind: "reject", reason: "No issuer KEL anchor seals the node." };
163
187
  }
164
188
  const explicit = envelope.tsgs.filter((group) => group.pre === issuer.qb64);
165
189
  const last = envelope.ssgs.filter((group) => group.pre === issuer.qb64);
@@ -181,5 +205,5 @@ export function authenticateIpexNode(serder, envelope, db, registry, historical
181
205
  ? { kind: "missing", reason: `Missing current issuer KEL evidence for ${serder.said}.` }
182
206
  : result.tsgs.length + result.cigars.length > 0
183
207
  ? { kind: "authenticated", registration: null }
184
- : { kind: "reject" };
208
+ : { kind: "reject", reason: "No current issuer signature authenticates the node." };
185
209
  }
@@ -393,6 +393,10 @@ export class IssuanceOperations {
393
393
  deps.changes?.push({ kind: "issuance", id: group.said });
394
394
  return { grant: group.said, xid };
395
395
  }
396
+ /** Whether the live operation for `grant` still holds a message the transport has not taken. */
397
+ owes(grant) {
398
+ return (this.live.get(grant)?.outbox.length ?? 0) > 0;
399
+ }
396
400
  /** One pass of the issuer loop, in the order of the CLI loop it replaces. */
397
401
  *step(op) {
398
402
  if (op.group) {
@@ -776,7 +780,11 @@ function heldRoot(deps, hab, grant) {
776
780
  * worker and drives nothing: someone else must call `processOnce`.
777
781
  */
778
782
  export function* waitForIssuance(ops, grant, { timeoutMs, clock = ops.deps.clock, until = (status) => status.state !== "active" }) {
779
- const settled = yield* observeUntil(() => until(issuanceStatus(ops, grant)), { timeoutMs, clock });
783
+ // A phase is not reached while the step that produced it still owes a delivery: the host would stop its workers first.
784
+ const settled = yield* observeUntil(() => until(issuanceStatus(ops, grant)) && !ops.owes(grant), {
785
+ timeoutMs,
786
+ clock,
787
+ });
780
788
  const status = issuanceStatus(ops, grant);
781
789
  return settled ? { outcome: "settled", status } : { outcome: "timed-out", status, timeoutMs };
782
790
  }
@@ -87,6 +87,10 @@ export class PresentationOperations {
87
87
  value: new Map()
88
88
  });
89
89
  }
90
+ /** Whether the live presentation `said` still holds a message the transport has not taken. */
91
+ owes(said) {
92
+ return (this.live.get(said)?.outbox.length ?? 0) > 0;
93
+ }
90
94
  /** Whether a delivery is live. */
91
95
  active() {
92
96
  return this.live.size > 0;
@@ -340,7 +344,10 @@ export function presentationStatus(ops, said) {
340
344
  * nothing: someone else must call `processOnce`.
341
345
  */
342
346
  export function* waitForPresentation(ops, said, { timeoutMs, clock = ops.deps.clock, until = (status) => status.state !== "active" }) {
343
- const settled = yield* observeUntil(() => until(presentationStatus(ops, said)), { timeoutMs, clock });
347
+ const settled = yield* observeUntil(() => until(presentationStatus(ops, said)) && !ops.owes(said), {
348
+ timeoutMs,
349
+ clock,
350
+ });
344
351
  const status = presentationStatus(ops, said);
345
352
  return settled ? { outcome: "settled", status } : { outcome: "timed-out", status, timeoutMs };
346
353
  }
@@ -135,6 +135,10 @@ export class UnblindingOperations {
135
135
  value: null
136
136
  });
137
137
  }
138
+ /** Whether the live operation for `grant` still holds a message the transport has not taken. */
139
+ owes(grant) {
140
+ return (this.live.get(grant)?.outbox.length ?? 0) > 0;
141
+ }
138
142
  /**
139
143
  * Whether a pass has work: a live operation, or an owner holds an authenticated peer query whose reply is
140
144
  * owed (an in-memory walk of the owners, no store read).
@@ -635,7 +639,10 @@ export function unblindingStatus(ops, grant) {
635
639
  * someone else must call `processOnce`.
636
640
  */
637
641
  export function* waitForUnblinding(ops, grant, { timeoutMs, clock = ops.deps.clock }) {
638
- const settled = yield* observeUntil(() => unblindingStatus(ops, grant).state !== "active", { timeoutMs, clock });
642
+ const settled = yield* observeUntil(() => unblindingStatus(ops, grant).state !== "active" && !ops.owes(grant), {
643
+ timeoutMs,
644
+ clock,
645
+ });
639
646
  const status = unblindingStatus(ops, grant);
640
647
  return settled ? { outcome: "settled", status } : { outcome: "timed-out", status, timeoutMs };
641
648
  }
@@ -35,7 +35,7 @@
35
35
  * restart never leaves a delegated event waiting for one. Collaborators are
36
36
  * narrow (ADR-0013); nothing here reaches a runtime.
37
37
  */
38
- import { Diger, Ilks, NumberPrimitive, NumDex, Verfer } from "cesr-ts";
38
+ import { Diger, Ilks, NumberPrimitive, NumDex, ParserError, Verfer } from "cesr-ts";
39
39
  import { ValidationError } from "../../core/errors.js";
40
40
  import { Kever } from "../../core/kel/kever.js";
41
41
  import { dgKey } from "../../db/core/keys.js";
@@ -394,6 +394,12 @@ export class Counselor {
394
394
  // A witness refusing or being unreachable is a remote outcome: report it in status and retry later.
395
395
  run.failure = error instanceof Error ? error.message : String(error);
396
396
  run.delay = Math.min(run.delay * 2, this.receiptRetry.maxMs);
397
+ // A witness that answered with a reply this member cannot read (a receipt the elected member already
398
+ // obtained) will not answer better later: submit on the next attempt instead of waiting out the grace.
399
+ if (!submit && error instanceof ParserError) {
400
+ run.fallbackAfterMs = 0;
401
+ run.delay = this.receiptRetry.baseMs;
402
+ }
397
403
  }
398
404
  run.nextAt = now + run.delay;
399
405
  if (!this.hby.db.fullyWitnessed(serder))
@@ -142,23 +142,50 @@ export function* sendGroupMessage(postman, prepared) {
142
142
  recipient: prepared.recipient,
143
143
  exnSaid: prepared.exnSaid,
144
144
  payloadSaid: prepared.payloadSaid,
145
+ outcome: "delivered",
145
146
  deliveries: [...outcome.deliveries],
146
147
  };
147
148
  }
148
- /** Collect successful recipient outcomes into message identities and completed target URLs. */
149
+ /** The outcome of sending `prepared` as a failed delivery with the transport's reason. */
150
+ export function failedGroupDelivery(prepared, error) {
151
+ return {
152
+ recipient: prepared.recipient,
153
+ exnSaid: prepared.exnSaid,
154
+ payloadSaid: prepared.payloadSaid,
155
+ outcome: "failed",
156
+ deliveries: [],
157
+ reason: error instanceof Error ? error.message : String(error),
158
+ };
159
+ }
160
+ /**
161
+ * Like {@link sendGroupMessage}, but a transport failure is this member's outcome and not an error: the wrapper
162
+ * is already accepted locally, so one unreachable member must not stop the others or the caller's next step.
163
+ */
164
+ export function* deliverGroupMessage(postman, prepared) {
165
+ try {
166
+ return yield* sendGroupMessage(postman, prepared);
167
+ }
168
+ catch (error) {
169
+ return failedGroupDelivery(prepared, error);
170
+ }
171
+ }
172
+ /** Collect recipient outcomes: delivered message identities and completed target URLs, and every member's outcome. */
149
173
  export function groupSendResult(outcomes) {
174
+ const delivered = outcomes.filter(outcome => outcome.outcome === "delivered");
150
175
  return {
151
- messages: outcomes.map(({ recipient, exnSaid, payloadSaid }) => ({ recipient, exnSaid, payloadSaid })),
152
- deliveries: outcomes.flatMap(outcome => outcome.deliveries),
176
+ messages: delivered.map(({ recipient, exnSaid, payloadSaid }) => ({ recipient, exnSaid, payloadSaid })),
177
+ deliveries: delivered.flatMap(outcome => outcome.deliveries),
178
+ members: [...outcomes],
153
179
  };
154
180
  }
155
181
  /**
156
182
  * Process the exact signed local contribution before admitting remote delivery,
157
183
  * so an immediate peer reply cannot outrun local Multiplexor evidence. Await
158
- * normal intake, then send the same bytes without signing again. Intake or send
159
- * failures propagate; no cross-store/network transaction is implied.
184
+ * normal intake, then send the same bytes without signing again. Intake failures
185
+ * propagate; a send failure is the member's `failed` outcome. No cross-store/network
186
+ * transaction is implied.
160
187
  */
161
188
  export function* admitAndSendGroupMessage(input, postman, prepared) {
162
189
  yield* input.processBytes(prepared.message, { local: true });
163
- return yield* sendGroupMessage(postman, prepared);
190
+ return yield* deliverGroupMessage(postman, prepared);
164
191
  }
@@ -25,7 +25,7 @@ import { abortable } from "../runtime/abortable.js";
25
25
  import { collectBytes } from "../transport/byte-source.js";
26
26
  import { findLocalGroupMember as findLocalMember, groupMemberHasKey, uniqueMembers } from "./group-members.js";
27
27
  import { acdcV2RegistryProposal, MULTISIG_ICP_ROUTE, MULTISIG_ISS_ROUTE, MULTISIG_IXN_ROUTE, MULTISIG_REV_ROUTE, MULTISIG_ROT_ROUTE, MULTISIG_RPY_ROUTE, MULTISIG_VCP_ROUTE, multisigChild, multisigChildEvidence, multisigChildGenusAttrs, prepareAcdcV2RegistryExn, prepareMultisigEventExn, prepareMultisigVdrExn, registryProposalEvent, } from "./grouping.js";
28
- import { admitAndSendGroupMessage, groupSendResult, prepareGroupMessage, remoteGroupMembers, signGroupMessage, storedGroupMessage, } from "./multisig-messaging.js";
28
+ import { admitAndSendGroupMessage, failedGroupDelivery, groupSendResult, prepareGroupMessage, remoteGroupMembers, signGroupMessage, storedGroupMessage, } from "./multisig-messaging.js";
29
29
  /** Approve one exact ACDC v2 TEL/anchor through existing member signing and ordinary processors.
30
30
  * A returned wrapper records consent, not quorum/witness/TEL completion; retry the accepted-state owners.
31
31
  * `resume` reuses this member's recorded anchor signatures after a crash instead of signing again.
@@ -135,14 +135,10 @@ export function* publishAcdcV2Registry(hby, postman, group, approval) {
135
135
  notify: true,
136
136
  });
137
137
  const outcome = yield* postman.wait(evt);
138
- if (outcome.kin === "failed")
139
- throw outcome.error;
140
- outcomes.push({
141
- recipient,
142
- exnSaid: approval[0].said,
143
- payloadSaid: approval[0].sad.a.anc,
144
- deliveries: outcome.deliveries,
145
- });
138
+ const ref = { recipient, exnSaid: approval[0].said, payloadSaid: approval[0].sad.a.anc };
139
+ outcomes.push(outcome.kin === "failed"
140
+ ? failedGroupDelivery(ref, outcome.error)
141
+ : { ...ref, outcome: "delivered", deliveries: outcome.deliveries });
146
142
  }
147
143
  return groupSendResult(outcomes);
148
144
  }
@@ -372,7 +372,10 @@ export function* joinIpexProposal(hby, runtime, reger, ipexArgs) {
372
372
  throw new ValidationError(`Multisig IPEX sender ${group ?? "<missing>"} is not a local group AID.`);
373
373
  }
374
374
  if (!ipexArgs.auto) {
375
- return { exn, group: { embedded, accepted: null, approved: null, lead: null, messages: [], deliveries: [] } };
375
+ return {
376
+ exn,
377
+ group: { embedded, accepted: null, approved: null, lead: null, messages: [], deliveries: [], members: [] },
378
+ };
376
379
  }
377
380
  if (embedded.pvrsn.major === 2) {
378
381
  // ACDC v2: the runtime's group IPEX owner approves, publishes, collects and lets the lead deliver.
@@ -393,6 +396,7 @@ export function* joinIpexProposal(hby, runtime, reger, ipexArgs) {
393
396
  lead: runtime.exc.lead(hby.habs.get(group), said),
394
397
  messages: outcome?.messages ?? [],
395
398
  deliveries: [...(outcome?.deliveries ?? []), ...(outcome?.sent ?? [])],
399
+ members: outcome?.members ?? [],
396
400
  },
397
401
  };
398
402
  }
@@ -416,6 +420,7 @@ export function* joinIpexProposal(hby, runtime, reger, ipexArgs) {
416
420
  credentialSaved,
417
421
  messages: [],
418
422
  deliveries: [],
423
+ members: [],
419
424
  },
420
425
  };
421
426
  }
@@ -437,6 +442,7 @@ export function* joinIpexProposal(hby, runtime, reger, ipexArgs) {
437
442
  credentialSaved,
438
443
  messages: approval.messages,
439
444
  deliveries: approval.deliveries,
445
+ members: approval.members,
440
446
  },
441
447
  };
442
448
  }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Join approves exactly one stored proposal, the first the user consents to:
5
5
  * - an explicit `--said` names the proposal, otherwise the unapproved proposals
6
- * the notice store announces are offered in notice order;
6
+ * the notice store announces are offered in notice order; `--kind` restricts the
7
+ * offer (and a `--said`) to one proposal kind, so a join never signs another kind;
7
8
  * - without `--auto` every offered proposal is shown and approved only on a yes;
8
9
  * - with `--auto` the first offered proposal is approved without a prompt. This is
9
10
  * the whole of auto consent: it exists only inside the `join` the user ran for
@@ -19,17 +20,20 @@ function joinedGroup(hby, group) {
19
20
  return group === undefined ? undefined : (hby.habByName(group) ?? hby.habs.get(group))?.pre ?? undefined;
20
21
  }
21
22
  /** Candidates for one pass, oldest first. */
22
- function candidates(deps, { said, group }) {
23
+ function candidates(deps, { said, group, kind }) {
23
24
  const wanted = joinedGroup(deps.hby, group);
24
25
  if (said !== undefined) {
25
26
  const proposal = findGroupProposal(deps, said);
27
+ if (proposal && kind !== undefined && proposal.kind !== kind) {
28
+ throw new ValidationError(`Multisig exchange ${said} is a ${proposal.kind} proposal, not ${kind}.`);
29
+ }
26
30
  if (proposal && wanted !== undefined && proposal.group !== wanted) {
27
31
  throw new ValidationError(`Multisig exchange ${said} belongs to group ${proposal.group}, not ${wanted}.`);
28
32
  }
29
33
  return proposal ? [proposal] : [];
30
34
  }
31
35
  return listGroupProposals(deps, wanted === undefined ? {} : { group: wanted })
32
- .filter(proposal => proposal.local === "unapproved");
36
+ .filter(proposal => proposal.local === "unapproved" && (kind === undefined || proposal.kind === kind));
33
37
  }
34
38
  /**
35
39
  * Approve the first proposal the user consents to, polling stored proposals until