@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,394 +1,394 @@
1
- /**
2
- * Matchmaking — voting-quorum assembler (db-core, discovery-only composition).
3
- *
4
- * The voting subsystem's **discovery** flow, built as a thin composition over the matchmaking module
5
- * (`docs/matchmaking.md` §Voting-quorum assembly). Matchmaking already provides "find the peers": the
6
- * stable {@link import("./topic-anchor.js").MatchTopicAnchor}, the signed provider registration
7
- * ({@link MatchmakingProvider}), the single-cohort hang-out walk and the hot-topic multi-cohort sweep,
8
- * and the reply-side per-entry `registrationSig` re-validation ({@link verifyProviderEntry}). This
9
- * module adds the small amount of glue that turns that surface into "assemble a quorum of eligible
10
- * voters for proposal `P`".
11
- *
12
- * **No matchmaking wire-protocol change is introduced.** The genuinely voting-layer pieces are injected
13
- * or left out of scope entirely:
14
- *
15
- * - **Eligibility proof minting** — the caller mints an opaque proof (a signature over the proposal from
16
- * a stake-bearing key, etc.) and supplies the {@link VotingQuorumRequest.verifyEligibility} predicate
17
- * over it. Matchmaking treats the proof as opaque bytes carried in the provider's `capabilities`.
18
- * - **The quorum selection rule** (random sample, stake-weighted, geographic) — injected via
19
- * {@link VotingQuorumRequest.select}; the default is first-`targetSize`.
20
- * - **Ballots, tally, dispute, ballot privacy** — out of scope; a separate forthcoming voting doc.
21
- *
22
- * ## Resolved design decisions (`docs/matchmaking.md` §Voting-quorum assembly)
23
- *
24
- * 1. **Reply-side per-entry verification is the default and required path; a client-side reputation
25
- * pre-filter is optional and additive.** Quorum-assembly *liveness* must never depend on
26
- * reputation-subsystem *availability*, so reputation is an optimization, never a dependency.
27
- * Matchmaking forwards each entry's `registrationSig` plus the eligibility proof in `capabilities`,
28
- * so reply-side verification is effectively free; the cohort returns ineligible entries the
29
- * coordinator discards (bandwidth bounded by `query_limit_max × swept-cohort-count`).
30
- * 2. **Flash-vote fairness among competing coordinators is a voting-layer concern; no matchmaking signal
31
- * is added.** Matchmaking returns an advisory *candidate set*, never an allocation — overlapping sets
32
- * are expected and quorum non-collision is decided by the voting protocol.
33
- * 3. **No matchmaking protocol additions are needed.** `AggregateCountV1` is threshold-signed (an
34
- * attested *registered*-provider count); an attested *eligible*-voter count, if voting needs one, is a
35
- * voting-layer aggregate computed after discovery+verification, not a matchmaking message.
36
- * 4. **Quorum-assembler delegation.** The seeker role may be held by the coordinator itself or by a
37
- * delegated assembler peer; whoever holds it owns `patienceMs` and the reply-side re-validation duty.
38
- * A delegated assembler returns an already-validated {@link ProviderEntryV1}`[]`; because every entry
39
- * carries `registrationSig` + the proof in `capabilities`, the set is independently checkable, so the
40
- * coordinator MAY re-validate (trust-but-verify) on receipt by re-running the verify→select pipeline.
41
- *
42
- * ## Architecture seam
43
- *
44
- * db-core is transport-free, so the actual discovery I/O (the register/query/renew walk, the root
45
- * `AggregateCountV1` sweep) is **injected** as a {@link QuorumDiscovery} port — exactly as the seeker
46
- * walk client and entry verifier are injected elsewhere in this module. db-p2p binds the port to
47
- * `MatchmakingSeeker.walk` (single-cohort) and `multi-cohort-seeker` (sweep). The assembler itself never
48
- * dials voters, never collects ballots, never tallies.
49
- */
50
-
51
- import { createMatchTopicAnchor, type MatchTopicAnchor } from "./topic-anchor.js";
52
- import { MatchmakingProvider } from "./provider.js";
53
- import { verifyProviderEntry, type ProviderEntryV1, type EntrySigVerifier } from "./wire.js";
54
-
55
- /** Default concurrent-vote-collection budget for a registered voter — accepts one vote-collection RPC. */
56
- export const DEFAULT_VOTER_CAPACITY_BUDGET = 1;
57
-
58
- /**
59
- * Indicative `patienceMs` range for voting-quorum assembly (`docs/matchmaking.md` §Patience budgeting).
60
- * Advisory only — the layer does not dictate; {@link VotingQuorumAssembler.assembleQuorum} requires a
61
- * positive budget but does not clamp to this window.
62
- */
63
- export const VOTING_PATIENCE_MIN_MS = 30_000;
64
- export const VOTING_PATIENCE_MAX_MS = 300_000;
65
-
66
- /**
67
- * Reserved capability-tag prefix carrying a voter's opaque eligibility proof. The proof is bound into the
68
- * provider `capabilities`, which {@link import("./wire.js").providerSigningPayload} covers, so the
69
- * provider's `registrationSig` attests the proof verbatim. The caller's `verifyEligibility` predicate
70
- * reads it back from the forwarded entry via {@link eligibilityProofOf}.
71
- */
72
- export const ELIGIBILITY_TAG_PREFIX = "voter-eligibility:";
73
-
74
- /** Encode an opaque eligibility proof as the reserved capability tag. */
75
- export function eligibilityTag(proof: string): string {
76
- return ELIGIBILITY_TAG_PREFIX + proof;
77
- }
78
-
79
- /** Extract the eligibility proof from a capability set, or `undefined` if no eligibility tag is present. */
80
- export function eligibilityProofFromCapabilities(capabilities: readonly string[]): string | undefined {
81
- const tag = capabilities.find((c) => c.startsWith(ELIGIBILITY_TAG_PREFIX));
82
- return tag === undefined ? undefined : tag.slice(ELIGIBILITY_TAG_PREFIX.length);
83
- }
84
-
85
- /** Extract the eligibility proof carried by a forwarded {@link ProviderEntryV1} (`undefined` if absent). */
86
- export function eligibilityProofOf(entry: ProviderEntryV1): string | undefined {
87
- return eligibilityProofFromCapabilities(entry.capabilities);
88
- }
89
-
90
- // --- discovery seam (satisfied by MatchmakingSeeker.walk + multi-cohort-seeker, wired in db-p2p) ---
91
-
92
- /** One discovery slice: candidate entries plus the hotness signal that drives the single-vs-sweep choice. */
93
- export interface QuorumDiscoverySlice {
94
- /** Candidate provider entries returned by this discovery hop (advisory — the assembler re-verifies). */
95
- readonly entries: readonly ProviderEntryV1[];
96
- /**
97
- * Max `topicTraffic.childCohortCount` observed across the walked tiers. `> 0` means the topic has
98
- * promoted (it is hot), so a single-cohort sample is unrepresentative and the assembler escalates to
99
- * the multi-cohort sweep. The sweep hop sets this to `0` (the decision is already made).
100
- */
101
- readonly childCohortCount: number;
102
- }
103
-
104
- /** Inputs to one discovery hop. */
105
- export interface QuorumDiscoveryRequest {
106
- /** The quorum topic id (`H("quorum" ‖ proposalHash ‖ "match")`). */
107
- readonly topicId: Uint8Array;
108
- /** Providers desired (drives the hang-out feasibility math / how many shards the sweep unions). */
109
- readonly wantCount: number;
110
- /** Patience budget remaining for this hop (ms); drains across walk → sweep. */
111
- readonly patienceMs: number;
112
- }
113
-
114
- /**
115
- * The discovery surface {@link VotingQuorumAssembler} composes over. db-p2p binds:
116
- * - {@link QuorumDiscovery.walk} → `MatchmakingSeeker.walk` (single-cohort hang-out walk), and
117
- * - {@link QuorumDiscovery.sweep} → `multi-cohort-seeker` (root `AggregateCountV1` → tier shards union).
118
- *
119
- * Both surface already-deduped, `registrationSig`-validated entries in production; the assembler treats
120
- * them as advisory candidates and re-validates regardless, which keeps the delegated-assembler
121
- * (trust-but-verify) path and the mock-tier test fixtures honest.
122
- */
123
- export interface QuorumDiscovery {
124
- /** Single-cohort hang-out walk; returns matched candidates plus the observed hotness signal. */
125
- walk(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
126
- /** Multi-cohort sweep across high-population tier shards; returns the unioned candidate slice. */
127
- sweep(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
128
- }
129
-
130
- // --- request / result ---
131
-
132
- /** A request to assemble a voting quorum for one proposal. All voting-layer policy is injected here. */
133
- export interface VotingQuorumRequest {
134
- /** The proposal hash — the quorum topic's label. */
135
- readonly proposalHash: string;
136
- /** Desired quorum size (integer `>= 1`). */
137
- readonly targetSize: number;
138
- /** Patience budget (ms); positive. Voting range is 30–300 s (§Patience budgeting), advisory. */
139
- readonly patienceMs: number;
140
- /** Voting/stake-layer eligibility predicate over the opaque proof in an entry's `capabilities`. */
141
- readonly verifyEligibility: (entry: ProviderEntryV1) => boolean;
142
- /** Voting-layer quorum selection rule; default = first `targetSize`. Must choose from the input set. */
143
- readonly select?: (eligible: ProviderEntryV1[], targetSize: number) => ProviderEntryV1[];
144
- /** Optional, additive reputation pre-filter applied *before* verification to trim candidates. */
145
- readonly reputationPrefilter?: (candidates: ProviderEntryV1[]) => ProviderEntryV1[];
146
- /** Force the multi-cohort sweep even when a single-cohort walk would suffice (representativeness). */
147
- readonly preferSweep?: boolean;
148
- }
149
-
150
- /** The outcome of {@link VotingQuorumAssembler.assembleQuorum}. */
151
- export interface VotingQuorumResult {
152
- /** The selected, eligibility-verified quorum, deduped by `participantId`. */
153
- readonly quorum: ProviderEntryV1[];
154
- /** Distinct candidates discovery surfaced (after dedup, before eligibility filtering). */
155
- readonly candidates: number;
156
- /** Candidates that passed `registrationSig` re-validation **and** `verifyEligibility`. */
157
- readonly eligible: number;
158
- /** Whether the assembled quorum met `targetSize` (`quorum.length >= targetSize`). */
159
- readonly metTarget: boolean;
160
- /** Whether the multi-cohort sweep was used (hot topic or `preferSweep`). */
161
- readonly swept: boolean;
162
- }
163
-
164
- /** Construction inputs for a {@link VotingQuorumAssembler}; deps are per-role and validated at call time. */
165
- export interface VotingQuorumAssemblerDeps {
166
- /** Stable topic anchor; defaults to db-core's ring-hash anchor ({@link createMatchTopicAnchor}). */
167
- readonly anchor?: MatchTopicAnchor;
168
- /** Discovery surface (walk + sweep) — required by {@link VotingQuorumAssembler.assembleQuorum}. */
169
- readonly discovery?: QuorumDiscovery;
170
- /**
171
- * Per-entry `registrationSig` verifier (db-p2p binds `verifyPeerSig`) — required by
172
- * {@link VotingQuorumAssembler.assembleQuorum}. db-core is crypto-free, so the peer-key check is
173
- * injected, matching {@link verifyProviderEntry}'s contract.
174
- */
175
- readonly verifyEntrySig?: EntrySigVerifier;
176
- /** Sign a provider registration image — required by {@link VotingQuorumAssembler.registerEligibleVoter}. */
177
- readonly sign?: (payload: Uint8Array) => Promise<string>;
178
- /**
179
- * Register a signed voter-provider payload at the quorum topic (cohort-topic T2) — required by
180
- * {@link VotingQuorumAssembler.registerEligibleVoter}. db-p2p binds this to the provider manager /
181
- * `MatchmakingProvider.register` once `matchmaking-sweep-adversarial-module` lands.
182
- */
183
- readonly registerProvider?: (req: RegisterVoterProviderRequest) => Promise<void>;
184
- /** CSPRNG source for the provider correlation id (injectable for deterministic tests). */
185
- readonly randomBytes?: (n: number) => Uint8Array;
186
- /** Wall clock (unix ms), injectable for tests; default `Date.now`. Splits patience across walk → sweep. */
187
- readonly clock?: () => number;
188
- }
189
-
190
- /** What {@link VotingQuorumAssembler.registerEligibleVoter} hands the injected `registerProvider` port. */
191
- export interface RegisterVoterProviderRequest {
192
- /** The quorum topic id this voter registers at. */
193
- readonly topicId: Uint8Array;
194
- /** The opaque, signed cohort-topic `RegisterV1.appPayload` bytes for the voter provider. */
195
- readonly appPayloadBytes: Uint8Array;
196
- }
197
-
198
- /** Construction inputs for {@link VotingQuorumAssembler.registerEligibleVoter}. */
199
- export interface RegisterEligibleVoterRequest {
200
- /** The proposal hash — the quorum topic's label. */
201
- readonly proposalHash: string;
202
- /** base64url, app-defined eligibility proof minted by the voting/stake layer. */
203
- readonly eligibilityProof: string;
204
- /** Extra application capability tags merged into the registration (the proof tag is always added). */
205
- readonly capabilityTags?: readonly string[];
206
- /** Concurrent vote-collection budget; default {@link DEFAULT_VOTER_CAPACITY_BUDGET} (1). */
207
- readonly capacityBudget?: number;
208
- /** Multiaddr or PeerId-based callback for the vote-collection dial. */
209
- readonly contactHint: string;
210
- /** Optional soft expiry hint (unix ms). */
211
- readonly serviceUntil?: number;
212
- }
213
-
214
- /**
215
- * Discovery-only voting-quorum assembler over the matchmaking module. One instance can act in either
216
- * role: a voter calls {@link registerEligibleVoter}; a coordinator (or delegated assembler) calls
217
- * {@link assembleQuorum}. Deps are role-specific and asserted per method, so a coordinator need not
218
- * supply `sign`/`registerProvider` and a voter need not supply `discovery`/`verifyEntrySig`.
219
- */
220
- export class VotingQuorumAssembler {
221
- private readonly anchor: MatchTopicAnchor;
222
- private readonly deps: VotingQuorumAssemblerDeps;
223
- private readonly clock: () => number;
224
-
225
- constructor(deps: VotingQuorumAssemblerDeps = {}) {
226
- this.deps = deps;
227
- this.anchor = deps.anchor ?? createMatchTopicAnchor();
228
- this.clock = deps.clock ?? ((): number => Date.now());
229
- }
230
-
231
- /**
232
- * The quorum topic anchor for a proposal: `kind = "quorum"`, `label = proposalHash`, yielding
233
- * `topicId = H("quorum" ‖ proposalHash ‖ "match")` via the matchmaking topic anchor.
234
- */
235
- static quorumTopic(proposalHash: string): { kind: "quorum"; label: string } {
236
- return { kind: "quorum", label: proposalHash };
237
- }
238
-
239
- /** Resolve the cohort-topic `topicId` for a proposal's quorum topic. */
240
- topicIdFor(proposalHash: string): Uint8Array {
241
- const { kind, label } = VotingQuorumAssembler.quorumTopic(proposalHash);
242
- return this.anchor.topicId(kind, label);
243
- }
244
-
245
- /**
246
- * Voter side: bind the caller-minted eligibility proof into `capabilities`, build the signed provider
247
- * registration, and register it at the quorum topic (cohort-topic T2). The proof is opaque to
248
- * matchmaking; the provider `signature` covers `(topicId, capabilities, capacityBudget)`, so it binds
249
- * the proof to the registration.
250
- */
251
- async registerEligibleVoter(req: RegisterEligibleVoterRequest): Promise<void> {
252
- const sign = this.requireSign();
253
- const registerProvider = this.requireRegisterProvider();
254
- const topicId = this.topicIdFor(req.proposalHash);
255
- const capacityBudget = req.capacityBudget ?? DEFAULT_VOTER_CAPACITY_BUDGET;
256
- const capabilities = mergeEligibilityCapabilities(req.capabilityTags ?? [], req.eligibilityProof);
257
-
258
- const provider = new MatchmakingProvider({
259
- topicId,
260
- capabilities,
261
- capacityBudget,
262
- contactHint: req.contactHint,
263
- sign,
264
- ...(req.serviceUntil !== undefined ? { serviceUntil: req.serviceUntil } : {}),
265
- ...(this.deps.randomBytes !== undefined ? { randomBytes: this.deps.randomBytes } : {}),
266
- });
267
-
268
- await registerProvider({ topicId, appPayloadBytes: await provider.appPayloadBytes() });
269
- }
270
-
271
- /**
272
- * Coordinator / delegated-assembler side: discover → verify → select. Drives the single-cohort walk,
273
- * escalates to the multi-cohort sweep on a hot topic (`childCohortCount > 0`) or when `preferSweep`,
274
- * dedups by `participantId`, applies the optional reputation pre-filter, re-validates each entry's
275
- * `registrationSig` **and** `verifyEligibility`, then applies the selection rule. Passes the *draining*
276
- * patience budget to each discovery hop (walk, then sweep) — the deadline is fixed at entry and each
277
- * hop receives the remaining slice — and on exhaustion returns whatever matched with `metTarget = false`.
278
- * (Both legs honour `patienceMs`: the walk enforces a wall-clock deadline across hops and hang-out; the
279
- * sweep stops starting new shard queries once the budget drains. The residual is at most one in-flight
280
- * shard RPC — assembly never blocks materially past `patienceMs`.)
281
- */
282
- async assembleQuorum(req: VotingQuorumRequest): Promise<VotingQuorumResult> {
283
- const discovery = this.requireDiscovery();
284
- const verifyEntrySig = this.requireVerifyEntrySig();
285
- const targetSize = requireTargetSize(req.targetSize);
286
- requirePositivePatience(req.patienceMs);
287
- const topicId = this.topicIdFor(req.proposalHash);
288
-
289
- const deadline = this.clock() + req.patienceMs;
290
- const remaining = (): number => Math.max(0, deadline - this.clock());
291
-
292
- // Step 2: single-cohort walk, then the single-vs-sweep decision.
293
- const walkSlice = await discovery.walk({ topicId, wantCount: targetSize, patienceMs: remaining() });
294
- const swept = req.preferSweep === true || walkSlice.childCohortCount > 0;
295
- const slices: QuorumDiscoverySlice[] = [walkSlice];
296
- if (swept) {
297
- slices.push(await discovery.sweep({ topicId, wantCount: targetSize, patienceMs: remaining() }));
298
- }
299
-
300
- // Step 3: dedup the unioned candidate set by participantId (defensive — promotion/redirect overlap).
301
- const candidates = dedupByParticipant(slices.flatMap((s) => [...s.entries]));
302
-
303
- // Step 4: optional, additive reputation pre-filter (bandwidth/quality trim; never a dependency).
304
- const prefiltered = req.reputationPrefilter ? req.reputationPrefilter([...candidates]) : candidates;
305
-
306
- // Step 5: reply-side default — both registrationSig re-validation AND verifyEligibility must pass.
307
- const eligible = prefiltered.filter(
308
- (entry) => verifyProviderEntry(topicId, entry, verifyEntrySig) && req.verifyEligibility(entry),
309
- );
310
-
311
- // Step 6: selection rule (default first targetSize), deduped defensively.
312
- const select = req.select ?? defaultSelect;
313
- const quorum = dedupByParticipant(select([...eligible], targetSize));
314
-
315
- return {
316
- quorum,
317
- candidates: candidates.length,
318
- eligible: eligible.length,
319
- metTarget: quorum.length >= targetSize,
320
- swept,
321
- };
322
- }
323
-
324
- private requireDiscovery(): QuorumDiscovery {
325
- if (this.deps.discovery === undefined) {
326
- throw new Error("VotingQuorumAssembler.assembleQuorum requires a `discovery` dependency");
327
- }
328
- return this.deps.discovery;
329
- }
330
-
331
- private requireVerifyEntrySig(): EntrySigVerifier {
332
- if (this.deps.verifyEntrySig === undefined) {
333
- throw new Error("VotingQuorumAssembler.assembleQuorum requires a `verifyEntrySig` dependency");
334
- }
335
- return this.deps.verifyEntrySig;
336
- }
337
-
338
- private requireSign(): (payload: Uint8Array) => Promise<string> {
339
- if (this.deps.sign === undefined) {
340
- throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `sign` dependency");
341
- }
342
- return this.deps.sign;
343
- }
344
-
345
- private requireRegisterProvider(): (req: RegisterVoterProviderRequest) => Promise<void> {
346
- if (this.deps.registerProvider === undefined) {
347
- throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `registerProvider` dependency");
348
- }
349
- return this.deps.registerProvider;
350
- }
351
- }
352
-
353
- /** The default selection rule: take the first `targetSize` eligible entries (the discovery order). */
354
- export function defaultSelect(eligible: ProviderEntryV1[], targetSize: number): ProviderEntryV1[] {
355
- return eligible.slice(0, targetSize);
356
- }
357
-
358
- /**
359
- * Merge application capability tags with the eligibility proof tag: drop any caller-supplied tag that
360
- * already uses the reserved prefix (the proof is authoritative), then append exactly one proof tag.
361
- * Order is preserved end-to-end (the cohort forwards `capabilities` verbatim), so the provider signature
362
- * reconstructs byte-for-byte on the seeker side.
363
- */
364
- function mergeEligibilityCapabilities(tags: readonly string[], proof: string): string[] {
365
- const appTags = tags.filter((tag) => !tag.startsWith(ELIGIBILITY_TAG_PREFIX));
366
- return [...appTags, eligibilityTag(proof)];
367
- }
368
-
369
- /** Dedup entries by `participantId`, keeping the first occurrence (preserves discovery/FCFS order). */
370
- function dedupByParticipant(entries: readonly ProviderEntryV1[]): ProviderEntryV1[] {
371
- const seen = new Map<string, ProviderEntryV1>();
372
- for (const entry of entries) {
373
- if (!seen.has(entry.participantId)) {
374
- seen.set(entry.participantId, entry);
375
- }
376
- }
377
- return [...seen.values()];
378
- }
379
-
380
- /** Validate `targetSize` is an integer `>= 1`. */
381
- function requireTargetSize(targetSize: number): number {
382
- if (!Number.isInteger(targetSize) || targetSize < 1) {
383
- throw new RangeError(`voting quorum: targetSize must be an integer >= 1, got ${targetSize}`);
384
- }
385
- return targetSize;
386
- }
387
-
388
- /** Validate `patienceMs` is a finite number `> 0` (the voting 30–300 s range is advisory, not enforced). */
389
- function requirePositivePatience(patienceMs: number): number {
390
- if (!Number.isFinite(patienceMs) || patienceMs <= 0) {
391
- throw new RangeError(`voting quorum: patienceMs must be a finite number > 0, got ${patienceMs}`);
392
- }
393
- return patienceMs;
394
- }
1
+ /**
2
+ * Matchmaking — voting-quorum assembler (db-core, discovery-only composition).
3
+ *
4
+ * The voting subsystem's **discovery** flow, built as a thin composition over the matchmaking module
5
+ * (`docs/matchmaking.md` §Voting-quorum assembly). Matchmaking already provides "find the peers": the
6
+ * stable {@link import("./topic-anchor.js").MatchTopicAnchor}, the signed provider registration
7
+ * ({@link MatchmakingProvider}), the single-cohort hang-out walk and the hot-topic multi-cohort sweep,
8
+ * and the reply-side per-entry `registrationSig` re-validation ({@link verifyProviderEntry}). This
9
+ * module adds the small amount of glue that turns that surface into "assemble a quorum of eligible
10
+ * voters for proposal `P`".
11
+ *
12
+ * **No matchmaking wire-protocol change is introduced.** The genuinely voting-layer pieces are injected
13
+ * or left out of scope entirely:
14
+ *
15
+ * - **Eligibility proof minting** — the caller mints an opaque proof (a signature over the proposal from
16
+ * a stake-bearing key, etc.) and supplies the {@link VotingQuorumRequest.verifyEligibility} predicate
17
+ * over it. Matchmaking treats the proof as opaque bytes carried in the provider's `capabilities`.
18
+ * - **The quorum selection rule** (random sample, stake-weighted, geographic) — injected via
19
+ * {@link VotingQuorumRequest.select}; the default is first-`targetSize`.
20
+ * - **Ballots, tally, dispute, ballot privacy** — out of scope; a separate forthcoming voting doc.
21
+ *
22
+ * ## Resolved design decisions (`docs/matchmaking.md` §Voting-quorum assembly)
23
+ *
24
+ * 1. **Reply-side per-entry verification is the default and required path; a client-side reputation
25
+ * pre-filter is optional and additive.** Quorum-assembly *liveness* must never depend on
26
+ * reputation-subsystem *availability*, so reputation is an optimization, never a dependency.
27
+ * Matchmaking forwards each entry's `registrationSig` plus the eligibility proof in `capabilities`,
28
+ * so reply-side verification is effectively free; the cohort returns ineligible entries the
29
+ * coordinator discards (bandwidth bounded by `query_limit_max × swept-cohort-count`).
30
+ * 2. **Flash-vote fairness among competing coordinators is a voting-layer concern; no matchmaking signal
31
+ * is added.** Matchmaking returns an advisory *candidate set*, never an allocation — overlapping sets
32
+ * are expected and quorum non-collision is decided by the voting protocol.
33
+ * 3. **No matchmaking protocol additions are needed.** `AggregateCountV1` is threshold-signed (an
34
+ * attested *registered*-provider count); an attested *eligible*-voter count, if voting needs one, is a
35
+ * voting-layer aggregate computed after discovery+verification, not a matchmaking message.
36
+ * 4. **Quorum-assembler delegation.** The seeker role may be held by the coordinator itself or by a
37
+ * delegated assembler peer; whoever holds it owns `patienceMs` and the reply-side re-validation duty.
38
+ * A delegated assembler returns an already-validated {@link ProviderEntryV1}`[]`; because every entry
39
+ * carries `registrationSig` + the proof in `capabilities`, the set is independently checkable, so the
40
+ * coordinator MAY re-validate (trust-but-verify) on receipt by re-running the verify→select pipeline.
41
+ *
42
+ * ## Architecture seam
43
+ *
44
+ * db-core is transport-free, so the actual discovery I/O (the register/query/renew walk, the root
45
+ * `AggregateCountV1` sweep) is **injected** as a {@link QuorumDiscovery} port — exactly as the seeker
46
+ * walk client and entry verifier are injected elsewhere in this module. db-p2p binds the port to
47
+ * `MatchmakingSeeker.walk` (single-cohort) and `multi-cohort-seeker` (sweep). The assembler itself never
48
+ * dials voters, never collects ballots, never tallies.
49
+ */
50
+
51
+ import { createMatchTopicAnchor, type MatchTopicAnchor } from "./topic-anchor.js";
52
+ import { MatchmakingProvider } from "./provider.js";
53
+ import { verifyProviderEntry, type ProviderEntryV1, type EntrySigVerifier } from "./wire.js";
54
+
55
+ /** Default concurrent-vote-collection budget for a registered voter — accepts one vote-collection RPC. */
56
+ export const DEFAULT_VOTER_CAPACITY_BUDGET = 1;
57
+
58
+ /**
59
+ * Indicative `patienceMs` range for voting-quorum assembly (`docs/matchmaking.md` §Patience budgeting).
60
+ * Advisory only — the layer does not dictate; {@link VotingQuorumAssembler.assembleQuorum} requires a
61
+ * positive budget but does not clamp to this window.
62
+ */
63
+ export const VOTING_PATIENCE_MIN_MS = 30_000;
64
+ export const VOTING_PATIENCE_MAX_MS = 300_000;
65
+
66
+ /**
67
+ * Reserved capability-tag prefix carrying a voter's opaque eligibility proof. The proof is bound into the
68
+ * provider `capabilities`, which {@link import("./wire.js").providerSigningPayload} covers, so the
69
+ * provider's `registrationSig` attests the proof verbatim. The caller's `verifyEligibility` predicate
70
+ * reads it back from the forwarded entry via {@link eligibilityProofOf}.
71
+ */
72
+ export const ELIGIBILITY_TAG_PREFIX = "voter-eligibility:";
73
+
74
+ /** Encode an opaque eligibility proof as the reserved capability tag. */
75
+ export function eligibilityTag(proof: string): string {
76
+ return ELIGIBILITY_TAG_PREFIX + proof;
77
+ }
78
+
79
+ /** Extract the eligibility proof from a capability set, or `undefined` if no eligibility tag is present. */
80
+ export function eligibilityProofFromCapabilities(capabilities: readonly string[]): string | undefined {
81
+ const tag = capabilities.find((c) => c.startsWith(ELIGIBILITY_TAG_PREFIX));
82
+ return tag === undefined ? undefined : tag.slice(ELIGIBILITY_TAG_PREFIX.length);
83
+ }
84
+
85
+ /** Extract the eligibility proof carried by a forwarded {@link ProviderEntryV1} (`undefined` if absent). */
86
+ export function eligibilityProofOf(entry: ProviderEntryV1): string | undefined {
87
+ return eligibilityProofFromCapabilities(entry.capabilities);
88
+ }
89
+
90
+ // --- discovery seam (satisfied by MatchmakingSeeker.walk + multi-cohort-seeker, wired in db-p2p) ---
91
+
92
+ /** One discovery slice: candidate entries plus the hotness signal that drives the single-vs-sweep choice. */
93
+ export interface QuorumDiscoverySlice {
94
+ /** Candidate provider entries returned by this discovery hop (advisory — the assembler re-verifies). */
95
+ readonly entries: readonly ProviderEntryV1[];
96
+ /**
97
+ * Max `topicTraffic.childCohortCount` observed across the walked tiers. `> 0` means the topic has
98
+ * promoted (it is hot), so a single-cohort sample is unrepresentative and the assembler escalates to
99
+ * the multi-cohort sweep. The sweep hop sets this to `0` (the decision is already made).
100
+ */
101
+ readonly childCohortCount: number;
102
+ }
103
+
104
+ /** Inputs to one discovery hop. */
105
+ export interface QuorumDiscoveryRequest {
106
+ /** The quorum topic id (`H("quorum" ‖ proposalHash ‖ "match")`). */
107
+ readonly topicId: Uint8Array;
108
+ /** Providers desired (drives the hang-out feasibility math / how many shards the sweep unions). */
109
+ readonly wantCount: number;
110
+ /** Patience budget remaining for this hop (ms); drains across walk → sweep. */
111
+ readonly patienceMs: number;
112
+ }
113
+
114
+ /**
115
+ * The discovery surface {@link VotingQuorumAssembler} composes over. db-p2p binds:
116
+ * - {@link QuorumDiscovery.walk} → `MatchmakingSeeker.walk` (single-cohort hang-out walk), and
117
+ * - {@link QuorumDiscovery.sweep} → `multi-cohort-seeker` (root `AggregateCountV1` → tier shards union).
118
+ *
119
+ * Both surface already-deduped, `registrationSig`-validated entries in production; the assembler treats
120
+ * them as advisory candidates and re-validates regardless, which keeps the delegated-assembler
121
+ * (trust-but-verify) path and the mock-tier test fixtures honest.
122
+ */
123
+ export interface QuorumDiscovery {
124
+ /** Single-cohort hang-out walk; returns matched candidates plus the observed hotness signal. */
125
+ walk(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
126
+ /** Multi-cohort sweep across high-population tier shards; returns the unioned candidate slice. */
127
+ sweep(req: QuorumDiscoveryRequest): Promise<QuorumDiscoverySlice>;
128
+ }
129
+
130
+ // --- request / result ---
131
+
132
+ /** A request to assemble a voting quorum for one proposal. All voting-layer policy is injected here. */
133
+ export interface VotingQuorumRequest {
134
+ /** The proposal hash — the quorum topic's label. */
135
+ readonly proposalHash: string;
136
+ /** Desired quorum size (integer `>= 1`). */
137
+ readonly targetSize: number;
138
+ /** Patience budget (ms); positive. Voting range is 30–300 s (§Patience budgeting), advisory. */
139
+ readonly patienceMs: number;
140
+ /** Voting/stake-layer eligibility predicate over the opaque proof in an entry's `capabilities`. */
141
+ readonly verifyEligibility: (entry: ProviderEntryV1) => boolean;
142
+ /** Voting-layer quorum selection rule; default = first `targetSize`. Must choose from the input set. */
143
+ readonly select?: (eligible: ProviderEntryV1[], targetSize: number) => ProviderEntryV1[];
144
+ /** Optional, additive reputation pre-filter applied *before* verification to trim candidates. */
145
+ readonly reputationPrefilter?: (candidates: ProviderEntryV1[]) => ProviderEntryV1[];
146
+ /** Force the multi-cohort sweep even when a single-cohort walk would suffice (representativeness). */
147
+ readonly preferSweep?: boolean;
148
+ }
149
+
150
+ /** The outcome of {@link VotingQuorumAssembler.assembleQuorum}. */
151
+ export interface VotingQuorumResult {
152
+ /** The selected, eligibility-verified quorum, deduped by `participantId`. */
153
+ readonly quorum: ProviderEntryV1[];
154
+ /** Distinct candidates discovery surfaced (after dedup, before eligibility filtering). */
155
+ readonly candidates: number;
156
+ /** Candidates that passed `registrationSig` re-validation **and** `verifyEligibility`. */
157
+ readonly eligible: number;
158
+ /** Whether the assembled quorum met `targetSize` (`quorum.length >= targetSize`). */
159
+ readonly metTarget: boolean;
160
+ /** Whether the multi-cohort sweep was used (hot topic or `preferSweep`). */
161
+ readonly swept: boolean;
162
+ }
163
+
164
+ /** Construction inputs for a {@link VotingQuorumAssembler}; deps are per-role and validated at call time. */
165
+ export interface VotingQuorumAssemblerDeps {
166
+ /** Stable topic anchor; defaults to db-core's ring-hash anchor ({@link createMatchTopicAnchor}). */
167
+ readonly anchor?: MatchTopicAnchor;
168
+ /** Discovery surface (walk + sweep) — required by {@link VotingQuorumAssembler.assembleQuorum}. */
169
+ readonly discovery?: QuorumDiscovery;
170
+ /**
171
+ * Per-entry `registrationSig` verifier (db-p2p binds `verifyPeerSig`) — required by
172
+ * {@link VotingQuorumAssembler.assembleQuorum}. db-core is crypto-free, so the peer-key check is
173
+ * injected, matching {@link verifyProviderEntry}'s contract.
174
+ */
175
+ readonly verifyEntrySig?: EntrySigVerifier;
176
+ /** Sign a provider registration image — required by {@link VotingQuorumAssembler.registerEligibleVoter}. */
177
+ readonly sign?: (payload: Uint8Array) => Promise<string>;
178
+ /**
179
+ * Register a signed voter-provider payload at the quorum topic (cohort-topic T2) — required by
180
+ * {@link VotingQuorumAssembler.registerEligibleVoter}. db-p2p binds this to the provider manager /
181
+ * `MatchmakingProvider.register` once `matchmaking-sweep-adversarial-module` lands.
182
+ */
183
+ readonly registerProvider?: (req: RegisterVoterProviderRequest) => Promise<void>;
184
+ /** CSPRNG source for the provider correlation id (injectable for deterministic tests). */
185
+ readonly randomBytes?: (n: number) => Uint8Array;
186
+ /** Wall clock (unix ms), injectable for tests; default `Date.now`. Splits patience across walk → sweep. */
187
+ readonly clock?: () => number;
188
+ }
189
+
190
+ /** What {@link VotingQuorumAssembler.registerEligibleVoter} hands the injected `registerProvider` port. */
191
+ export interface RegisterVoterProviderRequest {
192
+ /** The quorum topic id this voter registers at. */
193
+ readonly topicId: Uint8Array;
194
+ /** The opaque, signed cohort-topic `RegisterV1.appPayload` bytes for the voter provider. */
195
+ readonly appPayloadBytes: Uint8Array;
196
+ }
197
+
198
+ /** Construction inputs for {@link VotingQuorumAssembler.registerEligibleVoter}. */
199
+ export interface RegisterEligibleVoterRequest {
200
+ /** The proposal hash — the quorum topic's label. */
201
+ readonly proposalHash: string;
202
+ /** base64url, app-defined eligibility proof minted by the voting/stake layer. */
203
+ readonly eligibilityProof: string;
204
+ /** Extra application capability tags merged into the registration (the proof tag is always added). */
205
+ readonly capabilityTags?: readonly string[];
206
+ /** Concurrent vote-collection budget; default {@link DEFAULT_VOTER_CAPACITY_BUDGET} (1). */
207
+ readonly capacityBudget?: number;
208
+ /** Multiaddr or PeerId-based callback for the vote-collection dial. */
209
+ readonly contactHint: string;
210
+ /** Optional soft expiry hint (unix ms). */
211
+ readonly serviceUntil?: number;
212
+ }
213
+
214
+ /**
215
+ * Discovery-only voting-quorum assembler over the matchmaking module. One instance can act in either
216
+ * role: a voter calls {@link registerEligibleVoter}; a coordinator (or delegated assembler) calls
217
+ * {@link assembleQuorum}. Deps are role-specific and asserted per method, so a coordinator need not
218
+ * supply `sign`/`registerProvider` and a voter need not supply `discovery`/`verifyEntrySig`.
219
+ */
220
+ export class VotingQuorumAssembler {
221
+ private readonly anchor: MatchTopicAnchor;
222
+ private readonly deps: VotingQuorumAssemblerDeps;
223
+ private readonly clock: () => number;
224
+
225
+ constructor(deps: VotingQuorumAssemblerDeps = {}) {
226
+ this.deps = deps;
227
+ this.anchor = deps.anchor ?? createMatchTopicAnchor();
228
+ this.clock = deps.clock ?? ((): number => Date.now());
229
+ }
230
+
231
+ /**
232
+ * The quorum topic anchor for a proposal: `kind = "quorum"`, `label = proposalHash`, yielding
233
+ * `topicId = H("quorum" ‖ proposalHash ‖ "match")` via the matchmaking topic anchor.
234
+ */
235
+ static quorumTopic(proposalHash: string): { kind: "quorum"; label: string } {
236
+ return { kind: "quorum", label: proposalHash };
237
+ }
238
+
239
+ /** Resolve the cohort-topic `topicId` for a proposal's quorum topic. */
240
+ topicIdFor(proposalHash: string): Uint8Array {
241
+ const { kind, label } = VotingQuorumAssembler.quorumTopic(proposalHash);
242
+ return this.anchor.topicId(kind, label);
243
+ }
244
+
245
+ /**
246
+ * Voter side: bind the caller-minted eligibility proof into `capabilities`, build the signed provider
247
+ * registration, and register it at the quorum topic (cohort-topic T2). The proof is opaque to
248
+ * matchmaking; the provider `signature` covers `(topicId, capabilities, capacityBudget)`, so it binds
249
+ * the proof to the registration.
250
+ */
251
+ async registerEligibleVoter(req: RegisterEligibleVoterRequest): Promise<void> {
252
+ const sign = this.requireSign();
253
+ const registerProvider = this.requireRegisterProvider();
254
+ const topicId = this.topicIdFor(req.proposalHash);
255
+ const capacityBudget = req.capacityBudget ?? DEFAULT_VOTER_CAPACITY_BUDGET;
256
+ const capabilities = mergeEligibilityCapabilities(req.capabilityTags ?? [], req.eligibilityProof);
257
+
258
+ const provider = new MatchmakingProvider({
259
+ topicId,
260
+ capabilities,
261
+ capacityBudget,
262
+ contactHint: req.contactHint,
263
+ sign,
264
+ ...(req.serviceUntil !== undefined ? { serviceUntil: req.serviceUntil } : {}),
265
+ ...(this.deps.randomBytes !== undefined ? { randomBytes: this.deps.randomBytes } : {}),
266
+ });
267
+
268
+ await registerProvider({ topicId, appPayloadBytes: await provider.appPayloadBytes() });
269
+ }
270
+
271
+ /**
272
+ * Coordinator / delegated-assembler side: discover → verify → select. Drives the single-cohort walk,
273
+ * escalates to the multi-cohort sweep on a hot topic (`childCohortCount > 0`) or when `preferSweep`,
274
+ * dedups by `participantId`, applies the optional reputation pre-filter, re-validates each entry's
275
+ * `registrationSig` **and** `verifyEligibility`, then applies the selection rule. Passes the *draining*
276
+ * patience budget to each discovery hop (walk, then sweep) — the deadline is fixed at entry and each
277
+ * hop receives the remaining slice — and on exhaustion returns whatever matched with `metTarget = false`.
278
+ * (Both legs honour `patienceMs`: the walk enforces a wall-clock deadline across hops and hang-out; the
279
+ * sweep stops starting new shard queries once the budget drains. The residual is at most one in-flight
280
+ * shard RPC — assembly never blocks materially past `patienceMs`.)
281
+ */
282
+ async assembleQuorum(req: VotingQuorumRequest): Promise<VotingQuorumResult> {
283
+ const discovery = this.requireDiscovery();
284
+ const verifyEntrySig = this.requireVerifyEntrySig();
285
+ const targetSize = requireTargetSize(req.targetSize);
286
+ requirePositivePatience(req.patienceMs);
287
+ const topicId = this.topicIdFor(req.proposalHash);
288
+
289
+ const deadline = this.clock() + req.patienceMs;
290
+ const remaining = (): number => Math.max(0, deadline - this.clock());
291
+
292
+ // Step 2: single-cohort walk, then the single-vs-sweep decision.
293
+ const walkSlice = await discovery.walk({ topicId, wantCount: targetSize, patienceMs: remaining() });
294
+ const swept = req.preferSweep === true || walkSlice.childCohortCount > 0;
295
+ const slices: QuorumDiscoverySlice[] = [walkSlice];
296
+ if (swept) {
297
+ slices.push(await discovery.sweep({ topicId, wantCount: targetSize, patienceMs: remaining() }));
298
+ }
299
+
300
+ // Step 3: dedup the unioned candidate set by participantId (defensive — promotion/redirect overlap).
301
+ const candidates = dedupByParticipant(slices.flatMap((s) => [...s.entries]));
302
+
303
+ // Step 4: optional, additive reputation pre-filter (bandwidth/quality trim; never a dependency).
304
+ const prefiltered = req.reputationPrefilter ? req.reputationPrefilter([...candidates]) : candidates;
305
+
306
+ // Step 5: reply-side default — both registrationSig re-validation AND verifyEligibility must pass.
307
+ const eligible = prefiltered.filter(
308
+ (entry) => verifyProviderEntry(topicId, entry, verifyEntrySig) && req.verifyEligibility(entry),
309
+ );
310
+
311
+ // Step 6: selection rule (default first targetSize), deduped defensively.
312
+ const select = req.select ?? defaultSelect;
313
+ const quorum = dedupByParticipant(select([...eligible], targetSize));
314
+
315
+ return {
316
+ quorum,
317
+ candidates: candidates.length,
318
+ eligible: eligible.length,
319
+ metTarget: quorum.length >= targetSize,
320
+ swept,
321
+ };
322
+ }
323
+
324
+ private requireDiscovery(): QuorumDiscovery {
325
+ if (this.deps.discovery === undefined) {
326
+ throw new Error("VotingQuorumAssembler.assembleQuorum requires a `discovery` dependency");
327
+ }
328
+ return this.deps.discovery;
329
+ }
330
+
331
+ private requireVerifyEntrySig(): EntrySigVerifier {
332
+ if (this.deps.verifyEntrySig === undefined) {
333
+ throw new Error("VotingQuorumAssembler.assembleQuorum requires a `verifyEntrySig` dependency");
334
+ }
335
+ return this.deps.verifyEntrySig;
336
+ }
337
+
338
+ private requireSign(): (payload: Uint8Array) => Promise<string> {
339
+ if (this.deps.sign === undefined) {
340
+ throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `sign` dependency");
341
+ }
342
+ return this.deps.sign;
343
+ }
344
+
345
+ private requireRegisterProvider(): (req: RegisterVoterProviderRequest) => Promise<void> {
346
+ if (this.deps.registerProvider === undefined) {
347
+ throw new Error("VotingQuorumAssembler.registerEligibleVoter requires a `registerProvider` dependency");
348
+ }
349
+ return this.deps.registerProvider;
350
+ }
351
+ }
352
+
353
+ /** The default selection rule: take the first `targetSize` eligible entries (the discovery order). */
354
+ export function defaultSelect(eligible: ProviderEntryV1[], targetSize: number): ProviderEntryV1[] {
355
+ return eligible.slice(0, targetSize);
356
+ }
357
+
358
+ /**
359
+ * Merge application capability tags with the eligibility proof tag: drop any caller-supplied tag that
360
+ * already uses the reserved prefix (the proof is authoritative), then append exactly one proof tag.
361
+ * Order is preserved end-to-end (the cohort forwards `capabilities` verbatim), so the provider signature
362
+ * reconstructs byte-for-byte on the seeker side.
363
+ */
364
+ function mergeEligibilityCapabilities(tags: readonly string[], proof: string): string[] {
365
+ const appTags = tags.filter((tag) => !tag.startsWith(ELIGIBILITY_TAG_PREFIX));
366
+ return [...appTags, eligibilityTag(proof)];
367
+ }
368
+
369
+ /** Dedup entries by `participantId`, keeping the first occurrence (preserves discovery/FCFS order). */
370
+ function dedupByParticipant(entries: readonly ProviderEntryV1[]): ProviderEntryV1[] {
371
+ const seen = new Map<string, ProviderEntryV1>();
372
+ for (const entry of entries) {
373
+ if (!seen.has(entry.participantId)) {
374
+ seen.set(entry.participantId, entry);
375
+ }
376
+ }
377
+ return [...seen.values()];
378
+ }
379
+
380
+ /** Validate `targetSize` is an integer `>= 1`. */
381
+ function requireTargetSize(targetSize: number): number {
382
+ if (!Number.isInteger(targetSize) || targetSize < 1) {
383
+ throw new RangeError(`voting quorum: targetSize must be an integer >= 1, got ${targetSize}`);
384
+ }
385
+ return targetSize;
386
+ }
387
+
388
+ /** Validate `patienceMs` is a finite number `> 0` (the voting 30–300 s range is advisory, not enforced). */
389
+ function requirePositivePatience(patienceMs: number): number {
390
+ if (!Number.isFinite(patienceMs) || patienceMs <= 0) {
391
+ throw new RangeError(`voting quorum: patienceMs must be a finite number > 0, got ${patienceMs}`);
392
+ }
393
+ return patienceMs;
394
+ }