@real-life/trust-protocol 0.4.0 → 0.5.0

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 (88) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +47 -25
  3. package/dist/adapters/vti-mediator.d.ts +18 -3
  4. package/dist/adapters/vti-mediator.d.ts.map +1 -1
  5. package/dist/adapters/vti-mediator.js +51 -13
  6. package/dist/adapters/vti-mediator.js.map +1 -1
  7. package/dist/codec.d.ts +9 -1
  8. package/dist/codec.d.ts.map +1 -1
  9. package/dist/codec.js +107 -23
  10. package/dist/codec.js.map +1 -1
  11. package/dist/engine.d.ts +15 -3
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +130 -16
  14. package/dist/engine.js.map +1 -1
  15. package/dist/entropy.d.ts +1 -1
  16. package/dist/entropy.d.ts.map +1 -1
  17. package/dist/entropy.js +1 -1
  18. package/dist/entropy.js.map +1 -1
  19. package/dist/events.d.ts +21 -0
  20. package/dist/events.d.ts.map +1 -1
  21. package/dist/events.js.map +1 -1
  22. package/dist/holder-api.d.ts +29 -1
  23. package/dist/holder-api.d.ts.map +1 -1
  24. package/dist/holder-api.js +12 -2
  25. package/dist/holder-api.js.map +1 -1
  26. package/dist/holder-state-v2.d.ts +858 -0
  27. package/dist/holder-state-v2.d.ts.map +1 -0
  28. package/dist/holder-state-v2.js +2 -0
  29. package/dist/holder-state-v2.js.map +1 -0
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal.d.ts +2 -1
  35. package/dist/internal.d.ts.map +1 -1
  36. package/dist/internal.js +1 -1
  37. package/dist/internal.js.map +1 -1
  38. package/dist/introduction/holder.d.ts.map +1 -1
  39. package/dist/introduction/holder.js +7 -0
  40. package/dist/introduction/holder.js.map +1 -1
  41. package/dist/invariants.d.ts.map +1 -1
  42. package/dist/invariants.js +70 -0
  43. package/dist/invariants.js.map +1 -1
  44. package/dist/membership/holder.d.ts +3 -5
  45. package/dist/membership/holder.d.ts.map +1 -1
  46. package/dist/membership/holder.js +165 -88
  47. package/dist/membership/holder.js.map +1 -1
  48. package/dist/membership.d.ts +31 -0
  49. package/dist/membership.d.ts.map +1 -1
  50. package/dist/membership.js +25 -2
  51. package/dist/membership.js.map +1 -1
  52. package/dist/message/holder.d.ts +49 -0
  53. package/dist/message/holder.d.ts.map +1 -0
  54. package/dist/message/holder.js +290 -0
  55. package/dist/message/holder.js.map +1 -0
  56. package/dist/message.d.ts +43 -0
  57. package/dist/message.d.ts.map +1 -0
  58. package/dist/message.js +51 -0
  59. package/dist/message.js.map +1 -0
  60. package/dist/receive.d.ts +4 -3
  61. package/dist/receive.d.ts.map +1 -1
  62. package/dist/receive.js +23 -4
  63. package/dist/receive.js.map +1 -1
  64. package/dist/results.d.ts +17 -2
  65. package/dist/results.d.ts.map +1 -1
  66. package/dist/results.js.map +1 -1
  67. package/dist/schemas.d.ts.map +1 -1
  68. package/dist/schemas.js +9 -6
  69. package/dist/schemas.js.map +1 -1
  70. package/dist/state.d.ts +66 -5
  71. package/dist/state.d.ts.map +1 -1
  72. package/dist/state.js +2 -1
  73. package/dist/state.js.map +1 -1
  74. package/dist/trust/continuity.d.ts.map +1 -1
  75. package/dist/trust/continuity.js +7 -1
  76. package/dist/trust/continuity.js.map +1 -1
  77. package/dist/trust/entries.d.ts.map +1 -1
  78. package/dist/trust/entries.js +103 -2
  79. package/dist/trust/entries.js.map +1 -1
  80. package/dist/views.d.ts +34 -2
  81. package/dist/views.d.ts.map +1 -1
  82. package/dist/views.js +19 -1
  83. package/dist/views.js.map +1 -1
  84. package/dist/wire.d.ts +47 -6
  85. package/dist/wire.d.ts.map +1 -1
  86. package/dist/wire.js.map +1 -1
  87. package/package.json +6 -2
  88. package/schemas/holder-state-v2.schema.json +4123 -0
@@ -1,5 +1,5 @@
1
1
  // membership/holder — groups on the holder (lib Stage 2, PR 7a; Membership
2
- // Tasks 0.19 on Access 0.57, Delivery 0.81): founding, the prelude and
2
+ // Tasks 0.20 on Access 0.57, Delivery 0.82): founding, the prelude and
3
3
  // the invite, the accept, admission and welcome, evidence and vouches, on
4
4
  // HolderState and the engine. The pure forms and checks are the root
5
5
  // module's (membership.ts).
@@ -10,7 +10,7 @@
10
10
  // that context is the founder's member
11
11
  // anchor, its memberOp the genesis)
12
12
  // membership.invite the prelude toward a contact (Membership
13
- // 1.2); the invite follows its answer
13
+ // 3.5); the invite follows its answer
14
14
  // membership.vouch a vouch@2 for a held consent pair
15
15
  // (Access 5.3), relayed as evidence
16
16
  // decision group-invite: accept signs and sends the accept (3.2),
@@ -18,21 +18,24 @@
18
18
  // tasks membership-invite/0.2 (proof absent), membership-accept/0.2
19
19
  // (proof required), membership-evidence/0.2 and /0.1 (proof
20
20
  // absent): channel bindings on member keys (receive.ts);
21
- // x-membership-prelude-request/0.1, x-membership-prelude-
22
- // reply/0.1 (tuple binding) and x-membership-welcome/0.1
23
- // (channel binding), this library's experimental forms; all
24
- // signature-class: signed acks
21
+ // membership-prelude-request/0.1 and membership-prelude-
22
+ // reply/0.1 (proof required, tuple binding; Membership 3.5),
23
+ // and x-membership-welcome/0.1 (channel binding), this
24
+ // library's experimental form; all signature-class: signed acks
25
25
  // keepKeys the member keys of held groups and of groups on the way in,
26
- // and the tuple rkid of every held prelude (sent or answered)
27
- // retain declined and expired invitations leave `joining`
26
+ // and the tuple rkid of every prelude sent and held, and of an
27
+ // answered one while its answer may still travel
28
+ // retain declined and expired invitations leave `joining`; a
29
+ // binding once its relationship ended and no open or accepted
30
+ // invitation rests on it (RLTP-MT-3945)
28
31
  //
29
32
  // Choices this file makes where spec and note leave them open:
30
- // - the prelude is application-level (Membership 1.2): this library
31
- // carries it over the relationship tuple as two experimental, signed
32
- // documents; the invitee's holder answers on its own, registering its member label and key first (Identity 6.1),
33
+ // - the prelude (Membership 3.5) travels over the relationship tuple as
34
+ // its two registered types; the invitee's holder answers on its own
35
+ // (3960), registering its member label and key first (Identity 6.1),
33
36
  // with a displayed member card (2.6) whose key the invite is sealed
34
37
  // to. An answer is taken only on a thread whose request this holder
35
- // sent (the outbox holds it)
38
+ // sent and still holds, on its tuple (3965)
36
39
  // - the welcome: Membership 3.3 carries it in access-operation/0.1 with
37
40
  // a member.add envelope and a sealed rltp-welcome/0.1 (Section 4),
38
41
  // Access's materialization deciding; until the Access envelope is
@@ -45,34 +48,37 @@
45
48
  // 3.3), a member's the oid of its accept document's digest (a member admitted later is
46
49
  // unknown to earlier members until their port materializes it)
47
50
  // - the admission without a human act at the admitting member: the
48
- // invitation is read as the anticipated admission act — the inviter
49
- // decided when it invited, so the accept (n = 1) or the evidence that
50
- // completes the vouches admits in the same commit. Membership 3280
51
- // (the accept grants nothing) and 3800 (evidence grants nothing) can be
52
- // read against this; an open spec reading for Membership 0.20, no
53
- // admission prompt yet
51
+ // invitation stands as the inviter's act of admission (RLTP-MT-3282),
52
+ // so the accept (n = 1) or the evidence that completes the vouches
53
+ // admits in the same commit — before invite.validUntil +
54
+ // membership-skew + the longest give-up horizon by the inviter's
55
+ // clock; later the consent stays as evidence (an admission prompt is
56
+ // not built)
54
57
  // - founding: the genesis admits under the rule of one (vouchThreshold
55
58
  // "1"); a stricter rule needs policy.change, which is not built, so
56
59
  // found refuses another (threshold-unsupported). A member's own vouch
57
60
  // for its own invitee never counts beside its admission and is refused
58
61
  // (own-admission)
59
62
  // - the prelude is bound both ways: the request names the inviter's
60
- // member anchor and the answering holder files the prelude under
61
- // (genesis digest, inviter), so an invite surfaces as the contact that
62
- // asked as that inviter; the inviter files its request by thread
63
+ // member anchor and the answering holder keeps the binding under
64
+ // (genesis digest, inviter) with the arrival tuple (3925), so an invite
65
+ // surfaces as the contact that asked as that inviter; the inviter files its request by thread
63
66
  // (group, contact, own rkid) and takes an answer only for exactly that
64
67
  // group on exactly that thread and tuple
65
68
  // - invitations are kept per thread: a new invite opens its own thread
66
69
  // beside earlier ones, so an accept signed in time on an earlier thread
67
70
  // still answers it; an accept answers exactly the invitation of its
68
71
  // thread, and an admission answers every invitation to its subject
72
+ // - the lock keys of 3950–3957 and 3980: the engine serializes every
73
+ // operation of a holder and commits by CAS over the whole state
74
+ // (receive.ts), a superset of every key the rules name
69
75
  // - bounded state (tick): an issued invitation leaves past validUntil +
70
76
  // membership-skew + the longest give-up horizon (P90D, RLTP-MT-3090),
71
77
  // as does evidence (an accept signed in time may arrive that late), or
72
78
  // once its subject is on the roster; an outgoing prelude past an
73
79
  // invite's life and the horizon (its answer may travel that long); an
74
- // answered prelude no open or accepted invitation rests on past an
75
- // invite's life + skew + P90D; an accepted or joined invitation past
80
+ // answered prelude's binding once its relationship ended and no open
81
+ // or accepted invitation rests on it (3945); an accepted or joined invitation past
76
82
  // validUntil + skew + two horizons — the accept's delivery and the
77
83
  // welcome's — so the welcome finds the accepted state and the member
78
84
  // key (kept while an invitation is open or accepted); an explicit abort
@@ -83,9 +89,10 @@
83
89
  // under the inviter's member key over (anchor, group, thread, and both
84
90
  // pair anchors of the channel it travels on, checked against the
85
91
  // document's issuer and recipient as stage 8 authenticated them), so
86
- // no other contact can replay it on its own channel; the
87
- // answering holder never overwrites a binding (group, inviter) held for
88
- // another contact. Residue: the binding links the inviter's member
92
+ // no other contact can replay it on its own channel (3905, 3910); the
93
+ // answering holder keeps one binding per relationship (3930, 3935):
94
+ // a request from another relationship is refused while the bound one
95
+ // is current, whichever entries they feed. Residue: the binding links the inviter's member
89
96
  // anchor to its pair channel transferably (the invite names that
90
97
  // anchor before consent anyway)
91
98
  // - the admission policy: the group rule vouchThreshold n reads as the
@@ -117,18 +124,17 @@ import { b64uOf, fromB64u, iso, sameDigest, toU } from '../core.js';
117
124
  import { anchorOfEd, diSignWith, diVerify, digestBytes, digestDoc } from '../crypto.js';
118
125
  import { seal } from '../delivery.js';
119
126
  import { cardBody } from '../encounter.js';
120
- import { ACCEPT_TYPE, EVIDENCE_TYPE, EVIDENCE_TYPE_01, INVITE_TYPE, MEMBERSHIP_SKEW, acceptProblem, enclosedCardOk, evidenceProblem, inviteCredentialBody, inviteProblem, memberLabelOf, membershipSchemaOk, oidOf, vouchCredentialBody, vouchProblem, withinBudget, } from '../membership.js';
127
+ import { ACCEPT_TYPE, EVIDENCE_TYPE, EVIDENCE_TYPE_01, INVITE_TYPE, MEMBERSHIP_SKEW, PRELUDE_REPLY_TYPE, PRELUDE_REQUEST_TYPE, acceptProblem, enclosedCardOk, evidenceProblem, inviteCredentialBody, inviteProblem, inviterBindingBody, memberLabelOf, membershipSchemaOk, oidOf, preludeRequestProblem, vouchCredentialBody, vouchProblem, withinBudget, } from '../membership.js';
121
128
  import { withLabel, withPairNonce } from '../identity-state.js';
122
129
  import { ED_PKCS8 } from '../keys-seed.js';
123
130
  import { tupleByRkid } from '../receive.js';
131
+ import { INVITE_RATE, blockedRel, rateCount, relOfRkid, withRate } from '../message/holder.js';
124
132
  import { entryOf, findContact, liveRel, activeTuple } from '../trust/state.js';
125
133
  import { groupStarRematch, withGroupHits } from '../trust/group-star.js';
126
134
  const TT = 'https://real-life.org/trust-tasks/';
127
- /** This library's experimental forms (receive.ts SIGNATURE_CLASS). */
128
- export const PRELUDE_REQUEST_TYPE = TT + 'x-membership-prelude-request/0.1';
129
- export const PRELUDE_REPLY_TYPE = TT + 'x-membership-prelude-reply/0.1';
135
+ /** This library's experimental form of the welcome (receive.ts SIGNATURE_CLASS). */
130
136
  export const WELCOME_TYPE = TT + 'x-membership-welcome/0.1';
131
- export { INVITE_TYPE, ACCEPT_TYPE, EVIDENCE_TYPE };
137
+ export { INVITE_TYPE, ACCEPT_TYPE, EVIDENCE_TYPE, PRELUDE_REQUEST_TYPE, PRELUDE_REPLY_TYPE };
132
138
  /** How long an invite is answerable (validUntil − validFrom), as the probe had it. */
133
139
  export const INVITE_LIFE = 24 * 3_600_000;
134
140
  /** The longest give-up horizon (Delivery [P1D, P90D]): what late answers may still need (RLTP-MT-3090). */
@@ -161,6 +167,12 @@ const ownName = (state) => {
161
167
  return typeof n === 'string' && n.length ? [...n].slice(0, 200).join('') : undefined;
162
168
  };
163
169
  const groupOfLabel = (state, label) => label === undefined ? undefined : Object.values(state.groups).find((g) => g.memberLabel === label);
170
+ /** RLTP-MT-3282: the inviter admits on its own invitation without a
171
+ * further human act only before invite.validUntil + membership-skew +
172
+ * the longest give-up horizon, by its own clock; later, the admission
173
+ * would wait for a human act, which this library does not offer — the
174
+ * consent stays as evidence until its retention lets it go. */
175
+ const admissionOpen = (invite, when) => when < untilOf(invite) + MEMBERSHIP_SKEW + HORIZON_MAX;
164
176
  /** The policy (see the header): admitted when vouchThreshold distinct
165
177
  * members stand behind it, the admitting member one of them. */
166
178
  function policyMet(g, ev) {
@@ -255,16 +267,18 @@ const inviteDef = {
255
267
  const ch = channelTo(ctx.snapshot, input.contactId);
256
268
  if (!ch)
257
269
  return no('no-own-channel');
270
+ // no invitation toward a blocked relationship (Message Tasks 6410)
271
+ if (blockedRel(ctx.snapshot, ch.rel.id))
272
+ return no('blocked', 'RLTP-MSG-6410');
258
273
  const own = await ctx.keys.anchorOf(ch.tuple.ownPairLabel);
259
274
  const at = iso(ctx.when);
260
275
  const body = {
261
276
  id: ctx.entropy.id('doc.id'), type: PRELUDE_REQUEST_TYPE, issuer: own.anchor, recipient: ch.tuple.counterpartAnchor,
262
277
  threadId: ctx.entropy.id('thread.id'), issuedAt: at, payload: { genesisDigest: g.id, group: g.did ?? '', name: g.name, inviter: g.memberAnchor, binding: {} },
263
278
  };
264
- // the inviter's member anchor proven: signed under it over (anchor, group, thread)
265
- body.payload.binding = await signDoc(ctx, g.memberLabel, g.memberAnchor, {
266
- type: 'x-membership-inviter@0.1', inviter: g.memberAnchor, genesisDigest: g.id, threadId: body.threadId, from: own.anchor, to: ch.tuple.counterpartAnchor,
267
- }, at);
279
+ // the inviter binding (RLTP-MT-3905): signed under the member anchor
280
+ // over (inviter, genesis digest, thread, from, to)
281
+ body.payload.binding = await signDoc(ctx, g.memberLabel, g.memberAnchor, inviterBindingBody({ inviter: g.memberAnchor, genesisDigest: g.id, threadId: body.threadId, from: own.anchor, to: ch.tuple.counterpartAnchor }), at);
268
282
  const doc = await signDoc(ctx, ch.tuple.ownPairLabel, own.anchor, body, at);
269
283
  return sealed(ctx, doc, { contactId: ch.contact.id, rkid: ch.tuple.rkid, keyAgreement: ch.tuple.counterpartKeyAgreement });
270
284
  },
@@ -286,22 +300,20 @@ function arrival(state, rkid) {
286
300
  const preludeKey = (group, inviter) => `${group} ${inviter}`;
287
301
  const preludeRequestTask = {
288
302
  proof: 'required',
303
+ // stage 7: the payload schema (RLTP-MT-3905)
289
304
  decode(p) {
290
- const x = p;
291
- if (x === null || typeof x !== 'object' || Object.keys(x).length !== 5 || typeof x.genesisDigest !== 'string' || typeof x.group !== 'string' ||
292
- typeof x.name !== 'string' || typeof x.inviter !== 'string' || !/^did:key:z6Mk[1-9A-HJ-NP-Za-km-z]{44}$/.test(x.inviter) ||
293
- x.binding === null || typeof x.binding !== 'object')
294
- return refuse('malformed');
295
- return memberLabelOf(x.genesisDigest) === null ? refuse('validation-failed') : x;
305
+ if (!membershipSchemaOk('payload-membership-prelude-request.schema.json', p))
306
+ return refuse('malformed', 'RLTP-MT-3905');
307
+ return memberLabelOf(p.genesisDigest) === null ? refuse('validation-failed') : p;
296
308
  },
297
309
  async prepare(ctx, doc, arr) {
298
- if (!(await diVerify(doc, doc.issuer, ctx.crypto.subtle)))
299
- return refuse('validation-failed', 'RLTP-MT-2130');
300
- // the claimed inviter proven by its own member key, for this group and thread
301
- const b = doc.payload.binding;
302
- if (b.type !== 'x-membership-inviter@0.1' || b.inviter !== doc.payload.inviter || b.genesisDigest !== doc.payload.genesisDigest ||
303
- b.threadId !== doc.threadId || b.from !== doc.issuer || b.to !== doc.recipient || !(await diVerify(b, doc.payload.inviter, ctx.crypto.subtle)))
304
- return refuse('validation-failed', 'RLTP-MT-3020');
310
+ // the document proof under its issuer, the binding's fields (genesis
311
+ // digests by their decoded bytes) and its proof under the inviter
312
+ // (RLTP-MT-3905, 3910); the issuer and recipient are the arrival
313
+ // tuple's pair anchors by the core's tuple binding (receive.ts)
314
+ const bad = await preludeRequestProblem(doc, ctx.crypto.subtle);
315
+ if (bad)
316
+ return refuse('validation-failed', bad === 'documentProof' ? 'RLTP-MT-3900' : 'RLTP-MT-3905');
305
317
  const a = arrival(ctx.snapshot, arr.rkid);
306
318
  const label = memberLabelOf(doc.payload.genesisDigest);
307
319
  const member = await ctx.keys.anchorOf(label);
@@ -318,14 +330,17 @@ const preludeRequestTask = {
318
330
  apply(state, prepared, doc, arr) {
319
331
  const p = prepared.value;
320
332
  const a = arrival(state, arr.rkid);
321
- // a binding (group, inviter) held for another contact is never
322
- // overwritten: compared by the entries the two arrival tuples feed now
323
- // (a merge, a split or a chaining since moves it with them); a binding
324
- // whose tuple is gone (a departure) is free
333
+ // the binding per relationship (RLTP-MT-3925–3935): a request under a
334
+ // held key from a tuple of the bound tuple's relationship replaces the
335
+ // tuple (3930); from another relationship it is refused while the bound
336
+ // one is current, whatever entry either feeds (3935), and replaces the
337
+ // binding once that one has ended. The relationships are read through
338
+ // the tuples now (3940): a merge, a split or a chaining moves them
325
339
  const bound = state.joining.preludes[preludeKey(toU(doc.payload.genesisDigest), doc.payload.inviter)];
326
- const was = bound ? arrival(state, bound.rkid) : null;
327
- if (was && was.contactId !== a.contactId)
328
- return refuse('validation-failed', 'RLTP-MT-3030');
340
+ const was = bound ? tupleByRkid(state, bound.rkid) : null;
341
+ const here = tupleByRkid(state, arr.rkid);
342
+ if (was && liveRel(was.rel) && was.rel.id !== here.rel.id)
343
+ return refuse('validation-failed', 'RLTP-MT-3935');
329
344
  const keys = Object.hasOwn(state.addresses.keys, p.rkid) ? state.addresses.keys : { ...state.addresses.keys, [p.rkid]: { label: p.label, lastShown: arr.when } };
330
345
  return {
331
346
  state: {
@@ -341,24 +356,23 @@ const preludeRequestTask = {
341
356
  };
342
357
  const preludeReplyTask = {
343
358
  proof: 'required',
359
+ // stage 7: the payload schema (RLTP-MT-3960)
344
360
  decode(p) {
345
- const x = p;
346
- if (x === null || typeof x !== 'object' || Object.keys(x).length !== 2 || typeof x.genesisDigest !== 'string' || x.card === null || typeof x.card !== 'object')
347
- return refuse('malformed');
348
- return x;
361
+ return membershipSchemaOk('payload-membership-prelude-reply.schema.json', p) ? p : refuse('malformed', 'RLTP-MT-3960');
349
362
  },
350
363
  async prepare(ctx, doc, arr) {
351
364
  const s = ctx.snapshot;
352
365
  if (!(await diVerify(doc, doc.issuer, ctx.crypto.subtle)))
353
- return refuse('validation-failed', 'RLTP-MT-2130');
354
- // the answer to exactly the prelude this holder sent: its group, thread and tuple
366
+ return refuse('validation-failed', 'RLTP-MT-3960');
367
+ // the answer to exactly the prelude this holder sent and still holds:
368
+ // its group (genesis digests by their decoded bytes), thread and tuple (3965)
355
369
  const g = s.groups[toU(doc.payload.genesisDigest) ?? ''];
356
370
  const asked = g?.preludes[doc.threadId];
357
371
  if (!g || g.memberAnchor === undefined || !asked || asked.rkid !== arr.rkid)
358
- return refuse('validation-failed', 'RLTP-MT-3030');
372
+ return refuse('validation-failed', 'RLTP-MT-3965');
359
373
  const card = doc.payload.card;
360
374
  if (!(await enclosedCardOk(card, card.anchor, ctx.crypto.subtle)))
361
- return refuse('validation-failed', 'RLTP-MT-2250');
375
+ return refuse('validation-failed', 'RLTP-MT-3960');
362
376
  const a = arrival(s, arr.rkid);
363
377
  const S = ctx.crypto.subtle;
364
378
  const at = iso(ctx.when);
@@ -381,6 +395,12 @@ const preludeReplyTask = {
381
395
  const g = state.groups[toU(doc.payload.genesisDigest)];
382
396
  const preludes = { ...g.preludes };
383
397
  delete preludes[doc.threadId];
398
+ // under a block of the relationship the reply arrives on: the waiting
399
+ // prelude closes, acknowledged, and no invitation is issued or sent —
400
+ // one transaction with the block check (RLTP-MT-3975, 3980)
401
+ const rel = relOfRkid(state, arr.rkid);
402
+ if (rel !== null && blockedRel(state, rel))
403
+ return { state: { ...state, groups: { ...state.groups, [g.id]: { ...g, preludes } } } };
384
404
  // a member is not invited again; every invitation is its own thread,
385
405
  // kept beside earlier ones (an accept signed in time may still travel)
386
406
  if (Object.hasOwn(g.roster, p.invitee))
@@ -396,44 +416,91 @@ const preludeReplyTask = {
396
416
  };
397
417
  // ── the invite at the invitee (Membership 3.1) ─────────────────────────
398
418
  const invitePromptId = (credDigest) => 'invite:' + credDigest;
419
+ /** The invitation's attribution (RLTP-MT-3100): the relationship of the
420
+ * prelude binding held for its genesis digest and issuer, read through
421
+ * the binding's tuple now (3940), or none. */
422
+ function attribution(state, inv) {
423
+ const pre = state.joining.preludes[preludeKey(toU(inv.credentialSubject.genesisDigest) ?? '', inv.issuer)];
424
+ const relId = pre ? relOfRkid(state, pre.rkid) : null;
425
+ return pre && relId !== null ? { pre, relId } : null;
426
+ }
427
+ /** Whether the holder holds the credential digest in any record (3105):
428
+ * surfaced, declined, or a received invitation. */
429
+ const knownInvite = (state, credDigest) => state.joining.surfaced.includes(credDigest) || state.joining.declined.includes(credDigest) || Object.hasOwn(state.joining.invites, credDigest);
430
+ const sortedWith = (xs, x) => (xs.includes(x) ? [...xs] : [...xs, x].sort());
399
431
  const inviteTask = {
400
432
  proof: 'absent',
401
433
  decode: (p) => (membershipSchemaOk('payload-membership-invite.schema.json', p) ? p : refuse('malformed')),
402
434
  // the prelude check (3030): the invite reaches the key of the own
403
- // derivation from its genesis digest, after a prelude this holder answered
435
+ // derivation from its genesis digest. The ack is sealed to the inviter's
436
+ // card and filed under the contact of the binding's relationship, or
437
+ // under the member where no binding attributes it (3100)
404
438
  channel(state, doc, rkid) {
405
439
  const inv = doc.payload.invite;
406
440
  const label = memberLabelOf(inv.credentialSubject.genesisDigest);
407
441
  if (label === null || state.addresses.keys[rkid]?.label !== label)
408
442
  return refuse('validation-failed', 'RLTP-MT-3030');
409
- // the contact that asked as this inviter (the prelude's binding)
410
- const pre = state.joining.preludes[preludeKey(toU(inv.credentialSubject.genesisDigest) ?? '', inv.issuer)];
411
- if (!pre)
412
- return refuse('validation-failed', 'RLTP-MT-3030');
413
- return { keyAgreement: inv.credentialSubject.card.keyAgreement, contactId: pre.contactId, via: pre.rkid };
443
+ const at = attribution(state, inv);
444
+ return at
445
+ ? { keyAgreement: inv.credentialSubject.card.keyAgreement, contactId: at.pre.contactId, via: at.pre.rkid }
446
+ : { keyAgreement: inv.credentialSubject.card.keyAgreement, contactId: 'member:' + inv.issuer };
414
447
  },
415
448
  async prepare(ctx, doc) {
416
449
  const bad = await inviteProblem(doc, ctx.crypto.subtle);
417
450
  if (bad)
418
451
  return refuse('validation-failed', bad);
419
- return { credDigest: await digestDoc(doc.payload.invite, ctx.crypto.subtle) };
452
+ const credDigest = await digestDoc(doc.payload.invite, ctx.crypto.subtle);
453
+ // the rate (3105, Message Tasks 4115): a digest new to the holder, on a
454
+ // relationship that accepted 20 new ones in the window, counted before
455
+ // the block, waits at its carrier: failed(rate-limited) at stage 8,
456
+ // nothing committed (Delivery RLTP-DEL-6270); a known digest never
457
+ // counts and is never refused by it
458
+ const s = ctx.snapshot;
459
+ const at = attribution(s, doc.payload.invite);
460
+ if (at && !knownInvite(s, credDigest) && rateCount(s, at.relId, 'invites', ctx.when) >= INVITE_RATE)
461
+ return refuse('rate-limited', 'RLTP-MT-3105');
462
+ return { credDigest };
420
463
  },
421
- // durable buffering and surfacing to the human (3095)
464
+ // the defined effect (3095): durable buffering and the surfacing
465
+ // decision, in one transaction under the credential digest's key (3110;
466
+ // the engine serializes the whole state, receive.ts): exactly one of
467
+ // decline, surface, or no new effect
422
468
  apply(state, prepared, doc, arr) {
423
469
  const inv = doc.payload.invite;
424
470
  const gd = toU(inv.credentialSubject.genesisDigest);
425
471
  const { credDigest } = prepared.value;
426
- if (Object.hasOwn(state.groups, gd) || Object.hasOwn(state.joining.invites, credDigest))
427
- return { state: state };
428
- const pre = state.joining.preludes[preludeKey(gd, inv.issuer)];
472
+ const at = attribution(state, inv);
473
+ // the rate counts the new digest before the block is applied (3105, Message Tasks 4130)
474
+ let next = at && !knownInvite(state, credDigest) ? withRate(state, at.relId, 'invites', arr.when) : state;
475
+ // decline under a block (3115, Message Tasks 6.4): the register holds
476
+ // the digest, an open prompt of it closes as declined, silently
477
+ if (at && blockedRel(state, at.relId)) {
478
+ const promptId = invitePromptId(credDigest);
479
+ const prompts = { ...next.prompts };
480
+ delete prompts[promptId];
481
+ const held = next.joining.invites[credDigest];
482
+ const invites = held && held.status === 'open' ? { ...next.joining.invites, [credDigest]: { ...held, status: 'declined' } } : next.joining.invites;
483
+ return { state: { ...next, prompts, joining: { ...next.joining, invites, declined: sortedWith(next.joining.declined, credDigest) } } };
484
+ }
485
+ // no new effect: the register holds it (3120), or it surfaced before
486
+ // (3125). An invitation into a group the holder holds surfaces like any
487
+ // other: 3125 names no exception, and the person decides
488
+ if (next.joining.declined.includes(credDigest) || next.joining.surfaced.includes(credDigest) || Object.hasOwn(next.joining.invites, credDigest))
489
+ return { state: next };
490
+ // surface (3125): a prompt opened, the digest kept for the holder's life (3130)
429
491
  const validUntil = Date.parse(inv.validUntil);
430
492
  const r = {
431
- credDigest, group: gd, name: typeof inv.credentialSubject.name === 'string' ? inv.credentialSubject.name : '', contactId: pre.contactId, rkid: pre.rkid,
493
+ credDigest, group: gd, name: typeof inv.credentialSubject.name === 'string' ? inv.credentialSubject.name : '',
494
+ ...(at ? { contactId: at.pre.contactId, rkid: at.pre.rkid } : {}),
432
495
  memberLabel: memberLabelOf(gd), invite: doc, promptId: invitePromptId(credDigest), validUntil, status: 'open', at: arr.when,
433
496
  };
434
- const prompt = { id: r.promptId, kind: 'group-invite', contactId: r.contactId, groupId: gd, openedAt: arr.when, expiresAt: validUntil + MEMBERSHIP_SKEW };
497
+ const prompt = { id: r.promptId, kind: 'group-invite', ...(r.contactId !== undefined ? { contactId: r.contactId } : {}), groupId: gd, openedAt: arr.when, expiresAt: validUntil + MEMBERSHIP_SKEW };
435
498
  return {
436
- state: { ...state, joining: { ...state.joining, invites: { ...state.joining.invites, [credDigest]: r } }, prompts: { ...state.prompts, [prompt.id]: prompt } },
499
+ state: {
500
+ ...next,
501
+ joining: { ...next.joining, invites: { ...next.joining.invites, [credDigest]: r }, surfaced: sortedWith(next.joining.surfaced, credDigest) },
502
+ prompts: { ...next.prompts, [prompt.id]: prompt },
503
+ },
437
504
  events: [{ type: 'prompt.opened', promptId: prompt.id, kind: 'group-invite' }],
438
505
  };
439
506
  },
@@ -458,7 +525,7 @@ const acceptDecision = {
458
525
  const doc = await signDoc(ctx, r.memberLabel, me.anchor, body, at);
459
526
  if (!withinBudget(doc))
460
527
  return no('unknown-group', 'RLTP-MT-2220');
461
- const out = await sealed(ctx, doc, { contactId: r.contactId, rkid: rkidOf(ctx.snapshot, r.memberLabel) ?? '', keyAgreement: inv.credentialSubject.card.keyAgreement, anchor: inv.issuer, via: r.rkid });
528
+ const out = await sealed(ctx, doc, { contactId: r.contactId ?? 'member:' + inv.issuer, rkid: rkidOf(ctx.snapshot, r.memberLabel) ?? '', keyAgreement: inv.credentialSubject.card.keyAgreement, anchor: inv.issuer, ...(r.rkid !== undefined ? { via: r.rkid } : {}) });
462
529
  return { ...out, acceptDigest: await digestDoc(doc, ctx.crypto.subtle) };
463
530
  },
464
531
  apply(state, prompt, choice, when, prepared) {
@@ -554,7 +621,7 @@ const acceptTask = {
554
621
  return refuse('validation-failed', bad);
555
622
  const acceptDigest = await digestDoc(doc, ctx.crypto.subtle);
556
623
  const ev = { invite: inv.invite, accept: doc, vouches: [], at: arr.when };
557
- const docs = await admissionDocs(ctx, g, ev, acceptDigest, policyMet(g, ev));
624
+ const docs = await admissionDocs(ctx, g, ev, acceptDigest, policyMet(g, ev) && admissionOpen(inv.invite, arr.when));
558
625
  return { group: g.id, acceptDigest, ...docs };
559
626
  },
560
627
  // the defined effect: the consent recorded (3280); the admission where
@@ -607,7 +674,7 @@ const evidenceTask = (schema) => ({
607
674
  const merged = mergeVouches(held?.vouches ?? [], vouches);
608
675
  const ev = { invite, accept, vouches: merged, at: arr.when };
609
676
  // the inviter admits once the policy holds (the header)
610
- const admit = invite.payload.invite.issuer === g.memberAnchor && !Object.hasOwn(g.roster, accept.payload.accept.subject) && policyMet(g, ev);
677
+ const admit = invite.payload.invite.issuer === g.memberAnchor && !Object.hasOwn(g.roster, accept.payload.accept.subject) && policyMet(g, ev) && admissionOpen(invite, arr.when);
611
678
  const welcome = admit ? (await admissionDocs(ctx, g, ev, acceptDigest, true)).welcome : null;
612
679
  return { group: g.id, acceptDigest, vouches, welcome };
613
680
  },
@@ -717,7 +784,7 @@ const welcomeTask = {
717
784
  const i = acceptedInvite(state, doc);
718
785
  if (!i || state.addresses.keys[rkid]?.label !== i.memberLabel)
719
786
  return refuse('validation-failed');
720
- return { keyAgreement: i.invite.payload.invite.credentialSubject.card.keyAgreement, contactId: i.contactId, via: i.rkid };
787
+ return { keyAgreement: i.invite.payload.invite.credentialSubject.card.keyAgreement, contactId: i.contactId ?? 'member:' + doc.issuer, ...(i.rkid !== undefined ? { via: i.rkid } : {}) };
721
788
  },
722
789
  // the group stars already accepted are matched against the joined group (5520)
723
790
  async prepare(ctx, doc) {
@@ -743,7 +810,7 @@ const welcomeTask = {
743
810
  }
744
811
  const roster = {};
745
812
  for (const m of welcomeRoster(w.roster).values()) {
746
- roster[m.anchor] = { anchor: m.anchor, op: m.op, keyAgreement: m.keyAgreement, ...(m.name !== undefined ? { name: m.name } : {}), addedAt: arr.when, ...(m.anchor === doc.issuer ? { contactId: i.contactId, rkid: i.rkid } : {}) };
813
+ roster[m.anchor] = { anchor: m.anchor, op: m.op, keyAgreement: m.keyAgreement, ...(m.name !== undefined ? { name: m.name } : {}), addedAt: arr.when, ...(m.anchor === doc.issuer && i.contactId !== undefined && i.rkid !== undefined ? { contactId: i.contactId, rkid: i.rkid } : {}) };
747
814
  }
748
815
  const g = {
749
816
  id: w.genesisDigest, name: w.name, did: w.group, genesis: w.genesis, memberLabel: i.memberLabel, rules: { vouchThreshold: w.vouchThreshold },
@@ -761,7 +828,7 @@ const welcomeTask = {
761
828
  // ── keys and retention ─────────────────────────────────────────────────
762
829
  /** The member keys still needed: held groups' and those on the way in
763
830
  * (an answered prelude, an invitation open or accepted). */
764
- function memberKeys(state) {
831
+ function memberKeys(state, when) {
765
832
  const labels = new Set(Object.values(state.groups).map((g) => g.memberLabel));
766
833
  for (const p of Object.values(state.joining.preludes))
767
834
  labels.add(p.memberLabel);
@@ -775,8 +842,11 @@ function memberKeys(state) {
775
842
  for (const g of Object.values(state.groups))
776
843
  for (const p of Object.values(g.preludes))
777
844
  out.push(p.rkid);
845
+ // an answered prelude's tuple, while its answer and the acks may still
846
+ // travel; the binding itself lives longer (3945) and needs no key
778
847
  for (const p of Object.values(state.joining.preludes))
779
- out.push(p.rkid);
848
+ if (p.at + INVITE_LIFE + MEMBERSHIP_SKEW + HORIZON_MAX >= when)
849
+ out.push(p.rkid);
780
850
  return out;
781
851
  }
782
852
  const untilOf = (invite) => Date.parse(invite.payload.invite.validUntil);
@@ -797,12 +867,18 @@ function retainMembership(state, when) {
797
867
  // the accept → admission → welcome chain: two deliveries, each within a give-up horizon
798
868
  const late = (i) => i.validUntil + MEMBERSHIP_SKEW + 2 * HORIZON_MAX < when;
799
869
  const invites = Object.fromEntries(Object.entries(state.joining.invites).filter(([, i]) => !(i.status === 'declined' || (i.status === 'open' && i.validUntil + MEMBERSHIP_SKEW < when) || ((i.status === 'accepted' || i.status === 'joined') && late(i)))));
800
- // an answered prelude no open or accepted invitation still rests on, past an invite's life and the horizon
801
- const rests = (p) => Object.values(invites).some((i) => i.group === p.group && i.contactId === p.contactId && (i.status === 'open' || i.status === 'accepted'));
802
- const preludes = Object.fromEntries(Object.entries(state.joining.preludes).filter(([, p]) => rests(p) || p.at + INVITE_LIFE + MEMBERSHIP_SKEW + HORIZON_MAX >= when));
870
+ // a binding lives while the relationship of its tuple is current or an
871
+ // invitation attributed to it is open or accepted (RLTP-MT-3945); tick
872
+ // deletes it in its own serialized commit, deciding on the state it reads
873
+ // there (3947)
874
+ // an invitation rests on the binding of its key (genesis digest, inviter):
875
+ // a renewal may have moved the binding to a newer tuple since (3930)
876
+ const rests = (p) => Object.values(invites).some((i) => i.group === p.group && i.invite.payload.invite.issuer === p.inviter && (i.status === 'open' || i.status === 'accepted'));
877
+ const current = (p) => { const t = tupleByRkid(state, p.rkid); return t !== null && liveRel(t.rel); };
878
+ const preludes = Object.fromEntries(Object.entries(state.joining.preludes).filter(([, p]) => current(p) || rests(p)));
803
879
  if (Object.keys(invites).length !== Object.keys(state.joining.invites).length || Object.keys(preludes).length !== Object.keys(state.joining.preludes).length)
804
880
  changed = true;
805
- return changed ? { ...state, groups, joining: { preludes, invites } } : state;
881
+ return changed ? { ...state, groups, joining: { ...state.joining, preludes, invites } } : state;
806
882
  }
807
883
  /** When retainMembership lets something go (the timer contract, engine.ts). */
808
884
  function membershipDeadlines(state) {
@@ -821,6 +897,7 @@ function membershipDeadlines(state) {
821
897
  if (i.status === 'accepted' || i.status === 'joined')
822
898
  out.push(i.validUntil + MEMBERSHIP_SKEW + 2 * HORIZON_MAX + 1);
823
899
  }
900
+ // an answered prelude's tuple key leaves keepKeys then (memberKeys)
824
901
  for (const p of Object.values(state.joining.preludes))
825
902
  out.push(p.at + INVITE_LIFE + MEMBERSHIP_SKEW + HORIZON_MAX + 1);
826
903
  return out;