@optimystic/db-core 0.22.0 → 0.24.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 (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. package/dist/src/transaction/context.js.map +0 -1
@@ -1,416 +1,416 @@
1
- /**
2
- * Cohort-topic substrate — participant-facing service composition.
3
- *
4
- * This is the substrate's public contract to applications (`docs/cohort-topic.md` §Application
5
- * policies): given a `topicId` and a `tier`, reliably find a willing primary (or fail with a clear
6
- * back-off), keep a registration alive within its TTL, and verify cohort identity/membership. It is
7
- * the participant half of the substrate; the cohort half is {@link import("./member-engine.js").CohortMemberEngine}.
8
- *
9
- * The service is FRET-free: it drives the {@link ITopicRouter} (walk / register / direct-dial) and the
10
- * other db-core ports by injection. db-p2p binds those ports to FRET + libp2p and constructs the
11
- * service on the same node it runs the member engine on, so a node is simultaneously a participant and
12
- * a cohort member (`docs/cohort-topic.md` §FRET integration — all four protocols on one node).
13
- *
14
- * Composition wired here: {@link WalkEngine} (lookup / register), the `d_max` computer + tier
15
- * addressing, the participant-side {@link RenewalParticipant} (the `ttl/3` ping with crash-failover),
16
- * and the application integration hooks ({@link CohortGossipBus}, {@link MembershipVerifier}). The
17
- * cohort-side modules (willingness, promotion, traffic, store, membership publisher, anti-DoS) are
18
- * assembled by the member engine the FRET host wires into the `RouteAndMaybeAct` activity callback.
19
- */
20
-
21
- import { randomBytes } from "@noble/hashes/utils.js";
22
- import { createTierAddressing, type TierAddressing } from "./addressing.js";
23
- import { makeDMaxComputer, type DMaxComputer } from "./dmax.js";
24
- import { DEFAULT_FANOUT } from "./addressing.js";
25
- import type { ISizeEstimator, ITopicRouter, IRingHash, RingCoord } from "./ports.js";
26
- import { createWalkEngine, type RegisterMessageFactory, type WalkEngine, type WalkOutcome } from "./walk.js";
27
- import { createRenewalParticipant, type RenewalParticipant, type RenewalParticipantTransport, type UnsignedRenew } from "./registration/renewal.js";
28
- import type { RegistrationRecord } from "./registration/types.js";
29
- import { DEFAULT_TTL_MS } from "./registration/types.js";
30
- import { recordKey } from "./registration/bytes.js";
31
- import type { CohortGossipBus } from "./gossip/bus.js";
32
- import type { MembershipVerifier } from "./membership/verifier.js";
33
- import type { Tier } from "./tiers.js";
34
- import { bytesToB64url, b64urlToBytes, decodeRenewReplyV1, encodeCohortMessage } from "./wire/codec.js";
35
- import type { RegisterReplyV1, RegisterV1, RenewReplyV1, TopicTrafficV1 } from "./wire/types.js";
36
- import type { CollectionChangeEvent, CommitCert } from "../transactor/change-notifier.js";
37
-
38
- /** A resolved cohort for a topic/tier — the return of {@link CohortTopicService.lookup}. */
39
- export interface CohortHint {
40
- readonly topicId: Uint8Array;
41
- readonly tier: Tier;
42
- /** Serving cohort member. */
43
- readonly primary: Uint8Array;
44
- /** Warm-failover cohort members (1..2). */
45
- readonly backups: Uint8Array[];
46
- readonly cohortEpoch: Uint8Array;
47
- /** Full cohort member set, for client-side caching. */
48
- readonly cohortMembers: Uint8Array[];
49
- /** Coarse traffic barometer, when the cohort attached one. */
50
- readonly topicTraffic?: TopicTrafficV1;
51
- }
52
-
53
- /** A live registration: a {@link CohortHint} plus the participant-side renewal handle behind it. */
54
- export interface RegistrationHandle extends CohortHint {
55
- /** Internal renewal driver (the `ttl/3` ping loop). Opaque to applications. */
56
- readonly renewal: RenewalParticipant;
57
- }
58
-
59
- /**
60
- * The substrate's **origination hook**: a commit that lands on a node which is a cohort member for the
61
- * collection's reactivity topic is delivered here by the local change-notifier bridge
62
- * (`local-change-notifier-bridge`), carrying the raw {@link CollectionChangeEvent} and the
63
- * pass-through {@link CommitCert} (extracted from cluster consensus, forwarded UNCHANGED). Reactivity
64
- * (and later matchmaking) set this hook to fan the event out — reusing `commitCert.thresholdSig`
65
- * directly, never re-signing. The bridge swallows + logs any throw so origination can never break the
66
- * commit.
67
- */
68
- export type LocalChangeHook = (event: CollectionChangeEvent, commitCert: CommitCert) => void;
69
-
70
- /** Thrown when the substrate cannot place a registration right now; carries the back-off delay. */
71
- export class CohortBackoffError extends Error {
72
- constructor(readonly afterMs: number) {
73
- super(`cohort-topic: no willing primary right now; retry after ${afterMs}ms`);
74
- this.name = "CohortBackoffError";
75
- }
76
- }
77
-
78
- /** A registration request (`docs/cohort-topic.md` §Application policies). */
79
- export interface RegisterRequest {
80
- readonly topicId: Uint8Array;
81
- readonly tier: Tier;
82
- /** Opaque application slot (reactivity / matchmaking define the contents). */
83
- readonly appPayload?: Uint8Array;
84
- /** Registration TTL (ms); defaults to the tier default. */
85
- readonly ttl?: number;
86
- /** Mark this a cold-root bootstrap request. */
87
- readonly bootstrap?: boolean;
88
- }
89
-
90
- /** The substrate's participant-facing contract. */
91
- export interface CohortTopicService {
92
- /** Walk → register for `topicId` at `tier`; resolves a live {@link RegistrationHandle} or throws {@link CohortBackoffError}. */
93
- register(req: RegisterRequest): Promise<RegistrationHandle>;
94
- /** Run one `ttl/3` renewal cycle for `handle` (handles `primary_moved` + crash-failover). */
95
- renew(handle: RegistrationHandle): Promise<void>;
96
- /** Resolve the cohort for `topicId` at `tier` without keeping a live registration. */
97
- lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint>;
98
- /** Stop renewing `handle` and send a best-effort signed withdraw tombstone so the cohort frees the
99
- * record immediately (TTL expiry remains the fallback if the primary is unreachable). */
100
- withdraw(handle: RegistrationHandle): Promise<void>;
101
- /** Origination hook the change-notifier bridge invokes per local member commit; see {@link LocalChangeHook}. */
102
- onLocalCommit?: LocalChangeHook;
103
- /** Cohort gossip bus — applications fold app state into the existing gossip. */
104
- cohortGossip(): CohortGossipBus;
105
- /** Membership verifier — applications verify threshold-signed app messages. */
106
- verifier(): MembershipVerifier;
107
- }
108
-
109
- /**
110
- * Signs the participant's outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the peer key).
111
- *
112
- * Async because the underlying libp2p `PrivateKey.sign` is async; both call sites already `await`
113
- * (`messageFactory.build` and the renewal `sign` hook). The signature is over the canonical body
114
- * image ({@link import("./wire/payloads.js").registerSigningPayload} /
115
- * {@link import("./wire/payloads.js").renewSigningPayload}) so a cohort member can recompute and
116
- * verify it against the participant's peer key.
117
- */
118
- export interface ParticipantSigner {
119
- /** Sign a `RegisterV1` (minus its signature); resolves the base64url signature. */
120
- signRegister(body: Omit<RegisterV1, "signature">): Promise<string>;
121
- /** Sign a `RenewV1` (minus its signature); resolves the base64url signature. */
122
- signRenew(body: UnsignedRenew): Promise<string>;
123
- }
124
-
125
- /** Per-service tunables (cohort size, threshold, fan-out, TTL); all optional. */
126
- export interface CohortServiceConfig {
127
- /** Fan-out per tier `F`. Default {@link DEFAULT_FANOUT}. */
128
- readonly fanout?: number;
129
- /** Requested cohort size `wantK`. Default 16. */
130
- readonly wantK?: number;
131
- /** Threshold signers `minSigs = k − x`. Default 14. */
132
- readonly minSigs?: number;
133
- /** Default registration TTL (ms). Default {@link DEFAULT_TTL_MS}. */
134
- readonly ttl?: number;
135
- /** Frame ceiling for encode/decode. Defaults to the codec default. */
136
- readonly maxMessageBytes?: number;
137
- }
138
-
139
- export interface CohortTopicServiceDeps {
140
- /** This participant's peer id (the `P` in `coord_d(P, topicId)`). */
141
- readonly self: Uint8Array;
142
- /** Hash + ring math (db-core's own SHA-256). */
143
- readonly hash: IRingHash;
144
- /** FRET-backed router (walk / register / direct-dial). */
145
- readonly router: ITopicRouter;
146
- /** FRET-backed network-size estimator feeding `d_max`. */
147
- readonly sizeEstimator: ISizeEstimator;
148
- /** Participant body signer. */
149
- readonly signer: ParticipantSigner;
150
- /** Cohort gossip bus (the host constructs it for this node's cohort). */
151
- readonly gossipBus: CohortGossipBus;
152
- /** Participant-side membership verifier. */
153
- readonly verifier: MembershipVerifier;
154
- /** Monotonic-ish wall clock (unix ms); injectable for tests. Default `Date.now`. */
155
- readonly clock?: () => number;
156
- /**
157
- * Optional cold-start bootstrap-evidence builder (db-p2p supplies it). Invoked on **either** cold-start
158
- * re-issue — the root `bootstrap: true` or the deeper-tier `followOn: true` (both gated by the same
159
- * evidence policy) — with the register's own canonical fields, the same
160
- * `(topicId, tier, participantCoord, timestamp)` tuple a verifier binds via
161
- * `bootstrapBoundImage`. It returns the **raw** envelope JSON bytes — `utf8(JSON.stringify(env))`,
162
- * NOT the already-base64url string from `serializeBootstrapEvidenceEnvelope` — or `undefined` to
163
- * attach none; the service base64url-encodes those bytes into `RegisterV1.bootstrapEvidence`
164
- * **before** the body is signed, so the participant signature covers them and a MITM cannot strip or
165
- * swap the proof. (Returning the `serialize()` string's bytes would double-encode the field.)
166
- * Absent (default) → no evidence is attached, exactly today's behavior. The minting logic (PoW
167
- * search, reputation/parent-ref signing) is the db-p2p follow-on.
168
- */
169
- readonly buildBootstrapEvidence?: (params: {
170
- readonly topicId: string;
171
- readonly tier: number;
172
- readonly participantCoord: string;
173
- readonly timestamp: number;
174
- }) => Promise<Uint8Array | undefined>;
175
- readonly config?: CohortServiceConfig;
176
- }
177
-
178
- class WalkRegisterService implements CohortTopicService {
179
- public onLocalCommit?: LocalChangeHook;
180
-
181
- private readonly addressing: TierAddressing;
182
- private readonly dmax: DMaxComputer;
183
- private readonly walk: WalkEngine;
184
- private readonly clock: () => number;
185
- private readonly ttl: number;
186
- private readonly maxMessageBytes?: number;
187
- /** Live renewal drivers, keyed by `(topicId, participantId)` so renew/withdraw find their handle. */
188
- private readonly renewals = new Map<string, RenewalParticipant>();
189
- private readonly participantId: Uint8Array;
190
-
191
- constructor(private readonly deps: CohortTopicServiceDeps) {
192
- const cfg = deps.config ?? {};
193
- const fanout = cfg.fanout ?? DEFAULT_FANOUT;
194
- this.clock = deps.clock ?? ((): number => Date.now());
195
- this.ttl = cfg.ttl ?? DEFAULT_TTL_MS;
196
- this.maxMessageBytes = cfg.maxMessageBytes;
197
- // The participant identity carried on the wire (`participantCoord` on register, `participantId`
198
- // on renew) IS `self` — the dialable peer id db-p2p supplies via its peer-codec. It is the
199
- // record key the cohort stores AND the signer id its peer-key signature verifies against, so it
200
- // must round-trip back to a peer id rather than being re-hashed here (§Tier addressing names the
201
- // routing coord `P = self` directly; slot assignment hashes it internally for uniformity).
202
- this.participantId = deps.self;
203
- this.addressing = createTierAddressing(deps.hash, fanout);
204
- this.dmax = makeDMaxComputer({ estimator: deps.sizeEstimator, F: fanout });
205
- this.walk = createWalkEngine({
206
- router: deps.router,
207
- addressing: this.addressing,
208
- dmax: this.dmax,
209
- self: deps.self,
210
- factory: this.messageFactory(),
211
- config: { wantK: cfg.wantK, minSigs: cfg.minSigs, maxMessageBytes: cfg.maxMessageBytes },
212
- });
213
- }
214
-
215
- async register(req: RegisterRequest): Promise<RegistrationHandle> {
216
- const outcome = await this.walk.register(req.topicId, req.tier, req.appPayload);
217
- return this.handleFromOutcome(req, outcome);
218
- }
219
-
220
- async lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint> {
221
- // A read-only probe: it walks to the responsible cohort exactly as a register would and returns the
222
- // same cohort snapshot, but admits NOTHING — no soft-state record, no arrival, no promotion trigger,
223
- // no topic-budget touch, and never a cold-start instantiation. A topic served nowhere resolves to a
224
- // `CohortBackoffError` (the probe never bootstraps a cold root), so a lookup leaves no throwaway
225
- // registration behind to TTL-expire (§Application policies; the lookup-as-register interim is gone).
226
- const outcome = await this.walk.register(topicId, tier, undefined, { probe: true });
227
- if (outcome.kind !== "accepted") {
228
- throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
229
- }
230
- return this.hintFromReply(topicId, tier, outcome.reply);
231
- }
232
-
233
- async renew(handle: RegistrationHandle): Promise<void> {
234
- // Act only if this handle is still the live entry. A stale handle (superseded by a second
235
- // register() for the same pair) must not drive the new registration's ping loop, and a
236
- // withdrawn handle must not silently re-start it.
237
- if (this.renewals.get(recordKey(handle.topicId, this.participantId)) !== handle.renewal) {
238
- return;
239
- }
240
- await handle.renewal.pingLoop();
241
- this.syncHandle(handle);
242
- }
243
-
244
- async withdraw(handle: RegistrationHandle): Promise<void> {
245
- // Two halves: (1) drop the handle from the live set so further renew() pings no-op (see renew()),
246
- // stopping the local ping loop; (2) fire a best-effort signed withdraw tombstone to the current
247
- // primary so the cohort frees the record immediately instead of holding it for up to a full TTL.
248
- // The delete happens FIRST so a concurrent renew() already no-ops before the tombstone is sent. If
249
- // the tombstone send fails (primary unreachable), the cohort soft-state TTL-expires as the
250
- // fallback — withdraw never throws on a transport failure. Idempotent: a second withdraw finds
251
- // `renewal !== handle.renewal` (undefined ≠ any object) and no-ops.
252
- // Guard: act only if this handle is still the live entry. A stale handle must not evict the
253
- // new registration or send a tombstone on its behalf.
254
- const key = recordKey(handle.topicId, this.participantId);
255
- const renewal = this.renewals.get(key);
256
- if (renewal !== handle.renewal) {
257
- return;
258
- }
259
- this.renewals.delete(key);
260
- await renewal.withdraw();
261
- }
262
-
263
- cohortGossip(): CohortGossipBus {
264
- return this.deps.gossipBus;
265
- }
266
-
267
- verifier(): MembershipVerifier {
268
- return this.deps.verifier;
269
- }
270
-
271
- // --- internals ---
272
-
273
- private handleFromOutcome(req: RegisterRequest, outcome: WalkOutcome): RegistrationHandle {
274
- if (outcome.kind !== "accepted") {
275
- throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
276
- }
277
- const hint = this.hintFromReply(req.topicId, req.tier, outcome.reply);
278
- const renewal = this.startRenewal(req, hint, outcome.correlationId);
279
- return { ...hint, renewal };
280
- }
281
-
282
- private startRenewal(req: RegisterRequest, hint: CohortHint, correlationId: string): RenewalParticipant {
283
- const ttl = req.ttl ?? this.ttl;
284
- const initial: RegistrationRecord = {
285
- topicId: req.topicId,
286
- participantId: this.participantId,
287
- tier: req.tier,
288
- primary: hint.primary,
289
- backups: hint.backups,
290
- attachedAt: this.clock(),
291
- lastPing: this.clock(),
292
- ttl,
293
- appState: req.appPayload,
294
- };
295
- const transport = this.renewalTransport(req);
296
- const renewal = createRenewalParticipant(initial, {
297
- transport,
298
- clock: this.clock,
299
- sign: (body: UnsignedRenew): Promise<string> => this.deps.signer.signRenew(body),
300
- // Echo the accepted RegisterV1's correlationId: RenewV1 `correlationId` "matches original
301
- // RegisterV1" (docs §Wire, RenewV1), so a future renew-path freshness/replay guard can correlate
302
- // a renew back to the registration it renews.
303
- // NOTE: every periodic renew for this registration shares this ONE correlationId (as it always
304
- // has — this only makes the shared id equal the register's, not an independent nonce). A future
305
- // renew replay guard must therefore key renews by (correlationId, timestamp-window), NOT "drop
306
- // any repeated correlationId" — else it would reject the 2nd+ legitimate renew.
307
- correlationId,
308
- initialCohortEpoch: hint.cohortEpoch,
309
- });
310
- // NOTE: a second register() for the same (topicId, participantId) overwrites the map entry here,
311
- // orphaning the prior renewal. renew()/withdraw() on the stale handle now no-op (identity guard),
312
- // but no tombstone is sent for the superseded record — the cohort frees it via TTL expiry. Harmless
313
- // for occasional re-registers; if callers ever churn-register the same pair, send a withdraw for the
314
- // displaced renewal here so the cohort reclaims immediately instead of holding up to a full TTL.
315
- this.renewals.set(recordKey(req.topicId, this.participantId), renewal);
316
- return renewal;
317
- }
318
-
319
- /** Renewal transport: dial the cached primary directly; a full failure re-runs the register walk. */
320
- private renewalTransport(req: RegisterRequest): RenewalParticipantTransport {
321
- return {
322
- send: async (target: Uint8Array, msg): Promise<RenewReplyV1> => {
323
- const raw = await this.deps.router.dialMember({ id: target }, encodeCohortMessage(msg, this.maxMessageBytes));
324
- return decodeRenewReplyV1(raw, this.maxMessageBytes);
325
- },
326
- relookup: async (): Promise<void> => {
327
- await this.walk.register(req.topicId, req.tier, req.appPayload);
328
- },
329
- };
330
- }
331
-
332
- private hintFromReply(topicId: Uint8Array, tier: Tier, reply: RegisterReplyV1): CohortHint {
333
- if (reply.primary === undefined || reply.cohortEpoch === undefined) {
334
- throw new CohortBackoffError(0);
335
- }
336
- return {
337
- topicId,
338
- tier,
339
- primary: b64urlToBytes(reply.primary),
340
- backups: (reply.backups ?? []).map(b64urlToBytes),
341
- cohortEpoch: b64urlToBytes(reply.cohortEpoch),
342
- cohortMembers: (reply.cohortMembers ?? []).map(b64urlToBytes),
343
- topicTraffic: reply.topicTraffic,
344
- };
345
- }
346
-
347
- private syncHandle(handle: RegistrationHandle): void {
348
- const rec = handle.renewal.record;
349
- (handle as { primary: Uint8Array }).primary = rec.primary;
350
- (handle as { backups: Uint8Array[] }).backups = [...rec.backups];
351
- const epoch = handle.renewal.cohortEpochHint;
352
- if (epoch !== undefined) {
353
- (handle as { cohortEpoch: Uint8Array }).cohortEpoch = epoch;
354
- }
355
- }
356
-
357
- /** The per-probe `RegisterV1` builder: stamps participant coord/ttl/correlation and signs. */
358
- private messageFactory(): RegisterMessageFactory {
359
- const participantCoord = bytesToB64url(this.participantId);
360
- return {
361
- build: async (params): Promise<RegisterV1> => {
362
- const body: Omit<RegisterV1, "signature"> = {
363
- v: 1,
364
- topicId: bytesToB64url(params.topicId),
365
- tier: params.tier,
366
- treeTier: params.treeTier,
367
- participantCoord,
368
- ttl: this.ttl,
369
- timestamp: this.clock(),
370
- correlationId: this.freshCorrelationId(),
371
- };
372
- if (params.probe) {
373
- // A read-only lookup probe. Mutually exclusive with bootstrap/followOn (the walk never sets
374
- // either on a probe), so the cold-start-evidence branch below cannot run for a probe.
375
- body.probe = true;
376
- }
377
- // A cold-start re-issue — root (`bootstrap`) or deeper-tier follow-on (`followOn`). Both are
378
- // gated by the identical evidence policy (§Anti-DoS), so both mint and attach the same envelope
379
- // bound to the body's own canonical fields. Mutually exclusive with each other and with probe.
380
- if (params.bootstrap || params.followOn) {
381
- if (params.followOn) {
382
- body.followOn = true;
383
- } else {
384
- body.bootstrap = true;
385
- }
386
- // Attach cold-start evidence BEFORE signing (so the signature covers it). Bind the
387
- // builder to the body's own canonical fields — the exact tuple a verifier reconstructs.
388
- const evidence = await this.deps.buildBootstrapEvidence?.({
389
- topicId: body.topicId,
390
- tier: body.tier,
391
- participantCoord: body.participantCoord,
392
- timestamp: body.timestamp,
393
- });
394
- if (evidence !== undefined && evidence.length > 0) {
395
- body.bootstrapEvidence = bytesToB64url(evidence);
396
- }
397
- }
398
- if (params.appPayload !== undefined) {
399
- body.appPayload = bytesToB64url(params.appPayload);
400
- }
401
- return { ...body, signature: await this.deps.signer.signRegister(body) };
402
- },
403
- };
404
- }
405
-
406
- /** 16 fresh CSPRNG bytes, base64url. A correlation id must be unique per probe — the replay guard
407
- * keys on it — so it cannot be derived from the clock (two probes in the same ms would collide). */
408
- private freshCorrelationId(): string {
409
- return bytesToB64url(randomBytes(16));
410
- }
411
- }
412
-
413
- /** Build the participant-facing {@link CohortTopicService} over the injected ports. */
414
- export function createCohortTopicService(deps: CohortTopicServiceDeps): CohortTopicService {
415
- return new WalkRegisterService(deps);
416
- }
1
+ /**
2
+ * Cohort-topic substrate — participant-facing service composition.
3
+ *
4
+ * This is the substrate's public contract to applications (`docs/cohort-topic.md` §Application
5
+ * policies): given a `topicId` and a `tier`, reliably find a willing primary (or fail with a clear
6
+ * back-off), keep a registration alive within its TTL, and verify cohort identity/membership. It is
7
+ * the participant half of the substrate; the cohort half is {@link import("./member-engine.js").CohortMemberEngine}.
8
+ *
9
+ * The service is FRET-free: it drives the {@link ITopicRouter} (walk / register / direct-dial) and the
10
+ * other db-core ports by injection. db-p2p binds those ports to FRET + libp2p and constructs the
11
+ * service on the same node it runs the member engine on, so a node is simultaneously a participant and
12
+ * a cohort member (`docs/cohort-topic.md` §FRET integration — all four protocols on one node).
13
+ *
14
+ * Composition wired here: {@link WalkEngine} (lookup / register), the `d_max` computer + tier
15
+ * addressing, the participant-side {@link RenewalParticipant} (the `ttl/3` ping with crash-failover),
16
+ * and the application integration hooks ({@link CohortGossipBus}, {@link MembershipVerifier}). The
17
+ * cohort-side modules (willingness, promotion, traffic, store, membership publisher, anti-DoS) are
18
+ * assembled by the member engine the FRET host wires into the `RouteAndMaybeAct` activity callback.
19
+ */
20
+
21
+ import { randomBytes } from "@noble/hashes/utils.js";
22
+ import { createTierAddressing, type TierAddressing } from "./addressing.js";
23
+ import { makeDMaxComputer, type DMaxComputer } from "./dmax.js";
24
+ import { DEFAULT_FANOUT } from "./addressing.js";
25
+ import type { ISizeEstimator, ITopicRouter, IRingHash, RingCoord } from "./ports.js";
26
+ import { createWalkEngine, type RegisterMessageFactory, type WalkEngine, type WalkOutcome } from "./walk.js";
27
+ import { createRenewalParticipant, type RenewalParticipant, type RenewalParticipantTransport, type UnsignedRenew } from "./registration/renewal.js";
28
+ import type { RegistrationRecord } from "./registration/types.js";
29
+ import { DEFAULT_TTL_MS } from "./registration/types.js";
30
+ import { recordKey } from "./registration/bytes.js";
31
+ import type { CohortGossipBus } from "./gossip/bus.js";
32
+ import type { MembershipVerifier } from "./membership/verifier.js";
33
+ import type { Tier } from "./tiers.js";
34
+ import { bytesToB64url, b64urlToBytes, decodeRenewReplyV1, encodeCohortMessage } from "./wire/codec.js";
35
+ import type { RegisterReplyV1, RegisterV1, RenewReplyV1, TopicTrafficV1 } from "./wire/types.js";
36
+ import type { CollectionChangeEvent, CommitCert } from "../transactor/change-notifier.js";
37
+
38
+ /** A resolved cohort for a topic/tier — the return of {@link CohortTopicService.lookup}. */
39
+ export interface CohortHint {
40
+ readonly topicId: Uint8Array;
41
+ readonly tier: Tier;
42
+ /** Serving cohort member. */
43
+ readonly primary: Uint8Array;
44
+ /** Warm-failover cohort members (1..2). */
45
+ readonly backups: Uint8Array[];
46
+ readonly cohortEpoch: Uint8Array;
47
+ /** Full cohort member set, for client-side caching. */
48
+ readonly cohortMembers: Uint8Array[];
49
+ /** Coarse traffic barometer, when the cohort attached one. */
50
+ readonly topicTraffic?: TopicTrafficV1;
51
+ }
52
+
53
+ /** A live registration: a {@link CohortHint} plus the participant-side renewal handle behind it. */
54
+ export interface RegistrationHandle extends CohortHint {
55
+ /** Internal renewal driver (the `ttl/3` ping loop). Opaque to applications. */
56
+ readonly renewal: RenewalParticipant;
57
+ }
58
+
59
+ /**
60
+ * The substrate's **origination hook**: a commit that lands on a node which is a cohort member for the
61
+ * collection's reactivity topic is delivered here by the local change-notifier bridge
62
+ * (`local-change-notifier-bridge`), carrying the raw {@link CollectionChangeEvent} and the
63
+ * pass-through {@link CommitCert} (extracted from cluster consensus, forwarded UNCHANGED). Reactivity
64
+ * (and later matchmaking) set this hook to fan the event out — reusing `commitCert.thresholdSig`
65
+ * directly, never re-signing. The bridge swallows + logs any throw so origination can never break the
66
+ * commit.
67
+ */
68
+ export type LocalChangeHook = (event: CollectionChangeEvent, commitCert: CommitCert) => void;
69
+
70
+ /** Thrown when the substrate cannot place a registration right now; carries the back-off delay. */
71
+ export class CohortBackoffError extends Error {
72
+ constructor(readonly afterMs: number) {
73
+ super(`cohort-topic: no willing primary right now; retry after ${afterMs}ms`);
74
+ this.name = "CohortBackoffError";
75
+ }
76
+ }
77
+
78
+ /** A registration request (`docs/cohort-topic.md` §Application policies). */
79
+ export interface RegisterRequest {
80
+ readonly topicId: Uint8Array;
81
+ readonly tier: Tier;
82
+ /** Opaque application slot (reactivity / matchmaking define the contents). */
83
+ readonly appPayload?: Uint8Array;
84
+ /** Registration TTL (ms); defaults to the tier default. */
85
+ readonly ttl?: number;
86
+ /** Mark this a cold-root bootstrap request. */
87
+ readonly bootstrap?: boolean;
88
+ }
89
+
90
+ /** The substrate's participant-facing contract. */
91
+ export interface CohortTopicService {
92
+ /** Walk → register for `topicId` at `tier`; resolves a live {@link RegistrationHandle} or throws {@link CohortBackoffError}. */
93
+ register(req: RegisterRequest): Promise<RegistrationHandle>;
94
+ /** Run one `ttl/3` renewal cycle for `handle` (handles `primary_moved` + crash-failover). */
95
+ renew(handle: RegistrationHandle): Promise<void>;
96
+ /** Resolve the cohort for `topicId` at `tier` without keeping a live registration. */
97
+ lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint>;
98
+ /** Stop renewing `handle` and send a best-effort signed withdraw tombstone so the cohort frees the
99
+ * record immediately (TTL expiry remains the fallback if the primary is unreachable). */
100
+ withdraw(handle: RegistrationHandle): Promise<void>;
101
+ /** Origination hook the change-notifier bridge invokes per local member commit; see {@link LocalChangeHook}. */
102
+ onLocalCommit?: LocalChangeHook;
103
+ /** Cohort gossip bus — applications fold app state into the existing gossip. */
104
+ cohortGossip(): CohortGossipBus;
105
+ /** Membership verifier — applications verify threshold-signed app messages. */
106
+ verifier(): MembershipVerifier;
107
+ }
108
+
109
+ /**
110
+ * Signs the participant's outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the peer key).
111
+ *
112
+ * Async because the underlying libp2p `PrivateKey.sign` is async; both call sites already `await`
113
+ * (`messageFactory.build` and the renewal `sign` hook). The signature is over the canonical body
114
+ * image ({@link import("./wire/payloads.js").registerSigningPayload} /
115
+ * {@link import("./wire/payloads.js").renewSigningPayload}) so a cohort member can recompute and
116
+ * verify it against the participant's peer key.
117
+ */
118
+ export interface ParticipantSigner {
119
+ /** Sign a `RegisterV1` (minus its signature); resolves the base64url signature. */
120
+ signRegister(body: Omit<RegisterV1, "signature">): Promise<string>;
121
+ /** Sign a `RenewV1` (minus its signature); resolves the base64url signature. */
122
+ signRenew(body: UnsignedRenew): Promise<string>;
123
+ }
124
+
125
+ /** Per-service tunables (cohort size, threshold, fan-out, TTL); all optional. */
126
+ export interface CohortServiceConfig {
127
+ /** Fan-out per tier `F`. Default {@link DEFAULT_FANOUT}. */
128
+ readonly fanout?: number;
129
+ /** Requested cohort size `wantK`. Default 16. */
130
+ readonly wantK?: number;
131
+ /** Threshold signers `minSigs = k − x`. Default 14. */
132
+ readonly minSigs?: number;
133
+ /** Default registration TTL (ms). Default {@link DEFAULT_TTL_MS}. */
134
+ readonly ttl?: number;
135
+ /** Frame ceiling for encode/decode. Defaults to the codec default. */
136
+ readonly maxMessageBytes?: number;
137
+ }
138
+
139
+ export interface CohortTopicServiceDeps {
140
+ /** This participant's peer id (the `P` in `coord_d(P, topicId)`). */
141
+ readonly self: Uint8Array;
142
+ /** Hash + ring math (db-core's own SHA-256). */
143
+ readonly hash: IRingHash;
144
+ /** FRET-backed router (walk / register / direct-dial). */
145
+ readonly router: ITopicRouter;
146
+ /** FRET-backed network-size estimator feeding `d_max`. */
147
+ readonly sizeEstimator: ISizeEstimator;
148
+ /** Participant body signer. */
149
+ readonly signer: ParticipantSigner;
150
+ /** Cohort gossip bus (the host constructs it for this node's cohort). */
151
+ readonly gossipBus: CohortGossipBus;
152
+ /** Participant-side membership verifier. */
153
+ readonly verifier: MembershipVerifier;
154
+ /** Monotonic-ish wall clock (unix ms); injectable for tests. Default `Date.now`. */
155
+ readonly clock?: () => number;
156
+ /**
157
+ * Optional cold-start bootstrap-evidence builder (db-p2p supplies it). Invoked on **either** cold-start
158
+ * re-issue — the root `bootstrap: true` or the deeper-tier `followOn: true` (both gated by the same
159
+ * evidence policy) — with the register's own canonical fields, the same
160
+ * `(topicId, tier, participantCoord, timestamp)` tuple a verifier binds via
161
+ * `bootstrapBoundImage`. It returns the **raw** envelope JSON bytes — `utf8(JSON.stringify(env))`,
162
+ * NOT the already-base64url string from `serializeBootstrapEvidenceEnvelope` — or `undefined` to
163
+ * attach none; the service base64url-encodes those bytes into `RegisterV1.bootstrapEvidence`
164
+ * **before** the body is signed, so the participant signature covers them and a MITM cannot strip or
165
+ * swap the proof. (Returning the `serialize()` string's bytes would double-encode the field.)
166
+ * Absent (default) → no evidence is attached, exactly today's behavior. The minting logic (PoW
167
+ * search, reputation/parent-ref signing) is the db-p2p follow-on.
168
+ */
169
+ readonly buildBootstrapEvidence?: (params: {
170
+ readonly topicId: string;
171
+ readonly tier: number;
172
+ readonly participantCoord: string;
173
+ readonly timestamp: number;
174
+ }) => Promise<Uint8Array | undefined>;
175
+ readonly config?: CohortServiceConfig;
176
+ }
177
+
178
+ class WalkRegisterService implements CohortTopicService {
179
+ public onLocalCommit?: LocalChangeHook;
180
+
181
+ private readonly addressing: TierAddressing;
182
+ private readonly dmax: DMaxComputer;
183
+ private readonly walk: WalkEngine;
184
+ private readonly clock: () => number;
185
+ private readonly ttl: number;
186
+ private readonly maxMessageBytes?: number;
187
+ /** Live renewal drivers, keyed by `(topicId, participantId)` so renew/withdraw find their handle. */
188
+ private readonly renewals = new Map<string, RenewalParticipant>();
189
+ private readonly participantId: Uint8Array;
190
+
191
+ constructor(private readonly deps: CohortTopicServiceDeps) {
192
+ const cfg = deps.config ?? {};
193
+ const fanout = cfg.fanout ?? DEFAULT_FANOUT;
194
+ this.clock = deps.clock ?? ((): number => Date.now());
195
+ this.ttl = cfg.ttl ?? DEFAULT_TTL_MS;
196
+ this.maxMessageBytes = cfg.maxMessageBytes;
197
+ // The participant identity carried on the wire (`participantCoord` on register, `participantId`
198
+ // on renew) IS `self` — the dialable peer id db-p2p supplies via its peer-codec. It is the
199
+ // record key the cohort stores AND the signer id its peer-key signature verifies against, so it
200
+ // must round-trip back to a peer id rather than being re-hashed here (§Tier addressing names the
201
+ // routing coord `P = self` directly; slot assignment hashes it internally for uniformity).
202
+ this.participantId = deps.self;
203
+ this.addressing = createTierAddressing(deps.hash, fanout);
204
+ this.dmax = makeDMaxComputer({ estimator: deps.sizeEstimator, F: fanout });
205
+ this.walk = createWalkEngine({
206
+ router: deps.router,
207
+ addressing: this.addressing,
208
+ dmax: this.dmax,
209
+ self: deps.self,
210
+ factory: this.messageFactory(),
211
+ config: { wantK: cfg.wantK, minSigs: cfg.minSigs, maxMessageBytes: cfg.maxMessageBytes },
212
+ });
213
+ }
214
+
215
+ async register(req: RegisterRequest): Promise<RegistrationHandle> {
216
+ const outcome = await this.walk.register(req.topicId, req.tier, req.appPayload);
217
+ return this.handleFromOutcome(req, outcome);
218
+ }
219
+
220
+ async lookup(topicId: Uint8Array, tier: Tier): Promise<CohortHint> {
221
+ // A read-only probe: it walks to the responsible cohort exactly as a register would and returns the
222
+ // same cohort snapshot, but admits NOTHING — no soft-state record, no arrival, no promotion trigger,
223
+ // no topic-budget touch, and never a cold-start instantiation. A topic served nowhere resolves to a
224
+ // `CohortBackoffError` (the probe never bootstraps a cold root), so a lookup leaves no throwaway
225
+ // registration behind to TTL-expire (§Application policies; the lookup-as-register interim is gone).
226
+ const outcome = await this.walk.register(topicId, tier, undefined, { probe: true });
227
+ if (outcome.kind !== "accepted") {
228
+ throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
229
+ }
230
+ return this.hintFromReply(topicId, tier, outcome.reply);
231
+ }
232
+
233
+ async renew(handle: RegistrationHandle): Promise<void> {
234
+ // Act only if this handle is still the live entry. A stale handle (superseded by a second
235
+ // register() for the same pair) must not drive the new registration's ping loop, and a
236
+ // withdrawn handle must not silently re-start it.
237
+ if (this.renewals.get(recordKey(handle.topicId, this.participantId)) !== handle.renewal) {
238
+ return;
239
+ }
240
+ await handle.renewal.pingLoop();
241
+ this.syncHandle(handle);
242
+ }
243
+
244
+ async withdraw(handle: RegistrationHandle): Promise<void> {
245
+ // Two halves: (1) drop the handle from the live set so further renew() pings no-op (see renew()),
246
+ // stopping the local ping loop; (2) fire a best-effort signed withdraw tombstone to the current
247
+ // primary so the cohort frees the record immediately instead of holding it for up to a full TTL.
248
+ // The delete happens FIRST so a concurrent renew() already no-ops before the tombstone is sent. If
249
+ // the tombstone send fails (primary unreachable), the cohort soft-state TTL-expires as the
250
+ // fallback — withdraw never throws on a transport failure. Idempotent: a second withdraw finds
251
+ // `renewal !== handle.renewal` (undefined ≠ any object) and no-ops.
252
+ // Guard: act only if this handle is still the live entry. A stale handle must not evict the
253
+ // new registration or send a tombstone on its behalf.
254
+ const key = recordKey(handle.topicId, this.participantId);
255
+ const renewal = this.renewals.get(key);
256
+ if (renewal !== handle.renewal) {
257
+ return;
258
+ }
259
+ this.renewals.delete(key);
260
+ await renewal.withdraw();
261
+ }
262
+
263
+ cohortGossip(): CohortGossipBus {
264
+ return this.deps.gossipBus;
265
+ }
266
+
267
+ verifier(): MembershipVerifier {
268
+ return this.deps.verifier;
269
+ }
270
+
271
+ // --- internals ---
272
+
273
+ private handleFromOutcome(req: RegisterRequest, outcome: WalkOutcome): RegistrationHandle {
274
+ if (outcome.kind !== "accepted") {
275
+ throw new CohortBackoffError(outcome.kind === "retry_later" ? outcome.afterMs : 0);
276
+ }
277
+ const hint = this.hintFromReply(req.topicId, req.tier, outcome.reply);
278
+ const renewal = this.startRenewal(req, hint, outcome.correlationId);
279
+ return { ...hint, renewal };
280
+ }
281
+
282
+ private startRenewal(req: RegisterRequest, hint: CohortHint, correlationId: string): RenewalParticipant {
283
+ const ttl = req.ttl ?? this.ttl;
284
+ const initial: RegistrationRecord = {
285
+ topicId: req.topicId,
286
+ participantId: this.participantId,
287
+ tier: req.tier,
288
+ primary: hint.primary,
289
+ backups: hint.backups,
290
+ attachedAt: this.clock(),
291
+ lastPing: this.clock(),
292
+ ttl,
293
+ appState: req.appPayload,
294
+ };
295
+ const transport = this.renewalTransport(req);
296
+ const renewal = createRenewalParticipant(initial, {
297
+ transport,
298
+ clock: this.clock,
299
+ sign: (body: UnsignedRenew): Promise<string> => this.deps.signer.signRenew(body),
300
+ // Echo the accepted RegisterV1's correlationId: RenewV1 `correlationId` "matches original
301
+ // RegisterV1" (docs §Wire, RenewV1), so a future renew-path freshness/replay guard can correlate
302
+ // a renew back to the registration it renews.
303
+ // NOTE: every periodic renew for this registration shares this ONE correlationId (as it always
304
+ // has — this only makes the shared id equal the register's, not an independent nonce). A future
305
+ // renew replay guard must therefore key renews by (correlationId, timestamp-window), NOT "drop
306
+ // any repeated correlationId" — else it would reject the 2nd+ legitimate renew.
307
+ correlationId,
308
+ initialCohortEpoch: hint.cohortEpoch,
309
+ });
310
+ // NOTE: a second register() for the same (topicId, participantId) overwrites the map entry here,
311
+ // orphaning the prior renewal. renew()/withdraw() on the stale handle now no-op (identity guard),
312
+ // but no tombstone is sent for the superseded record — the cohort frees it via TTL expiry. Harmless
313
+ // for occasional re-registers; if callers ever churn-register the same pair, send a withdraw for the
314
+ // displaced renewal here so the cohort reclaims immediately instead of holding up to a full TTL.
315
+ this.renewals.set(recordKey(req.topicId, this.participantId), renewal);
316
+ return renewal;
317
+ }
318
+
319
+ /** Renewal transport: dial the cached primary directly; a full failure re-runs the register walk. */
320
+ private renewalTransport(req: RegisterRequest): RenewalParticipantTransport {
321
+ return {
322
+ send: async (target: Uint8Array, msg): Promise<RenewReplyV1> => {
323
+ const raw = await this.deps.router.dialMember({ id: target }, encodeCohortMessage(msg, this.maxMessageBytes));
324
+ return decodeRenewReplyV1(raw, this.maxMessageBytes);
325
+ },
326
+ relookup: async (): Promise<void> => {
327
+ await this.walk.register(req.topicId, req.tier, req.appPayload);
328
+ },
329
+ };
330
+ }
331
+
332
+ private hintFromReply(topicId: Uint8Array, tier: Tier, reply: RegisterReplyV1): CohortHint {
333
+ if (reply.primary === undefined || reply.cohortEpoch === undefined) {
334
+ throw new CohortBackoffError(0);
335
+ }
336
+ return {
337
+ topicId,
338
+ tier,
339
+ primary: b64urlToBytes(reply.primary),
340
+ backups: (reply.backups ?? []).map(b64urlToBytes),
341
+ cohortEpoch: b64urlToBytes(reply.cohortEpoch),
342
+ cohortMembers: (reply.cohortMembers ?? []).map(b64urlToBytes),
343
+ topicTraffic: reply.topicTraffic,
344
+ };
345
+ }
346
+
347
+ private syncHandle(handle: RegistrationHandle): void {
348
+ const rec = handle.renewal.record;
349
+ (handle as { primary: Uint8Array }).primary = rec.primary;
350
+ (handle as { backups: Uint8Array[] }).backups = [...rec.backups];
351
+ const epoch = handle.renewal.cohortEpochHint;
352
+ if (epoch !== undefined) {
353
+ (handle as { cohortEpoch: Uint8Array }).cohortEpoch = epoch;
354
+ }
355
+ }
356
+
357
+ /** The per-probe `RegisterV1` builder: stamps participant coord/ttl/correlation and signs. */
358
+ private messageFactory(): RegisterMessageFactory {
359
+ const participantCoord = bytesToB64url(this.participantId);
360
+ return {
361
+ build: async (params): Promise<RegisterV1> => {
362
+ const body: Omit<RegisterV1, "signature"> = {
363
+ v: 1,
364
+ topicId: bytesToB64url(params.topicId),
365
+ tier: params.tier,
366
+ treeTier: params.treeTier,
367
+ participantCoord,
368
+ ttl: this.ttl,
369
+ timestamp: this.clock(),
370
+ correlationId: this.freshCorrelationId(),
371
+ };
372
+ if (params.probe) {
373
+ // A read-only lookup probe. Mutually exclusive with bootstrap/followOn (the walk never sets
374
+ // either on a probe), so the cold-start-evidence branch below cannot run for a probe.
375
+ body.probe = true;
376
+ }
377
+ // A cold-start re-issue — root (`bootstrap`) or deeper-tier follow-on (`followOn`). Both are
378
+ // gated by the identical evidence policy (§Anti-DoS), so both mint and attach the same envelope
379
+ // bound to the body's own canonical fields. Mutually exclusive with each other and with probe.
380
+ if (params.bootstrap || params.followOn) {
381
+ if (params.followOn) {
382
+ body.followOn = true;
383
+ } else {
384
+ body.bootstrap = true;
385
+ }
386
+ // Attach cold-start evidence BEFORE signing (so the signature covers it). Bind the
387
+ // builder to the body's own canonical fields — the exact tuple a verifier reconstructs.
388
+ const evidence = await this.deps.buildBootstrapEvidence?.({
389
+ topicId: body.topicId,
390
+ tier: body.tier,
391
+ participantCoord: body.participantCoord,
392
+ timestamp: body.timestamp,
393
+ });
394
+ if (evidence !== undefined && evidence.length > 0) {
395
+ body.bootstrapEvidence = bytesToB64url(evidence);
396
+ }
397
+ }
398
+ if (params.appPayload !== undefined) {
399
+ body.appPayload = bytesToB64url(params.appPayload);
400
+ }
401
+ return { ...body, signature: await this.deps.signer.signRegister(body) };
402
+ },
403
+ };
404
+ }
405
+
406
+ /** 16 fresh CSPRNG bytes, base64url. A correlation id must be unique per probe — the replay guard
407
+ * keys on it — so it cannot be derived from the clock (two probes in the same ms would collide). */
408
+ private freshCorrelationId(): string {
409
+ return bytesToB64url(randomBytes(16));
410
+ }
411
+ }
412
+
413
+ /** Build the participant-facing {@link CohortTopicService} over the injected ports. */
414
+ export function createCohortTopicService(deps: CohortTopicServiceDeps): CohortTopicService {
415
+ return new WalkRegisterService(deps);
416
+ }