@kubun/plugin-p2p 0.10.1 → 0.12.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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,218 @@
1
+ import { signAccessDefaultSet } from './access-default-token.js';
2
+ import { processBroadcast } from './broadcast.js';
3
+ /**
4
+ * Why this device is not answering a `peer/query`.
5
+ *
6
+ * Declining is a THROW because that is the only silence the lane offers: the
7
+ * broadcast responder writes a reply for whatever a handler returns, and the
8
+ * requester's gather drops replies that carried an error and keeps the rest. So a
9
+ * decline bounds the RESULT, not the traffic — every member still publishes one
10
+ * reply frame — and the spec's "only matching devices reply" is true of what a
11
+ * gather reports, not of what crosses the hub.
12
+ */ class PeerQueryDeclined extends Error {
13
+ constructor(reason){
14
+ super(`peer/query declined: ${reason}`);
15
+ this.name = 'PeerQueryDeclined';
16
+ }
17
+ }
18
+ /**
19
+ * The MLS-authenticated sender of a frame, which group-rpc surfaces at
20
+ * `message.payload.iss`. It builds that context message itself from the sender
21
+ * `unwrap` recovered, so `iss` is an authenticated DID and not a transport-claimed
22
+ * one.
23
+ *
24
+ * Absent when the frame opened at a leaf that could not be named. That is
25
+ * `undefined`, never a sentinel: a placeholder DID would be an identity claim
26
+ * nothing vouched for, and a guard comparing against it would be comparing
27
+ * against a value chosen for never matching rather than one meaning "unknown".
28
+ */ function authenticatedSender(message) {
29
+ const payload = message.payload;
30
+ const iss = payload.iss;
31
+ return typeof iss === 'string' ? iss : undefined;
32
+ }
33
+ /**
34
+ * Build the control + sync procedure handlers for a single group.
35
+ *
36
+ * Each handler is a thin adapter: it maps the typed group-rpc procedure payload
37
+ * back to the {@link GroupBroadcastMessage} the pure {@link processBroadcast}
38
+ * apply logic already understands, re-adding the `groupID` the peer is bound to
39
+ * (group-rpc strips it from the wire because each topic already encodes the
40
+ * group). The apply logic owns verification, conflict resolution, and storage;
41
+ * the handler owns only the shape translation.
42
+ *
43
+ * Event handlers are fire-and-forget. `processBroadcast` already drops an
44
+ * unverifiable or malformed payload and returns `{ applied: false }` rather than
45
+ * throwing, so the result is intentionally ignored. A programmer-error rethrow
46
+ * (or an unexpected store fault) is caught and logged here so a single bad frame
47
+ * never escapes as an unhandled rejection out of the receive loop.
48
+ */ export function buildGroupHandlers(params, groupID) {
49
+ const safeApply = async (apply)=>{
50
+ try {
51
+ await apply();
52
+ } catch (error) {
53
+ params.logger?.warn('group handler apply failed', {
54
+ groupID,
55
+ error
56
+ });
57
+ }
58
+ };
59
+ return {
60
+ control: {
61
+ // Event handlers return the (never-rejecting) apply promise. The procedure
62
+ // contract types them `=> void`, so the server ignores the return; returning
63
+ // the promise is purely so a caller (e.g. a test) that holds the handler
64
+ // directly can await the apply completing.
65
+ 'control/delegationShare': ({ data })=>safeApply(()=>processBroadcast(params, {
66
+ type: 'delegation:share',
67
+ token: data.token,
68
+ groupID,
69
+ hlc: data.hlc
70
+ }, groupID)),
71
+ 'control/delegationRevoke': ({ data })=>safeApply(()=>processBroadcast(params, {
72
+ type: 'delegation:revoke',
73
+ token: data.token,
74
+ groupID,
75
+ hlc: data.hlc
76
+ }, groupID)),
77
+ // Every field the sender put on the frame is carried back verbatim. The
78
+ // signature covers the operation, not just the author, so a handler that
79
+ // filled a field in from context — or dropped one — would hand
80
+ // `processBroadcast` a frame whose token no longer describes it.
81
+ 'control/accessDefaultSet': ({ data })=>safeApply(()=>processBroadcast(params, {
82
+ type: 'access-default:set',
83
+ modelID: data.modelID,
84
+ permissionType: data.permissionType,
85
+ rule: {
86
+ level: data.rule.level,
87
+ allowedDIDs: data.rule.allowedDIDs,
88
+ allowedCircles: data.rule.allowedCircles,
89
+ allowedGroups: data.rule.allowedGroups
90
+ },
91
+ ownerDID: data.ownerDID,
92
+ hlc: data.hlc,
93
+ auth: data.auth
94
+ }, groupID)),
95
+ 'control/accessDefaultRemove': ({ data })=>safeApply(()=>processBroadcast(params, {
96
+ type: 'access-default:remove',
97
+ modelID: data.modelID,
98
+ permissionTypes: data.permissionTypes,
99
+ ownerDID: data.ownerDID,
100
+ hlc: data.hlc,
101
+ auth: data.auth
102
+ }, groupID)),
103
+ 'control/groupLeaveRequest': ({ data })=>safeApply(()=>processBroadcast(params, {
104
+ type: 'group:leaveRequest',
105
+ groupID,
106
+ memberDID: data.memberDID,
107
+ hlc: data.hlc
108
+ }, groupID)),
109
+ // Restate this device's OWN rules for a member that fell below the hub's
110
+ // retention floor and can no longer read the frames that carried them.
111
+ // Signed fresh, but stamped with each row's STORED hlc: the anchor is when
112
+ // the owner decided the rule, so re-stamping here would let a repair win
113
+ // an ordering race against a newer rule the requester already holds.
114
+ 'control/policyCatchup': async ()=>{
115
+ const identity = params.identity;
116
+ if (identity == null) {
117
+ params.logger?.warn('policy catch-up served empty: no signing identity', {
118
+ groupID
119
+ });
120
+ return {
121
+ defaults: []
122
+ };
123
+ }
124
+ const rows = await params.graphStore.listUserModelAccessDefaults(identity.id);
125
+ const defaults = await Promise.all(rows// An unstamped row cannot be ordered against what the requester
126
+ // holds, so restating it could only overwrite by luck.
127
+ .filter((row)=>row.hlc != null).map((row)=>signAccessDefaultSet(identity, {
128
+ modelID: row.modelID,
129
+ permissionType: row.permissionType,
130
+ rule: {
131
+ level: row.level,
132
+ allowedDIDs: row.allowedDIDs,
133
+ allowedCircles: row.allowedCircles,
134
+ allowedGroups: row.allowedGroups
135
+ },
136
+ hlc: row.hlc
137
+ })));
138
+ return {
139
+ defaults
140
+ };
141
+ },
142
+ 'control/ledgerCatchup': async ()=>{
143
+ // The whole ledger this device holds, in ENACTMENT order. Order is
144
+ // load-bearing: the requester checks the list against the authenticated
145
+ // head, which is a chain digest, so a permuted list folds to a different
146
+ // head and is rejected. Only the handle's log records that order — the
147
+ // store's is insertion order, which is why it cannot answer this.
148
+ //
149
+ // A device with no ledger reader serves nothing rather than something
150
+ // unordered: an empty reply fails the requester's head check (unless the
151
+ // ledger genuinely is empty) and costs it only this responder.
152
+ if (params.getGroupLedger == null) {
153
+ params.logger?.warn('ledger catch-up served empty: no ledger reader', {
154
+ groupID
155
+ });
156
+ return {
157
+ entries: []
158
+ };
159
+ }
160
+ const ledger = await params.getGroupLedger(groupID);
161
+ return {
162
+ entries: ledger.map((held)=>held.token)
163
+ };
164
+ }
165
+ },
166
+ sync: {
167
+ 'sync/mutationApply': ({ data, message })=>safeApply(()=>processBroadcast(params, {
168
+ type: 'mutation:apply',
169
+ entries: data.entries,
170
+ senderPeerDID: authenticatedSender(message)
171
+ }, groupID))
172
+ },
173
+ peer: {
174
+ // The announcer comes from the LANE, never the body — `peer/announce`
175
+ // carries no DID field, and the projection row is keyed on what arrives
176
+ // here. Adding a convenience `peerDID` to the body and reading it instead
177
+ // would let any member write any other member's row.
178
+ 'peer/announce': ({ data, message })=>safeApply(()=>processBroadcast(params, {
179
+ type: 'peer:announce',
180
+ label: data.label,
181
+ availability: data.availability,
182
+ capabilities: data.capabilities,
183
+ hlc: data.hlc,
184
+ announcerDID: authenticatedSender(message)
185
+ }, groupID)),
186
+ // A reply IS an announcement: this device's OWN projection row, read back
187
+ // verbatim under the stamp it was announced with. So the gathered copy and
188
+ // the broadcast copy of one announcement are the same row on the receiver,
189
+ // and a reply cannot become the one path where a body-carried identity
190
+ // sneaks in — the row holds no DID field to send.
191
+ 'peer/query': async ({ param })=>{
192
+ const own = await params.p2pStore.getPeerAnnouncement({
193
+ groupID,
194
+ peerDID: params.selfDID
195
+ });
196
+ if (own == null) {
197
+ // Never announced in this group, so there is nothing true to say. A
198
+ // fabricated reply would put a capability claim on the wire that no
199
+ // announcement backs.
200
+ throw new PeerQueryDeclined('this device has not announced in this group');
201
+ }
202
+ const requested = param.protocols;
203
+ if (requested != null && !own.capabilities.some((capability)=>requested.includes(capability.protocol))) {
204
+ throw new PeerQueryDeclined('no capability matches the requested protocols');
205
+ }
206
+ return {
207
+ label: own.label,
208
+ // Widened by the store column, narrowed back here: the row was written
209
+ // from a schema-validated frame (or from this device's own profile,
210
+ // validated the same way), so the value is one of the three.
211
+ availability: own.availability,
212
+ capabilities: own.capabilities,
213
+ hlc: own.hlc
214
+ };
215
+ }
216
+ }
217
+ };
218
+ }
@@ -1,3 +1,4 @@
1
+ import { type Logger } from '@kubun/logger';
1
2
  import type { GroupHealthCondition, GroupHealthSnapshot, GroupHealthState } from '../types.js';
2
3
  import type { P2PEventEmitter } from './events.js';
3
4
  export type GroupHealthMonitorParams = {
@@ -8,8 +9,18 @@ export type GroupHealthMonitorParams = {
8
9
  };
9
10
  /** Clock for `lastTransitionAt` stamps. Defaults to `Date.now`. */
10
11
  now?: () => number;
12
+ /** Logger for a throwing `groupHealthChanged` subscriber. Defaults to the
13
+ * `group-health-monitor` namespace. */
14
+ logger?: Logger;
11
15
  };
12
16
  /**
17
+ * INERT: the mutating producers (`signal`/`reset`/`mark*`) have no live caller,
18
+ * so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
19
+ * This is the front-end of the epoch-stale-recovery feature, deliberately
20
+ * dormant until tier-2 Welcome re-admission exists — see
21
+ * docs/agents/plans/backlog/recovery-rendezvous-transport.md. Kept (not deleted)
22
+ * because that feature is roadmapped and this scaffolding is design-aligned.
23
+ *
13
24
  * Per-group health state machine. Counts/latches failure signals per
14
25
  * `(groupID, condition)` and emits `groupHealthChanged` on transitions.
15
26
  * Crypto- and persistence-free: callers classify failures before signalling.
@@ -1 +1,139 @@
1
- export class GroupHealthMonitor{#e;#t;#s;#h=new Map;#a=new Map;constructor(e){this.#e=e.emitter,this.#t=e.epochStale?.threshold??3,this.#s=e.now??Date.now}signal(e,t){let s=this.#r(e,t);"healthy"===s.phase&&(s.count+=1,s.count>=this.#i(t)&&(s.phase="degraded",this.#o(e),this.#l(e,t,"degraded")))}reset(e,t){let s=this.#r(e,t);s.count=0,"healthy"!==s.phase&&(s.phase="healthy",this.#o(e))}markRecovering(e){this.#r(e,"epoch-stale").phase="recovering",this.#o(e),this.#l(e,"epoch-stale","recovering")}markRecovered(e){let t=this.#r(e,"epoch-stale");t.count=0,t.phase="healthy",this.#o(e),this.#l(e,"epoch-stale","recovered")}markRecoveryFailed(e){this.#r(e,"epoch-stale").phase="recovery-failed",this.#o(e),this.#l(e,"epoch-stale","recovery-failed")}getState(e){let t=this.#h.get(e);if(null==t)return"healthy";for(let e of t.values())if("healthy"!==e.phase)return e.phase;return"healthy"}getHealth(e){return{state:this.getState(e),lastTransitionAt:this.#a.get(e)??null}}#o(e){this.#a.set(e,this.#s())}#i(e){return"epoch-stale"===e?this.#t:1/0}#r(e,t){let s=this.#h.get(e);null==s&&(s=new Map,this.#h.set(e,s));let h=s.get(t);return null==h&&(h={phase:"healthy",count:0},s.set(t,h)),h}#l(e,t,s){this.#e.emit("groupHealthChanged",{groupID:e,condition:t,state:s})}}
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ const DEFAULT_EPOCH_STALE_THRESHOLD = 3;
3
+ /**
4
+ * INERT: the mutating producers (`signal`/`reset`/`mark*`) have no live caller,
5
+ * so `getHealth` always reports `healthy` and `groupHealthChanged` never fires.
6
+ * This is the front-end of the epoch-stale-recovery feature, deliberately
7
+ * dormant until tier-2 Welcome re-admission exists — see
8
+ * docs/agents/plans/backlog/recovery-rendezvous-transport.md. Kept (not deleted)
9
+ * because that feature is roadmapped and this scaffolding is design-aligned.
10
+ *
11
+ * Per-group health state machine. Counts/latches failure signals per
12
+ * `(groupID, condition)` and emits `groupHealthChanged` on transitions.
13
+ * Crypto- and persistence-free: callers classify failures before signalling.
14
+ *
15
+ * State is in-memory per-process — a restart re-derives it from the next signal.
16
+ * Transitions are synchronous (no `await`), so calls cannot interleave; signals
17
+ * arriving while a rejoin is in flight (`recovering`) are ignored — the recovery
18
+ * owns the group until it resolves.
19
+ */ export class GroupHealthMonitor {
20
+ #emitter;
21
+ #epochStaleThreshold;
22
+ #now;
23
+ #logger;
24
+ // groupID -> condition -> per-condition state
25
+ #groups = new Map();
26
+ // groupID -> epoch ms of the last transition affecting its aggregate state
27
+ #lastTransitionAt = new Map();
28
+ constructor(params){
29
+ this.#emitter = params.emitter;
30
+ this.#epochStaleThreshold = params.epochStale?.threshold ?? DEFAULT_EPOCH_STALE_THRESHOLD;
31
+ this.#now = params.now ?? Date.now;
32
+ this.#logger = params.logger ?? getKubunLogger('group-health-monitor');
33
+ }
34
+ /**
35
+ * Record a failure observation for a condition. Below the condition's
36
+ * threshold this only increments the counter; on reaching it the group
37
+ * transitions to `degraded` (emitted once, then latched). Ignored while the
38
+ * group is `recovering` (the rejoin owns it) or already `degraded`/failed.
39
+ */ signal(groupID, condition) {
40
+ const state = this.#ensure(groupID, condition);
41
+ if (state.phase !== 'healthy') {
42
+ // non-healthy: further failures are expected, don't re-emit.
43
+ return;
44
+ }
45
+ state.count += 1;
46
+ if (state.count >= this.#thresholdFor(condition)) {
47
+ state.phase = 'degraded';
48
+ this.#stamp(groupID);
49
+ this.#emit(groupID, condition, 'degraded');
50
+ }
51
+ }
52
+ /**
53
+ * Clear a condition to healthy and re-arm (counter reset), driven by the
54
+ * success path. Silent: only recovery (via the mark* methods) is surfaced.
55
+ */ reset(groupID, condition) {
56
+ const state = this.#ensure(groupID, condition);
57
+ state.count = 0;
58
+ if (state.phase !== 'healthy') {
59
+ state.phase = 'healthy';
60
+ this.#stamp(groupID);
61
+ }
62
+ }
63
+ markRecovering(groupID) {
64
+ const state = this.#ensure(groupID, 'epoch-stale');
65
+ state.phase = 'recovering';
66
+ this.#stamp(groupID);
67
+ this.#emit(groupID, 'epoch-stale', 'recovering');
68
+ }
69
+ /** Emit the transient `recovered` event, then settle the phase to `healthy`. */ markRecovered(groupID) {
70
+ const state = this.#ensure(groupID, 'epoch-stale');
71
+ state.count = 0;
72
+ state.phase = 'healthy';
73
+ this.#stamp(groupID);
74
+ this.#emit(groupID, 'epoch-stale', 'recovered');
75
+ }
76
+ /** Leave the group `recovery-failed` until a later success resets it. */ markRecoveryFailed(groupID) {
77
+ const state = this.#ensure(groupID, 'epoch-stale');
78
+ state.phase = 'recovery-failed';
79
+ this.#stamp(groupID);
80
+ this.#emit(groupID, 'epoch-stale', 'recovery-failed');
81
+ }
82
+ /** Aggregate state: the first non-healthy condition phase, else `healthy`. */ getState(groupID) {
83
+ const conditions = this.#groups.get(groupID);
84
+ if (conditions == null) {
85
+ return 'healthy';
86
+ }
87
+ for (const state of conditions.values()){
88
+ if (state.phase !== 'healthy') {
89
+ return state.phase;
90
+ }
91
+ }
92
+ return 'healthy';
93
+ }
94
+ getHealth(groupID) {
95
+ return {
96
+ state: this.getState(groupID),
97
+ lastTransitionAt: this.#lastTransitionAt.get(groupID) ?? null
98
+ };
99
+ }
100
+ #stamp(groupID) {
101
+ this.#lastTransitionAt.set(groupID, this.#now());
102
+ }
103
+ #thresholdFor(condition) {
104
+ // Only `epoch-stale` exists today; switch when more conditions land.
105
+ return condition === 'epoch-stale' ? this.#epochStaleThreshold : Number.POSITIVE_INFINITY;
106
+ }
107
+ #ensure(groupID, condition) {
108
+ let conditions = this.#groups.get(groupID);
109
+ if (conditions == null) {
110
+ conditions = new Map();
111
+ this.#groups.set(groupID, conditions);
112
+ }
113
+ let state = conditions.get(condition);
114
+ if (state == null) {
115
+ state = {
116
+ phase: 'healthy',
117
+ count: 0
118
+ };
119
+ conditions.set(condition, state);
120
+ }
121
+ return state;
122
+ }
123
+ #emit(groupID, condition, state) {
124
+ // Enkaku's `emit` rethrows a throwing subscriber; catch so it can't leak an
125
+ // unhandled rejection.
126
+ this.#emitter.emit('groupHealthChanged', {
127
+ groupID,
128
+ condition,
129
+ state
130
+ }).catch((error)=>{
131
+ this.#logger.error('groupHealthChanged listener failed', {
132
+ groupID,
133
+ condition,
134
+ state,
135
+ error
136
+ });
137
+ });
138
+ }
139
+ }
@@ -0,0 +1,66 @@
1
+ import { type OwnIdentity } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import { HLC } from '@kubun/hlc';
4
+ import { type Logger } from '@kubun/logger';
5
+ import type { GroupMLS } from '@kumiai/rpc';
6
+ import type { P2PEventEmitter } from './events.js';
7
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
8
+ export type CreateGroupMLSParams = {
9
+ /** Single canonical access point for the device's MLS `GroupHandle`. */
10
+ registry: GroupHandleRegistry;
11
+ /**
12
+ * The device's own signing identity. The recovery members sign under it — the
13
+ * request token, the membership attestation on a sealed reply, and the
14
+ * external commit that rejoins — and the commit-apply path reads its DID to
15
+ * recognise a removal that names THIS device.
16
+ *
17
+ * Held rather than passed per call, which departs from how the rest of this
18
+ * package takes an identity ({@link GroupManager}'s methods each receive one
19
+ * from the request that drove them). Recovery has no such request: the lane
20
+ * mints a gather on its own, off a drain that no user mutation is waiting on,
21
+ * so there is no caller to take the key from at the moment it is needed.
22
+ */
23
+ identity: OwnIdentity;
24
+ /**
25
+ * Store coordinator used to open the receive-side commit transaction so the
26
+ * MLS ratchet advance and the roster delta writes commit (or roll back)
27
+ * together. Without one transaction, a roster-write failure after the ratchet
28
+ * persists would silently keep a removed member's writes applying.
29
+ */
30
+ stores: StoreProvider;
31
+ /**
32
+ * Device-wide monotonic clock for stamping the `removed_at_hlc` of members
33
+ * dropped (and the `hlc` of members added) by a received MLS Commit. Pass the
34
+ * engine's single instance so every write from this device advances one shared
35
+ * clock, preventing two same-millisecond writes from minting identical
36
+ * timestamps (the second would silently lose under last-writer-wins).
37
+ */
38
+ hlc: HLC;
39
+ groupID: string;
40
+ /**
41
+ * Emitter for the domain events a landed commit's control entries drive
42
+ * (circle created/changed/deleted, circle membership, group settings) — the
43
+ * same events a broadcast-delivered entry fires. Optional: a caller that
44
+ * never surfaces those changes to subscribers (and existing tests) omits it
45
+ * and the fold still writes the projections.
46
+ */
47
+ emitter?: P2PEventEmitter;
48
+ logger?: Logger;
49
+ };
50
+ /**
51
+ * Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
52
+ * {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
53
+ * read the roster, seal this handle's ledger for another member's gather, and
54
+ * expose the epoch-independent recovery seed. group-rpc owns the transport +
55
+ * orchestration (subscribe the non-rotating handshake topic, run these methods,
56
+ * resync the app lane when the epoch advances); this adapter owns the MLS state,
57
+ * storage, and atomicity.
58
+ *
59
+ * The recovery members that throw below do so for reasons that live in KUBUN,
60
+ * not upstream: `@kumiai/mls` ships every primitive they would need. An earlier
61
+ * revision of this file justified the same throws by upstream gaps, and a later
62
+ * upstream release quietly made those justifications false. So a reader deciding
63
+ * whether one of these can now be wired must re-check the KUBUN-side statement
64
+ * in each message — not assume the blocker moved when a dependency did.
65
+ */
66
+ export declare function createGroupMLS(params: CreateGroupMLSParams): GroupMLS;