@serfab/cadre-core 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/cadre-node.d.ts +0 -27
  2. package/dist/cadre-node.js +2 -94
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-schema.js +1066 -1066
  5. package/dist/diagnostics/connection-path.d.ts +1 -18
  6. package/dist/diagnostics/connection-path.js +1 -20
  7. package/dist/diagnostics/connection-path.js.map +1 -1
  8. package/dist/identity-key.d.ts +0 -30
  9. package/dist/identity-key.js +7 -40
  10. package/dist/identity-key.js.map +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +6 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/relay-stun.d.ts +24 -0
  15. package/dist/relay-stun.js +51 -0
  16. package/dist/relay-stun.js.map +1 -0
  17. package/dist/seed-bootstrap.js +4 -4
  18. package/dist/strand-membership-writer.js +32 -32
  19. package/package.json +2 -2
  20. package/src/cadre-node.ts +8654 -8753
  21. package/src/control-authorization.ts +117 -117
  22. package/src/control-cohort.ts +128 -128
  23. package/src/control-formation-recorder.ts +353 -353
  24. package/src/control-read-retry.ts +216 -216
  25. package/src/control-schema.ts +1078 -1078
  26. package/src/control-write-retry.ts +567 -567
  27. package/src/diagnostics/connection-path.ts +1 -29
  28. package/src/formation-approval.ts +610 -610
  29. package/src/hibernation-manager.ts +638 -638
  30. package/src/identity-key.ts +7 -62
  31. package/src/index.ts +644 -647
  32. package/src/invitation-bootstrap.ts +40 -40
  33. package/src/joined-strand-store.ts +498 -498
  34. package/src/link-budget.ts +684 -684
  35. package/src/membership-connection-gater.ts +570 -570
  36. package/src/node-local-snapshot.ts +298 -298
  37. package/src/peer-dial.ts +315 -315
  38. package/src/peer-join-backfill.ts +710 -710
  39. package/src/pending-join-runner.ts +735 -735
  40. package/src/relay-reservation.ts +1121 -1121
  41. package/src/relay-server.ts +98 -98
  42. package/src/relay-stun.ts +58 -0
  43. package/src/seed-bootstrap.ts +1448 -1448
  44. package/src/strand-addr-protocol.ts +547 -547
  45. package/src/strand-database.ts +287 -287
  46. package/src/strand-first-sync-gate.ts +391 -391
  47. package/src/strand-formation-deadlines.ts +227 -227
  48. package/src/strand-formation-manager.ts +725 -725
  49. package/src/strand-fret-addrs.ts +145 -145
  50. package/src/strand-instance-manager.ts +1789 -1789
  51. package/src/strand-membership-writer.ts +1974 -1974
  52. package/src/strand-network-state-file.ts +40 -40
  53. package/src/strand-network-state.ts +243 -243
  54. package/src/strand-revocation-enforcer.ts +654 -654
  55. package/src/strand-solicitation.ts +532 -532
  56. package/src/strand-wake-protocol.ts +401 -401
  57. package/src/types.ts +2222 -2222
  58. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  59. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  60. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  61. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
@@ -1,684 +1,684 @@
1
- /**
2
- * Dial and reservation budgets derived from ONE declared link round trip, instead of fixed
3
- * millisecond numbers chosen on a fast local network.
4
- *
5
- * Reaching another machine through a relay costs a FIXED NUMBER OF EXCHANGES, not a fixed
6
- * number of milliseconds. So every budget written as milliseconds has a link speed above which
7
- * it can never open a connection at all — and the slower the link, the more of them fall over,
8
- * silently, because an abandoned dial looks the same as a peer that is not there. Counting round
9
- * trips instead is what stops that class of defect coming back: the next person to widen a
10
- * budget changes ONE declaration, and the next person to add a dial writes down its round-trip
11
- * count beside the arithmetic.
12
- *
13
- * This module is the only place a new deadline over the link should be written, and in
14
- * cadre-core's source `yarn lint` enforces it (`LINK_DEADLINE_GUARD` in `eslint.config.mjs`): a
15
- * value named `…TIMEOUT_MS`, `…BUDGET_MS` or `…DEADLINE_MS` (or `…TimeoutMs` and the like) set
16
- * to a number rather than a derivation fails, unless the line above it gives one of four
17
- * reasons. `// eslint-disable-next-line no-restricted-syntax -- link-independent: <why>` keeps a
18
- * deadline that never waits on the link; `-- link-bound, not yet derived: <ticket slug>` marks
19
- * one that does and names the ticket that owns converting it. Grep for the second to list the
20
- * deadlines this module does not yet cover. `-- cuts off by design: <why>; see
21
- * docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"` keeps a deadline
22
- * that does wait on the link, through an Optimystic read or commit, but whose value is what its
23
- * caller can tolerate: it gives up on purpose and must not grow with the link. That doc section
24
- * lists every such site. `-- measured, not derived: <where the measurement lives>` keeps a
25
- * deadline that waits on the link but has no round-trip count to derive from, because it bounds
26
- * a whole phase of many exchanges whose number depends on the data (a joining machine's first
27
- * sync): its value is sized from a recorded measurement, which the directive names, and it is
28
- * re-measured rather than recomputed when a number under it moves.
29
- *
30
- * Optimystic's per-peer cohort read deadline is derived here too ({@link cohortReadDeadlineMs}),
31
- * but declared in another package: `COHORT_READ_DEADLINE_MS` in
32
- * `quereus-plugin-sereus/src/cluster-size.ts` is the same arithmetic at the default declaration,
33
- * spelled as a number because that package cannot import this one. `link-budget.spec.ts` pins
34
- * the two equal.
35
- *
36
- * ── The instrument ──
37
- *
38
- * Every count and every number below, except the commit count, comes from
39
- * `packages/integration-tests/src/scenarios/relayed-dial-cost-by-latency.integration.ts`, which
40
- * is committed and opt-in (`RELAY_DIAL_COST=1`). **Its doc comment is the single home of the
41
- * measurement**; this module holds only what the derivation needs. Re-run it before changing
42
- * anything here. The commit count ({@link COMMIT_ROUND_TRIPS}) is a whole-operation
43
- * measurement from `relay-round-trip-measure.integration.ts` instead, and its figures live on
44
- * the constant.
45
- *
46
- * ── The unit ──
47
- *
48
- * ONE LINK ROUND TRIP is a message from one machine reaching the other and a reply coming
49
- * back, over the path those two machines actually use — through the relay, when that is the
50
- * only way they can reach each other. In the instrument, which injects a constant ONE-WAY
51
- * frame delay `d`, one link round trip is `2d`.
52
- *
53
- * A leg between a node and the RELAY is half a link round trip, because it crosses one of the
54
- * two hops rather than both. That is why the reservation drive's cost reads as "four
55
- * node-to-relay round trips" in one telling and "two link round trips" in another: same
56
- * quantity, different unit. This module uses the LINK round trip throughout, so there is one
57
- * unit to reason in.
58
- *
59
- * ── The counts, and what they cost ──
60
- *
61
- * | operation | link round trips | at the declared default |
62
- * | ---------------------------------------------------------------- | ---------------- | ----------------------- |
63
- * | open a relayed connection over a relay connection already open | 4 | 14 000 ms |
64
- * | dial the relay itself | 1 | 3 500 ms |
65
- * | open a relayed connection, opening the relay connection first | 5 | 17 500 ms |
66
- * | request a reservation on an open relay connection | 1 | 3 500 ms |
67
- * | negotiate a protocol over an established circuit | 1 | 3 500 ms |
68
- * | one request and its answer over an open circuit | 2 | 7 000 ms |
69
- * | open a relayed connection, then negotiate one protocol | 6 | 21 000 ms |
70
- * | open a relayed connection, then one request on it | 7 | 24 500 ms |
71
- * | one Optimystic commit over connections already open | 20 | 70 000 ms |
72
- *
73
- * Every row that opens a relayed connection counts opening the relay connection too
74
- * ({@link RELAYED_DIAL_ROUND_TRIPS}), because a dialer cannot know beforehand whether it holds
75
- * one, and a budget that is too short fails as silently as an absent peer. The
76
- * reservation-request row is {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}, which also sizes how long
77
- * a party-run relay waits for a stranger's reservation ({@link relayAdmissionReserveDeadlineMs}).
78
- * The commit row is a measurement of a whole write, not a count of exchanges; its doc comment
79
- * ({@link COMMIT_ROUND_TRIPS}) holds the figures and says when to re-measure it.
80
- *
81
- * **Measured** 2026-09-26, one Windows machine, loopback dedicated relay: a relayed dial over a
82
- * relay connection the dialer already held took 20-25 ms at no delay, **7 255-7 279 ms at 900 ms
83
- * one-way**, and **12 066-12 094 ms at 1 500 ms one-way**. Four link round trips of pure delay
84
- * would be 7 200 and 12 000, so the real cost sits about 60-95 ms ABOVE the arithmetic — the
85
- * handshakes, which no delay figure contains. On 2026-10-01 (libp2p 3.3.11), a dialer holding no
86
- * relay connection took **15 113 ms at 1 500 ms one-way**: ten one-way delays, five link round
87
- * trips, 113 ms above the arithmetic. That residue is why {@link DECLARED_LINK_ROUND_TRIP_MS}
88
- * carries headroom over the link sereus supports rather than matching it exactly: a declaration
89
- * equal to the supported round trip would derive a budget marginally BELOW the dial it has to
90
- * contain.
91
- *
92
- * NOTE: the instrument splits the delay evenly across both hops (each node's own dialed socket
93
- * gets the same one-way figure). On a link whose whole round trip sits on one machine's hop to
94
- * the relay (a phone on a congested mobile link), the exchanges that cross only that hop — the
95
- * relay dial, the hop-stream negotiation and CONNECT — may cost up to twice their share of the
96
- * count. Not measured. If relayed dials are seen failing near their budget on such a link,
97
- * measure that split before raising any count or the declaration.
98
- *
99
- * ── Admission decisions ──
100
- *
101
- * The table is link time only. Every budget that OPENS a connection also adds
102
- * {@link ADMISSION_DECISION_TIMEOUT_MS} (2 000 ms) once for each gate the open may pass, because
103
- * each gate runs on the dialer's clock (the constant's doc says why). A relayed dial passes
104
- * {@link DIAL_ADMISSION_DECISIONS}, two: a party-run relay's, on the dialer's connection to it,
105
- * then the called machine's. So a relayed dial is budgeted at 17 500 + 2 x 2 000 = 21 500 ms at
106
- * the default declaration, a dial plus one request at 28 500 ms, and a reservation drive, which
107
- * a party-run relay decides twice, at 18 000 ms. The allowance is flat: it is local decision
108
- * time, not link time, so it does not scale with the declaration. One allowance per gate, rather
109
- * than slack in the count, is what keeps each budget valid at every declaration.
110
- *
111
- * The measurement above crossed no gate. The instrument is bare libp2p on every side, with no
112
- * cadre connection gater, so the 15 113 ms contains no admission decision at all. The
113
- * allowances are therefore on top of the measured dial rather than already inside it.
114
- *
115
- * ── Optimystic's deadlines, from the same declaration ──
116
- *
117
- * cadre states its declared link to Optimystic on the control node and every strand node
118
- * (`@optimystic/db-p2p`'s `NodeOptions.linkRoundTripMs`), and Optimystic derives its network
119
- * deadlines from it (`resolveLinkDeadlines` in its `rpc-deadline.ts`), each never below the LAN
120
- * value it replaced. Optimystic's derivation is link time only, so cadre states the three limits
121
- * that cover opening a connection itself ({@link optimysticDialLimits}): Optimystic's value plus
122
- * {@link DIAL_ADMISSION_DECISIONS} admission decisions. At the default declaration:
123
- *
124
- * | deadline | link round trips | at the default | undeclared |
125
- * | ------------------------------------------------------- | ---------------------- | -------------- | ---------- |
126
- * | libp2p `addressDialTimeout`, one address of a dial | 10, + 2 decisions | 39 000 ms | 6 000 ms |
127
- * | libp2p `dialTimeout`, a dial that carries no signal | 10, + 2 decisions | 39 000 ms | 10 000 ms |
128
- * | libp2p `inboundUpgradeTimeout`, the listener's side | 5 | 17 500 ms | 10 000 ms |
129
- * | one request's dial, stream negotiation included | 11, + 2 decisions | 42 500 ms | 3 000 ms |
130
- * | one request's response | 3 | 10 500 ms | 10 000 ms |
131
- * | a rebalance transfer's dial, and again its reply | request dial, ≥ 30 000 | 42 500 ms | 30 000 ms |
132
- *
133
- * Ten round trips is Optimystic's count for opening a relayed connection through a relay the
134
- * dialer is not yet connected to, which opens that connection first (about 8.6 round trips over
135
- * WebSocket, 9.6 over `wss`), with a margin. The two decisions are the gates that open runs
136
- * through on the dialer's clock: a party-run relay's, on the dialer's connection to it, then the
137
- * called machine's. Each may take {@link ADMISSION_DECISION_TIMEOUT_MS}.
138
- *
139
- * Its churn and under-replication pushes take the request rows' values, and the Quereus plugin
140
- * derives a write's budgets from the request dial: four of them for the transaction (170 000 ms)
141
- * and one for cancelling it (42 500 ms). The undeclared column
142
- * is why cadre always states the value, default included. A fixed 3 000 ms request dial cannot
143
- * open a relayed connection above 375 ms one-way, and it also bounded the protocol negotiation
144
- * on a connection that was already open, which is one link round trip: at the supported link
145
- * every cohort consult was cut off at 3.0 s, before
146
- * {@link cohortReadDeadlineMs} was reached (measured 2026-09-29, on 1.7.0). From 1.8.0 a cohort
147
- * consult runs under the per-peer read deadline alone. Optimystic would derive that deadline
148
- * from the declaration too, at three round trips; cadre states its own, at two
149
- * ({@link cohortReadDeadlineMs} says why).
150
- *
151
- * NOTE: the longer deadlines are paid against a machine that is gone. An Optimystic request
152
- * to a peer that never completes a connection waits up to the 42 500 ms dial deadline, each of
153
- * that peer's addresses up to 39 000 ms inside it, and one that connects and never answers
154
- * waits 10 500. A control write against a connected, silent member fails after about 84 s
155
- * (one pend round and three cancel rounds; `docs/cadre-consistency.md` → "Deadlines Over
156
- * Optimystic's Reads and Commits"). The dial case was not measured. cadre's cut-off budgets
157
- * (`CONTROL_WRITE_RETRY_BUDGET_MS`, `CONTROL_READ_RETRY_BUDGET_MS`) are checked between
158
- * attempts, so one attempt can outlast them by that much. If writes on a fast link are seen
159
- * waiting out the dial deadline against an unreachable machine, declare that deployment's real
160
- * round trip instead of the default.
161
- *
162
- * The LISTENER's `inboundUpgradeTimeout` is how long the machine being called lets a half-built
163
- * connection finish its handshakes, and it is the limit that makes a dial outlasting it silent:
164
- * at libp2p's 10 000 ms and 1 500 ms one-way, the dialer's own 12 094 ms dial RESOLVED while
165
- * the listener had already thrown the connection away at 10 s, so the first stream over it died
166
- * with `Unexpected EOF - stream closed while reading 0/1 bytes` and the listener reported no
167
- * peer at all. cadre leaves it to Optimystic, with no decisions added: its timer starts when the
168
- * relay hands the listener the inbound circuit, so it runs over the listener's own upgrade and
169
- * the listener's own decision, never over the dialer's connection to the relay or the relay's
170
- * decision. So it has to contain the part of {@link relayedDialBudgetMs} its clock covers, not
171
- * the whole budget: at most the circuit leg ({@link CIRCUIT_DIAL_ROUND_TRIPS}) plus one
172
- * {@link ADMISSION_DECISION_TIMEOUT_MS}. Optimystic's derivation does at every declaration: five
173
- * round trips are at least four plus the allowance once the round trip reaches 2 000 ms, and the
174
- * 10 000 ms floor covers every shorter one. `link-budget.spec.ts` pins that against Optimystic's
175
- * own derivation, so a change on either side that breaks it fails there rather than silently on
176
- * a slow link.
177
- *
178
- * NOTE: the whole {@link relayedDialBudgetMs} is longer than the listener's limit (21 500 ms
179
- * against 17 500 ms at the default), so on a link SLOWER than the one declared, a dial over a
180
- * relay connection the dialer already holds can outlast the listener while its own budget still
181
- * runs (at the default, between about a 4.4 s and a 5.4 s round trip), and fails silently
182
- * instead of with a timeout. At or below the declared link it cannot. If streams dying with
183
- * `Unexpected EOF` are seen on such a link, declare that link's real round trip.
184
- *
185
- * NOTE: derived from the declaration alone, so a per-field override that raises a cadre dial
186
- * budget above it (`controlCohort.perAddressDialTimeoutMs`, `strandBackfill.dialTimeoutMs`)
187
- * does not raise the listener's limit; a dial that outlasts it gets the silent failure again.
188
- * If such overrides start being used for slow links, raise the declaration instead.
189
- *
190
- * ── What still fails at the supported link ──
191
- *
192
- * - **A machine that declares a faster link than its peers.** Every machine is the listener
193
- * for the others, so its `inboundUpgradeTimeout` — which Optimystic derives from ITS
194
- * declaration — bounds connections other machines open to it. A peer declaring 3 500 ms
195
- * dialing a machine that declared 500 ms gets exactly the silent failure above, once its
196
- * dial outlasts that machine's 10 000 ms floor. Strand formation has the same
197
- * shape: the joiner derives how long it waits for the host's reply from ITS declaration and
198
- * the host derives its provisioning budget from its own, so a joiner declaring a faster
199
- * link than the host gives up on a reply the host is still entitled to send. Declare the
200
- * same link on every machine of a party.
201
- * Relay admission too: a party-run relay derives how long a stranger has to ask for a
202
- * reservation from ITS declaration ({@link relayAdmissionReserveDeadlineMs}), so a relay
203
- * declaring a faster link than a client closes that client's connection before its request
204
- * arrives.
205
- */
206
-
207
- import { resolveLinkDeadlines, type Libp2pConnectionTimeouts, type RpcDeadlineDefaults } from '@optimystic/db-p2p';
208
-
209
- /**
210
- * The link round trip cadre assumes when a host declares none, in milliseconds. A deployment
211
- * that knows its own link states it with `NetworkConfig.linkRoundTripMs` instead.
212
- *
213
- * **Why 3 500.** Sereus supports two machines that reach each other only through a relay up to
214
- * a **3-second link round trip** (1 500 ms each way — a congested mobile or satellite link;
215
- * `docs/architecture.md` → "Relay Integration"). The worst relayed dial measured at that link,
216
- * opening the relay connection first, was 15 113 ms, above the 15 000 ms a declaration of
217
- * exactly 3 000 would derive, so the declaration rounds the supported link up to the next half
218
- * second. 3 500 derives 17 500 ms of link time and leaves about 2.4 s over that dial: room for a
219
- * phone's Noise handshakes, which the loopback measurement on a desktop does not contain. The
220
- * admission decisions are not in that 2.4 s; they have their own allowance on top (the module
221
- * doc's "Admission decisions").
222
- *
223
- * One budget outside this module does NOT move with it: `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`
224
- * (`strand-first-sync-gate.ts`) bounds a whole first sync, which has no round-trip count, so it
225
- * is sized from a measurement instead and re-measured when this declaration or the cohort read
226
- * deadline moves. The per-peer cohort read deadline itself does move with it
227
- * ({@link cohortReadDeadlineMs}).
228
- *
229
- * What 3 500 costs, against 2 000: a peer that is genuinely gone holds each operation longer
230
- * before it is abandoned and retried — the link part of a relayed dial 17.5 s instead of 10 s,
231
- * of a reservation drive 14 s instead of 8 s, and of a control-cohort dial of one peer across
232
- * all its addresses 70 s instead of 40 s. The admission allowances add to each (see
233
- * {@link relayedDialBudgetMs}).
234
- * Raise it further for a link slower still; lower it only on a deployment where EVERY machine
235
- * of the party is that close, for the reason the "What still fails" list gives.
236
- */
237
- export const DECLARED_LINK_ROUND_TRIP_MS = 3500;
238
-
239
- /**
240
- * Link round trips opening a relayed connection costs over a relay connection the dialer
241
- * ALREADY holds: the circuit open through the relay (hop-stream negotiation and CONNECT, then
242
- * the relay's stop stream to the called machine), then the encryption and multiplexer
243
- * handshakes end to end through it. Measured at 8 one-way delays.
244
- */
245
- export const CIRCUIT_DIAL_ROUND_TRIPS = 4;
246
-
247
- /**
248
- * Link round trips the dialer spends opening its own connection to the relay: transport,
249
- * encryption and multiplexer handshakes on its one hop. Measured at 2 one-way delays. libp2p's
250
- * circuit transport opens it inside the relayed dial, under the caller's signal, whenever no
251
- * relay connection is open yet.
252
- */
253
- export const RELAY_DIAL_ROUND_TRIPS = 1;
254
-
255
- /**
256
- * Link round trips a relayed dial is budgeted for: opening the relay connection
257
- * ({@link RELAY_DIAL_ROUND_TRIPS}) and then the circuit over it ({@link CIRCUIT_DIAL_ROUND_TRIPS}).
258
- * Measured at 10 one-way delays from a dialer holding no relay connection. Counted for every
259
- * relayed dial, because a dialer cannot know beforehand whether that connection is open.
260
- */
261
- export const RELAYED_DIAL_ROUND_TRIPS = RELAY_DIAL_ROUND_TRIPS + CIRCUIT_DIAL_ROUND_TRIPS;
262
-
263
- /**
264
- * Link round trips from the moment a party-run relay admits a stranger's connection for relay
265
- * use to the moment that stranger's RESERVE request reaches it. libp2p's reservation store
266
- * (`@libp2p/circuit-relay-v2`'s `addRelay`) opens the hop stream the instant its dial resolves,
267
- * without waiting for identify, so:
268
- *
269
- * 1. The client's last Noise handshake message and its hop-stream open arrive at the relay
270
- * together. The relay's gate runs on the handshake message (and arms the reserve deadline),
271
- * then the relay answers the protocol negotiation.
272
- * 2. The answer reaches the client and the client's RESERVE request reaches the relay: one round
273
- * trip on the client-to-relay hop, which is the whole link round trip when all of the link's
274
- * delay is on the client's own hop (a phone on a congested mobile link).
275
- *
276
- * The relay's decision on that request is local time, not link time;
277
- * {@link relayAdmissionReserveDeadlineMs} adds it separately.
278
- */
279
- export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
280
-
281
- /**
282
- * Link round trips one whole reservation drive is budgeted for
283
- * (`relay-reservation.ts`'s `driveRelayReservation`, whose ONE deadline covers the dial, the
284
- * reservation request and the wait together).
285
- *
286
- * The protocol work measured is 2 — dial the relay ({@link RELAY_DIAL_ROUND_TRIPS}) plus request
287
- * the reservation ({@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}). It is budgeted at twice that,
288
- * because the wait afterwards may instead be satisfied by libp2p's own relay discovery, which
289
- * repeats both legs after identify has run; 4 is therefore the largest thing this one deadline
290
- * can be asked to contain, and the poll interval (`DEFAULT_RELAY_RESERVE_POLL_MS`) rounds up on
291
- * top of it.
292
- */
293
- export const RELAY_RESERVATION_ROUND_TRIPS = 2 * (RELAY_DIAL_ROUND_TRIPS + RELAY_RESERVE_REQUEST_ROUND_TRIPS);
294
-
295
- /**
296
- * Link round trips negotiating one protocol over an ALREADY-OPEN connection costs: the
297
- * multistream-select exchange that `dialProtocol` / `newStream` runs before the stream is
298
- * handed back. Measured at 2 one-way delays.
299
- */
300
- export const PROTOCOL_NEGOTIATION_ROUND_TRIPS = 1;
301
-
302
- /**
303
- * Link round trips one request-and-answer over an ALREADY-OPEN circuit costs: the protocol
304
- * negotiation ({@link PROTOCOL_NEGOTIATION_ROUND_TRIPS}) plus the request and its response (1).
305
- * It does not include a dial — a caller that may have to open the connection budgets
306
- * {@link RELAYED_DIAL_ROUND_TRIPS} separately, which is exactly what the two-field shape of
307
- * Optimystic's `dialTimeoutMs` / `responseTimeoutMs` pair is for.
308
- */
309
- export const CIRCUIT_REQUEST_ROUND_TRIPS = PROTOCOL_NEGOTIATION_ROUND_TRIPS + 1;
310
-
311
- /**
312
- * Link round trips one Optimystic commit is budgeted for, over connections that are ALREADY
313
- * open. **Measured, not counted**: a commit is many sequential exchanges whose exact number
314
- * the transactor decides, so this is a whole-operation figure and it depends on the Optimystic
315
- * version.
316
- *
317
- * Measured 2026-09-23 at optimystic `9e5c1e85` with
318
- * `packages/integration-tests/src/scenarios/relay-round-trip-measure.integration.ts`,
319
- * `delayed` configuration (`RELAY_RRT_MEASURE=1 RELAY_RRT_CONFIG=delayed`: a 150 ms one-way
320
- * delay on one party's link, so a 300 ms link round trip): one strand insert took 3.8–6.1 s
321
- * (the founder 4.46, 4.43, 3.84 s; the joiner 5.11, 5.09, 6.09 s), which is up to 20.3 link
322
- * round trips, including about 0.1–0.2 s of local work (the same inserts on an undelayed
323
- * loopback took 57–165 ms). A static count of the write path agrees on the order: three repo
324
- * messages through three consensus rounds each, nine sequential requests of a negotiation plus
325
- * a request, so 18.
326
- *
327
- * Re-measured 2026-09-29 on `@optimystic/*` 1.8.0, same scenario and configuration: the founder
328
- * 3.26-3.94 s and the joiner 4.45-5.17 s over three inserts each, so at most 17.2 link round
329
- * trips. The count stands.
330
- *
331
- * It measures a strand insert; a control-database insert runs the same network transactor
332
- * against the responder's own party's machines, which the module doc already says must
333
- * declare the same link. Re-measure with that scenario, rather than recompute, whenever
334
- * `@optimystic/*` is bumped.
335
- */
336
- export const COMMIT_ROUND_TRIPS = 20;
337
-
338
- /**
339
- * Link round trips one request-and-answer costs when the connection may first have to be OPENED,
340
- * possibly through a relay: {@link RELAYED_DIAL_ROUND_TRIPS} + {@link CIRCUIT_REQUEST_ROUND_TRIPS}.
341
- * The shape of cadre's own one-frame control protocols (strand wake, strand address, seed
342
- * delivery), each of which bounds the dial and the exchange with ONE deadline. Link time only:
343
- * {@link relayedRequestBudgetMs} adds the dial's admission allowances on top.
344
- */
345
- export const RELAYED_REQUEST_ROUND_TRIPS = RELAYED_DIAL_ROUND_TRIPS + CIRCUIT_REQUEST_ROUND_TRIPS;
346
-
347
- /**
348
- * What a block-transfer push message's BYTES get to cross, on top of the latency
349
- * {@link CIRCUIT_REQUEST_ROUND_TRIPS} covers, in milliseconds.
350
- *
351
- * This part is bandwidth, not latency, so it does not scale with the declared round trip: a
352
- * chunk is capped at `PeerJoinBackfillConfig.maxChunkBytes` (1 MiB by default) and crossing it
353
- * costs whatever the link's throughput costs.
354
- *
355
- * NOTE: 6 000 ms is not a measurement of any throughput — it is the residue of the 10 000 ms
356
- * this deadline shipped as before it was split into a latency part and a transfer part, chosen
357
- * so that at the declaration then in force (2 000 ms) the derived value stayed 10 000 and no
358
- * fast link got slower. Nobody has measured how long 1 MiB takes to cross a relayed mobile
359
- * link. If pushes ever time out with the transfer only part done, measure that and raise THIS,
360
- * not the declared round trip.
361
- */
362
- export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
363
-
364
- /**
365
- * Deadline for one admission decision, after which the fail-open outcome is
366
- * used (connection admitted / reservation admitted). Both inbound gaters use
367
- * it: the control node's membership gate (`membership-connection-gater.ts`),
368
- * and the closed-strand revoked-peer gate (`strand-revocation-enforcer.ts`).
369
- * It lives here, not beside the gaters, because the dial budgets below add it
370
- * and this module imports nothing of cadre-core's own: importing the gater
371
- * would close the cycle link-budget → membership-connection-gater →
372
- * seed-bootstrap → link-budget.
373
- *
374
- * libp2p awaits `denyInboundEncryptedConnection` inside the inbound upgrade
375
- * WITHOUT racing its inbound-upgrade timeout signal (unlike the pre-encryption
376
- * `denyInboundConnection` hook), so a decision that never settles wedges that
377
- * upgrade forever — the connection-manager's inbound-upgrade slot is taken by
378
- * `acceptIncomingConnection` and released in the `finally` that never runs. The
379
- * real policy reads the control DB (`listAuthorizedMembers`), which can pull
380
- * over the network, so "never settles" is reachable. Bounding it keeps the
381
- * fail-open contract honest: a slow decision admits rather than silently
382
- * failing closed (or not at all).
383
- *
384
- * The control gate's decision waits on the link, through two live control reads
385
- * (`Revocation`, then `CadrePeer`) that can consult the cohort, yet this deadline
386
- * cuts it off on purpose and must not grow with the link:
387
- *
388
- * - **Fail-open is the designed outcome.** An expired decision admits a
389
- * connection and nothing more: the per-protocol stream gates still refuse
390
- * every members-only protocol from the materialized snapshot, and unplaced
391
- * relay reservations stay capped (`MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
392
- * - **The slow case is bring-up, not steady state.** A membership read consults
393
- * the cohort only for a block this node does not hold, or before the
394
- * `Revocation` ledger marker exists; once the marker exists every block the
395
- * decision reads is held (`control-founding-consult-budget.spec.ts` pins both
396
- * states). So the gate always fails open only on a node whose membership reads
397
- * still consult: one consult costs about two link round trips, and asking a
398
- * silent peer costs the per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s
399
- * at the default declaration).
400
- * - **The decision is spent on the dialing machine's clock.** libp2p's listener
401
- * runs this gate before it answers the multiplexer negotiation the dialer is
402
- * waiting on (`libp2p/dist/src/upgrader.js`), and the listener's own
403
- * `inboundUpgradeTimeout` is already running while it does; a party-run
404
- * relay runs the same gate on the dialer's connection to it. So
405
- * {@link relayedDialBudgetMs}, every budget built on it, and the dial limits
406
- * cadre states to Optimystic ({@link optimysticDialLimits}) add this deadline
407
- * once for each of the {@link DIAL_ADMISSION_DECISIONS} gates a dial passes,
408
- * and the listener's limit Optimystic derives contains the called machine's
409
- * (the module doc's "Optimystic's deadlines, from the same declaration").
410
- * Raising it therefore lengthens every dial budget with it: a slow decision
411
- * still fits, and a dial to a peer that is gone takes that much longer to
412
- * give up.
413
- *
414
- * `CONTROL_READ_RETRY_BUDGET_MS` must stay below this, which
415
- * `control-read-retry.spec.ts` pins.
416
- */
417
- // eslint-disable-next-line no-restricted-syntax -- cuts off by design: an expired decision admits a connection only, and the dial budgets add it as a flat allowance; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
418
- export const ADMISSION_DECISION_TIMEOUT_MS = 2_000;
419
-
420
- /**
421
- * Resolve a host's declared link round trip, falling back to
422
- * {@link DECLARED_LINK_ROUND_TRIP_MS}.
423
- *
424
- * Validated here rather than downstream because nothing downstream would: every consumer
425
- * multiplies this value into a `setTimeout` deadline, where a zero silently turns a budget into
426
- * "give up immediately" and a `NaN` turns it into "never time out".
427
- *
428
- * Both node bring-up paths call this EAGERLY — `CadreNode.start()`, and
429
- * `StrandInstanceManager.buildStrandRuntime` behind `addStrand`/`resumeStrand` — so a bad
430
- * declaration fails the same start that Optimystic's own check on `cohortQueryTimeoutMs` fails.
431
- * The eager call is what makes that true: every budget derived here is behind a condition (a
432
- * node with no control storage builds no catch-up, a node with no relay addrs drives no
433
- * reservation), so waiting for a first consumer would let a broken declaration boot and then
434
- * throw inside a best-effort path that logs and carries on.
435
- */
436
- export function resolveLinkRoundTripMs(linkRoundTripMs?: number): number {
437
- if (linkRoundTripMs === undefined) {
438
- return DECLARED_LINK_ROUND_TRIP_MS;
439
- }
440
- if (!Number.isFinite(linkRoundTripMs) || linkRoundTripMs <= 0) {
441
- throw new Error(
442
- `network.linkRoundTripMs must be a finite number of milliseconds above zero, not ${String(linkRoundTripMs)}`
443
- );
444
- }
445
- return linkRoundTripMs;
446
- }
447
-
448
- /**
449
- * Admission decisions a relayed dial may wait on, each on the dialer's clock: a party-run relay's,
450
- * on the dialer's connection to it (`denyInboundEncryptedConnection`), then the called machine's.
451
- */
452
- export const DIAL_ADMISSION_DECISIONS = 2;
453
-
454
- /**
455
- * Deadline for OPENING a connection to another machine that may only be reachable through a
456
- * relay: {@link RELAYED_DIAL_ROUND_TRIPS} at the declared link round trip, which includes opening
457
- * the dialer's own connection to the relay, plus one {@link ADMISSION_DECISION_TIMEOUT_MS} for
458
- * each of the {@link DIAL_ADMISSION_DECISIONS} gates that run on this dial's clock.
459
- * 5 x 3 500 + 2 x 2 000 = 21 500 ms at the default declaration.
460
- *
461
- * What it costs: a peer that is truly gone holds each dial this long before it is abandoned — a
462
- * per-address dial 21.5 s, a control-cohort dial of one peer across its 4 addresses 86 s, and a
463
- * wake call's two attempts 57 s — of which the relay leg and the relay's decision are 5.5 s per
464
- * dial. A dial over a relay connection already open, or to a machine that runs no gate (the
465
- * dedicated relay container, an open-strand node), gets the same budget; the unused part only
466
- * lengthens a failure there, never a success.
467
- *
468
- * A per-field override of a deadline derived from this one
469
- * (`network.controlCohort.perAddressDialTimeoutMs`, `strandBackfill.dialTimeoutMs`,
470
- * `DialWakeOptions.timeoutMs`, …) replaces the whole derived value, allowances included.
471
- */
472
- export function relayedDialBudgetMs(linkRoundTripMs?: number): number {
473
- return RELAYED_DIAL_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + DIAL_ADMISSION_DECISIONS * ADMISSION_DECISION_TIMEOUT_MS;
474
- }
475
-
476
- /**
477
- * Deadline for what `dialProtocol` does end to end: OPEN a possibly relayed connection
478
- * ({@link relayedDialBudgetMs}, admission allowances included) and then NEGOTIATE one protocol
479
- * on it ({@link PROTOCOL_NEGOTIATION_ROUND_TRIPS} at the declared link round trip). 21 500 +
480
- * 3 500 = 25 000 ms at the default declaration. The strand formation dial is bounded by this;
481
- * the one-frame control exchanges bound the negotiation together with their request instead
482
- * ({@link relayedRequestBudgetMs}).
483
- */
484
- export function relayedStreamOpenBudgetMs(linkRoundTripMs?: number): number {
485
- return relayedDialBudgetMs(linkRoundTripMs) + PROTOCOL_NEGOTIATION_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
486
- }
487
-
488
- /**
489
- * Deadline for one Optimystic commit over connections that are already open:
490
- * {@link COMMIT_ROUND_TRIPS} at the declared link round trip, 70 000 ms at the default
491
- * declaration. No admission allowance: a commit dials nothing, so no called machine decides
492
- * on its clock.
493
- */
494
- export function commitBudgetMs(linkRoundTripMs?: number): number {
495
- return COMMIT_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
496
- }
497
-
498
- /**
499
- * Deadline for one whole relay reservation drive: {@link RELAY_RESERVATION_ROUND_TRIPS} at the
500
- * declared link round trip, plus two {@link ADMISSION_DECISION_TIMEOUT_MS}.
501
- * 4 x 3 500 + 2 x 2 000 = 18 000 ms at the default declaration.
502
- *
503
- * Two, because a party-run relay is a control node and decides twice on the drive's clock:
504
- * once for the connection (`denyInboundEncryptedConnection`), then again for the reservation
505
- * (`denyInboundRelayReservation`), each under the same deadline. The count's slack of 2 round
506
- * trips over the measured protocol work happens to cover both at the default declaration, but
507
- * not at a declared round trip under 2 000 ms; counting them makes the budget hold at every
508
- * declaration.
509
- */
510
- export function relayReservationBudgetMs(linkRoundTripMs?: number): number {
511
- return RELAY_RESERVATION_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + 2 * ADMISSION_DECISION_TIMEOUT_MS;
512
- }
513
-
514
- /**
515
- * How long a party-run relay keeps a connection it admitted only for relay use
516
- * (`membership-connection-gater.ts`'s `'admit-for-relay'`) before closing it, unless a
517
- * reservation is admitted on it first: {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS} at the declared
518
- * link round trip, plus one {@link ADMISSION_DECISION_TIMEOUT_MS} for the relay's own decision on
519
- * the reservation (`denyInboundRelayReservation`), which disarms the timer only once it settles.
520
- * 3 500 + 2 000 = 5 500 ms at the default declaration.
521
- *
522
- * The declaration is the RELAY's own, because the relay is the machine that decides.
523
- *
524
- * What it costs, against the fixed 5 000 ms it replaced: a stranger that connects and never
525
- * reserves holds a mute connection 0.5 s longer at the default declaration.
526
- */
527
- export function relayAdmissionReserveDeadlineMs(linkRoundTripMs?: number): number {
528
- return RELAY_RESERVE_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + ADMISSION_DECISION_TIMEOUT_MS;
529
- }
530
-
531
- /**
532
- * Deadline for one request and its answer over a circuit that is already open —
533
- * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip, plus `transferAllowanceMs`
534
- * for the payload's own bytes.
535
- */
536
- export function circuitRequestBudgetMs(
537
- linkRoundTripMs?: number,
538
- transferAllowanceMs = PUSH_TRANSFER_ALLOWANCE_MS
539
- ): number {
540
- return CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + transferAllowanceMs;
541
- }
542
-
543
- /**
544
- * Deadline for opening a (possibly relayed) connection and completing one small request on it:
545
- * {@link relayedDialBudgetMs}, admission allowances included, plus
546
- * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip — so
547
- * {@link RELAYED_REQUEST_ROUND_TRIPS} of link time in all. 21 500 + 2 x 3 500 = 28 500 ms at the
548
- * default declaration.
549
- *
550
- * No transfer allowance, unlike {@link circuitRequestBudgetMs}: the requests this bounds are a few
551
- * hundred bytes to a few KB, so their bytes cost nothing a round trip does not already cover.
552
- */
553
- export function relayedRequestBudgetMs(linkRoundTripMs?: number): number {
554
- return relayedDialBudgetMs(linkRoundTripMs) + CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
555
- }
556
-
557
- /**
558
- * How long ONE cohort peer gets to answer ONE read-path request — Optimystic's
559
- * `clusterPolicy.cohortQueryTimeoutMs`, which bounds the latest-revision query a read runs when
560
- * its local copy may be stale and the archive fetch that follows it.
561
- *
562
- * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip: 2 x 3 500 = 7 000 ms at
563
- * the default declaration. A read-path request opens a fresh protocol stream over a connection
564
- * that is already open (`@optimystic/db-p2p`'s `openProtocolStream`), so it costs the protocol
565
- * negotiation plus the request and its answer, and no dial. No admission allowance either: the
566
- * called machine decided that when the connection was opened. From `@optimystic/db-p2p` 1.8.0
567
- * this is the only limit on a consult; before it, a fixed 3 000 ms request dial deadline also
568
- * bounded the negotiation and cut every consult off first at the supported link.
569
- *
570
- * Measured at the supported link on 1.8.0 (2026-09-29, 1 500 ms one-way,
571
- * `strand-reattach-first-sync-measure.integration.ts`): every consult on a joining machine
572
- * completed, 6.03-6.05 s after the one before it, which is the two round trips and about 40 ms of
573
- * work, inside this deadline with about 0.95 s to spare.
574
- *
575
- * Stated to Optimystic explicitly rather than left to its own derivation from the same
576
- * declaration, which counts three round trips (10 500 ms at the default declaration). The
577
- * headroom over the supported link is already in the declaration (3 500 over 3 000), and the
578
- * third round trip would lengthen every consult against a peer that is gone, which a joining
579
- * machine's first sync pays several times over. The plugin's frozen policies have to state it
580
- * anyway: a host that embeds the plugin without cadre-core declares no link to Optimystic.
581
- *
582
- * Optimystic derives its whole-reconcile-pass bound from this value, `max(5 000, 5 x per-peer)`
583
- * (`db-p2p/src/cluster/cluster-policy.ts`): 35 000 ms at the default declaration.
584
- *
585
- * What it costs, against the 5 000 ms that preceded it: a peer that is truly gone holds a read
586
- * of a block missing locally 7 s instead of 5 s before the read is declined and retried, and a
587
- * joining machine's first sync runs several such consults. That is why
588
- * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` is re-measured whenever this moves; the bands are on
589
- * that constant.
590
- *
591
- * The plugin declares the same number as `COHORT_READ_DEADLINE_MS` (it cannot import this
592
- * module), and `link-budget.spec.ts` pins the two equal.
593
- */
594
- export function cohortReadDeadlineMs(linkRoundTripMs?: number): number {
595
- return CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
596
- }
597
-
598
- /** The two `NetworkConfig` fields the per-peer cohort read deadline is settled from. */
599
- export interface DeclaredReadDeadline {
600
- /** The host's own per-peer read deadline, which wins over the link derivation. */
601
- cohortQueryTimeoutMs?: number;
602
- /** The host's declared link round trip, from which the deadline is derived when no explicit one is set. */
603
- linkRoundTripMs?: number;
604
- }
605
-
606
- /**
607
- * The per-peer cohort read deadline to declare onto a network's cluster policy, or `undefined`
608
- * for "declare nothing and take the plugin's frozen policy whole". One helper for both policy
609
- * construction sites (`CadreNode`'s control policy and `StrandInstanceManager`'s strand policy),
610
- * because the two networks ride one link and must get the same value.
611
- *
612
- * An explicit `cohortQueryTimeoutMs` wins over the derivation, so a host that set the deadline
613
- * by hand keeps it whatever link it declared. With a declared link and no explicit deadline,
614
- * the deadline is {@link cohortReadDeadlineMs} at that link. With neither, `undefined`: the
615
- * plugin's frozen policy already carries `COHORT_READ_DEADLINE_MS`, which equals the derivation
616
- * at the default declaration, and returning `undefined` lets the builders return that frozen
617
- * object by identity, which existing specs pin.
618
- */
619
- export function declaredCohortReadDeadlineMs(network?: DeclaredReadDeadline): number | undefined {
620
- return network?.cohortQueryTimeoutMs
621
- ?? (network?.linkRoundTripMs === undefined ? undefined : cohortReadDeadlineMs(network.linkRoundTripMs));
622
- }
623
-
624
- /** The two deadlines one peer-join catch-up push needs, both derived from the declared link. */
625
- export interface PeerJoinPushBudget {
626
- /** Opening the connection to the peer being caught up — a relayed dial, in the worst case. */
627
- dialTimeoutMs: number;
628
- /** The push request and its answer over that connection, including the chunk's own bytes. */
629
- responseTimeoutMs: number;
630
- }
631
-
632
- /**
633
- * The dial and response deadlines a peer-join catch-up push needs at a declared link. One
634
- * helper rather than two call-site expressions, because the two construction sites
635
- * (`CadreNode`'s control catch-up and `StrandInstanceManager`'s per-strand one) and
636
- * `DEFAULT_PEER_JOIN_BACKFILL` must not drift apart.
637
- */
638
- export function peerJoinPushBudget(linkRoundTripMs?: number): PeerJoinPushBudget {
639
- return {
640
- dialTimeoutMs: relayedDialBudgetMs(linkRoundTripMs),
641
- responseTimeoutMs: circuitRequestBudgetMs(linkRoundTripMs)
642
- };
643
- }
644
-
645
- /** The connection limits cadre states to Optimystic on every node it builds, beside the declared link. */
646
- export interface OptimysticDialLimits {
647
- /** `NodeOptions.connectionManager`: libp2p's limit on one address of a dial, and on a dial that carries no signal. */
648
- connectionManager: Required<Pick<Libp2pConnectionTimeouts, 'addressDialTimeout' | 'dialTimeout'>>;
649
- /** `NodeOptions.rpcDeadlines`: the dial deadline of every Optimystic request. */
650
- rpcDeadlines: Pick<RpcDeadlineDefaults, 'dialTimeoutMs'>;
651
- }
652
-
653
- /**
654
- * The three limits that cover opening a connection, as cadre states them to Optimystic on the
655
- * control node and every strand node: Optimystic's own derivation from the declared link
656
- * (`resolveLinkDeadlines`), which is link time only, plus one {@link ADMISSION_DECISION_TIMEOUT_MS}
657
- * for each of the {@link DIAL_ADMISSION_DECISIONS} gates the open runs through. 39 000 ms per
658
- * address, 39 000 ms per dial and a 42 500 ms request dial at the default declaration.
659
- *
660
- * Built on Optimystic's derivation rather than restating its round-trip counts, so a change to
661
- * them reaches every node without an edit here. `dialTimeout` stays at least `addressDialTimeout`,
662
- * as Optimystic's derivation has it, because both take the same addition. The request dial stays
663
- * one round trip longer than `addressDialTimeout`, also as Optimystic derives them, although
664
- * Optimystic's `rpcDeadlines` doc asks for it to be no longer: that round trip is the stream
665
- * negotiation after the connection opens, which the per-address limit does not cover.
666
- *
667
- * The listener's `inboundUpgradeTimeout` and the request response deadline are left to Optimystic:
668
- * the listener's timer runs over its own upgrade and its own decision only (the module doc's
669
- * "Optimystic's deadlines, from the same declaration"), and a response waits on no gate.
670
- *
671
- * The declaration is resolved first, so an invalid one fails with cadre's message. A round trip
672
- * above Optimystic's `MAX_LINK_ROUND_TRIP_MS` throws from Optimystic, here.
673
- */
674
- export function optimysticDialLimits(linkRoundTripMs?: number): OptimysticDialLimits {
675
- const derived = resolveLinkDeadlines(resolveLinkRoundTripMs(linkRoundTripMs));
676
- const decisionsMs = DIAL_ADMISSION_DECISIONS * ADMISSION_DECISION_TIMEOUT_MS;
677
- return {
678
- connectionManager: {
679
- addressDialTimeout: derived.addressDialTimeoutMs + decisionsMs,
680
- dialTimeout: derived.libp2pDialTimeoutMs + decisionsMs
681
- },
682
- rpcDeadlines: { dialTimeoutMs: derived.dialTimeoutMs + decisionsMs }
683
- };
684
- }
1
+ /**
2
+ * Dial and reservation budgets derived from ONE declared link round trip, instead of fixed
3
+ * millisecond numbers chosen on a fast local network.
4
+ *
5
+ * Reaching another machine through a relay costs a FIXED NUMBER OF EXCHANGES, not a fixed
6
+ * number of milliseconds. So every budget written as milliseconds has a link speed above which
7
+ * it can never open a connection at all — and the slower the link, the more of them fall over,
8
+ * silently, because an abandoned dial looks the same as a peer that is not there. Counting round
9
+ * trips instead is what stops that class of defect coming back: the next person to widen a
10
+ * budget changes ONE declaration, and the next person to add a dial writes down its round-trip
11
+ * count beside the arithmetic.
12
+ *
13
+ * This module is the only place a new deadline over the link should be written, and in
14
+ * cadre-core's source `yarn lint` enforces it (`LINK_DEADLINE_GUARD` in `eslint.config.mjs`): a
15
+ * value named `…TIMEOUT_MS`, `…BUDGET_MS` or `…DEADLINE_MS` (or `…TimeoutMs` and the like) set
16
+ * to a number rather than a derivation fails, unless the line above it gives one of four
17
+ * reasons. `// eslint-disable-next-line no-restricted-syntax -- link-independent: <why>` keeps a
18
+ * deadline that never waits on the link; `-- link-bound, not yet derived: <ticket slug>` marks
19
+ * one that does and names the ticket that owns converting it. Grep for the second to list the
20
+ * deadlines this module does not yet cover. `-- cuts off by design: <why>; see
21
+ * docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"` keeps a deadline
22
+ * that does wait on the link, through an Optimystic read or commit, but whose value is what its
23
+ * caller can tolerate: it gives up on purpose and must not grow with the link. That doc section
24
+ * lists every such site. `-- measured, not derived: <where the measurement lives>` keeps a
25
+ * deadline that waits on the link but has no round-trip count to derive from, because it bounds
26
+ * a whole phase of many exchanges whose number depends on the data (a joining machine's first
27
+ * sync): its value is sized from a recorded measurement, which the directive names, and it is
28
+ * re-measured rather than recomputed when a number under it moves.
29
+ *
30
+ * Optimystic's per-peer cohort read deadline is derived here too ({@link cohortReadDeadlineMs}),
31
+ * but declared in another package: `COHORT_READ_DEADLINE_MS` in
32
+ * `quereus-plugin-sereus/src/cluster-size.ts` is the same arithmetic at the default declaration,
33
+ * spelled as a number because that package cannot import this one. `link-budget.spec.ts` pins
34
+ * the two equal.
35
+ *
36
+ * ── The instrument ──
37
+ *
38
+ * Every count and every number below, except the commit count, comes from
39
+ * `packages/integration-tests/src/scenarios/relayed-dial-cost-by-latency.integration.ts`, which
40
+ * is committed and opt-in (`RELAY_DIAL_COST=1`). **Its doc comment is the single home of the
41
+ * measurement**; this module holds only what the derivation needs. Re-run it before changing
42
+ * anything here. The commit count ({@link COMMIT_ROUND_TRIPS}) is a whole-operation
43
+ * measurement from `relay-round-trip-measure.integration.ts` instead, and its figures live on
44
+ * the constant.
45
+ *
46
+ * ── The unit ──
47
+ *
48
+ * ONE LINK ROUND TRIP is a message from one machine reaching the other and a reply coming
49
+ * back, over the path those two machines actually use — through the relay, when that is the
50
+ * only way they can reach each other. In the instrument, which injects a constant ONE-WAY
51
+ * frame delay `d`, one link round trip is `2d`.
52
+ *
53
+ * A leg between a node and the RELAY is half a link round trip, because it crosses one of the
54
+ * two hops rather than both. That is why the reservation drive's cost reads as "four
55
+ * node-to-relay round trips" in one telling and "two link round trips" in another: same
56
+ * quantity, different unit. This module uses the LINK round trip throughout, so there is one
57
+ * unit to reason in.
58
+ *
59
+ * ── The counts, and what they cost ──
60
+ *
61
+ * | operation | link round trips | at the declared default |
62
+ * | ---------------------------------------------------------------- | ---------------- | ----------------------- |
63
+ * | open a relayed connection over a relay connection already open | 4 | 14 000 ms |
64
+ * | dial the relay itself | 1 | 3 500 ms |
65
+ * | open a relayed connection, opening the relay connection first | 5 | 17 500 ms |
66
+ * | request a reservation on an open relay connection | 1 | 3 500 ms |
67
+ * | negotiate a protocol over an established circuit | 1 | 3 500 ms |
68
+ * | one request and its answer over an open circuit | 2 | 7 000 ms |
69
+ * | open a relayed connection, then negotiate one protocol | 6 | 21 000 ms |
70
+ * | open a relayed connection, then one request on it | 7 | 24 500 ms |
71
+ * | one Optimystic commit over connections already open | 20 | 70 000 ms |
72
+ *
73
+ * Every row that opens a relayed connection counts opening the relay connection too
74
+ * ({@link RELAYED_DIAL_ROUND_TRIPS}), because a dialer cannot know beforehand whether it holds
75
+ * one, and a budget that is too short fails as silently as an absent peer. The
76
+ * reservation-request row is {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}, which also sizes how long
77
+ * a party-run relay waits for a stranger's reservation ({@link relayAdmissionReserveDeadlineMs}).
78
+ * The commit row is a measurement of a whole write, not a count of exchanges; its doc comment
79
+ * ({@link COMMIT_ROUND_TRIPS}) holds the figures and says when to re-measure it.
80
+ *
81
+ * **Measured** 2026-09-26, one Windows machine, loopback dedicated relay: a relayed dial over a
82
+ * relay connection the dialer already held took 20-25 ms at no delay, **7 255-7 279 ms at 900 ms
83
+ * one-way**, and **12 066-12 094 ms at 1 500 ms one-way**. Four link round trips of pure delay
84
+ * would be 7 200 and 12 000, so the real cost sits about 60-95 ms ABOVE the arithmetic — the
85
+ * handshakes, which no delay figure contains. On 2026-10-01 (libp2p 3.3.11), a dialer holding no
86
+ * relay connection took **15 113 ms at 1 500 ms one-way**: ten one-way delays, five link round
87
+ * trips, 113 ms above the arithmetic. That residue is why {@link DECLARED_LINK_ROUND_TRIP_MS}
88
+ * carries headroom over the link sereus supports rather than matching it exactly: a declaration
89
+ * equal to the supported round trip would derive a budget marginally BELOW the dial it has to
90
+ * contain.
91
+ *
92
+ * NOTE: the instrument splits the delay evenly across both hops (each node's own dialed socket
93
+ * gets the same one-way figure). On a link whose whole round trip sits on one machine's hop to
94
+ * the relay (a phone on a congested mobile link), the exchanges that cross only that hop — the
95
+ * relay dial, the hop-stream negotiation and CONNECT — may cost up to twice their share of the
96
+ * count. Not measured. If relayed dials are seen failing near their budget on such a link,
97
+ * measure that split before raising any count or the declaration.
98
+ *
99
+ * ── Admission decisions ──
100
+ *
101
+ * The table is link time only. Every budget that OPENS a connection also adds
102
+ * {@link ADMISSION_DECISION_TIMEOUT_MS} (2 000 ms) once for each gate the open may pass, because
103
+ * each gate runs on the dialer's clock (the constant's doc says why). A relayed dial passes
104
+ * {@link DIAL_ADMISSION_DECISIONS}, two: a party-run relay's, on the dialer's connection to it,
105
+ * then the called machine's. So a relayed dial is budgeted at 17 500 + 2 x 2 000 = 21 500 ms at
106
+ * the default declaration, a dial plus one request at 28 500 ms, and a reservation drive, which
107
+ * a party-run relay decides twice, at 18 000 ms. The allowance is flat: it is local decision
108
+ * time, not link time, so it does not scale with the declaration. One allowance per gate, rather
109
+ * than slack in the count, is what keeps each budget valid at every declaration.
110
+ *
111
+ * The measurement above crossed no gate. The instrument is bare libp2p on every side, with no
112
+ * cadre connection gater, so the 15 113 ms contains no admission decision at all. The
113
+ * allowances are therefore on top of the measured dial rather than already inside it.
114
+ *
115
+ * ── Optimystic's deadlines, from the same declaration ──
116
+ *
117
+ * cadre states its declared link to Optimystic on the control node and every strand node
118
+ * (`@optimystic/db-p2p`'s `NodeOptions.linkRoundTripMs`), and Optimystic derives its network
119
+ * deadlines from it (`resolveLinkDeadlines` in its `rpc-deadline.ts`), each never below the LAN
120
+ * value it replaced. Optimystic's derivation is link time only, so cadre states the three limits
121
+ * that cover opening a connection itself ({@link optimysticDialLimits}): Optimystic's value plus
122
+ * {@link DIAL_ADMISSION_DECISIONS} admission decisions. At the default declaration:
123
+ *
124
+ * | deadline | link round trips | at the default | undeclared |
125
+ * | ------------------------------------------------------- | ---------------------- | -------------- | ---------- |
126
+ * | libp2p `addressDialTimeout`, one address of a dial | 10, + 2 decisions | 39 000 ms | 6 000 ms |
127
+ * | libp2p `dialTimeout`, a dial that carries no signal | 10, + 2 decisions | 39 000 ms | 10 000 ms |
128
+ * | libp2p `inboundUpgradeTimeout`, the listener's side | 5 | 17 500 ms | 10 000 ms |
129
+ * | one request's dial, stream negotiation included | 11, + 2 decisions | 42 500 ms | 3 000 ms |
130
+ * | one request's response | 3 | 10 500 ms | 10 000 ms |
131
+ * | a rebalance transfer's dial, and again its reply | request dial, ≥ 30 000 | 42 500 ms | 30 000 ms |
132
+ *
133
+ * Ten round trips is Optimystic's count for opening a relayed connection through a relay the
134
+ * dialer is not yet connected to, which opens that connection first (about 8.6 round trips over
135
+ * WebSocket, 9.6 over `wss`), with a margin. The two decisions are the gates that open runs
136
+ * through on the dialer's clock: a party-run relay's, on the dialer's connection to it, then the
137
+ * called machine's. Each may take {@link ADMISSION_DECISION_TIMEOUT_MS}.
138
+ *
139
+ * Its churn and under-replication pushes take the request rows' values, and the Quereus plugin
140
+ * derives a write's budgets from the request dial: four of them for the transaction (170 000 ms)
141
+ * and one for cancelling it (42 500 ms). The undeclared column
142
+ * is why cadre always states the value, default included. A fixed 3 000 ms request dial cannot
143
+ * open a relayed connection above 375 ms one-way, and it also bounded the protocol negotiation
144
+ * on a connection that was already open, which is one link round trip: at the supported link
145
+ * every cohort consult was cut off at 3.0 s, before
146
+ * {@link cohortReadDeadlineMs} was reached (measured 2026-09-29, on 1.7.0). From 1.8.0 a cohort
147
+ * consult runs under the per-peer read deadline alone. Optimystic would derive that deadline
148
+ * from the declaration too, at three round trips; cadre states its own, at two
149
+ * ({@link cohortReadDeadlineMs} says why).
150
+ *
151
+ * NOTE: the longer deadlines are paid against a machine that is gone. An Optimystic request
152
+ * to a peer that never completes a connection waits up to the 42 500 ms dial deadline, each of
153
+ * that peer's addresses up to 39 000 ms inside it, and one that connects and never answers
154
+ * waits 10 500. A control write against a connected, silent member fails after about 84 s
155
+ * (one pend round and three cancel rounds; `docs/cadre-consistency.md` → "Deadlines Over
156
+ * Optimystic's Reads and Commits"). The dial case was not measured. cadre's cut-off budgets
157
+ * (`CONTROL_WRITE_RETRY_BUDGET_MS`, `CONTROL_READ_RETRY_BUDGET_MS`) are checked between
158
+ * attempts, so one attempt can outlast them by that much. If writes on a fast link are seen
159
+ * waiting out the dial deadline against an unreachable machine, declare that deployment's real
160
+ * round trip instead of the default.
161
+ *
162
+ * The LISTENER's `inboundUpgradeTimeout` is how long the machine being called lets a half-built
163
+ * connection finish its handshakes, and it is the limit that makes a dial outlasting it silent:
164
+ * at libp2p's 10 000 ms and 1 500 ms one-way, the dialer's own 12 094 ms dial RESOLVED while
165
+ * the listener had already thrown the connection away at 10 s, so the first stream over it died
166
+ * with `Unexpected EOF - stream closed while reading 0/1 bytes` and the listener reported no
167
+ * peer at all. cadre leaves it to Optimystic, with no decisions added: its timer starts when the
168
+ * relay hands the listener the inbound circuit, so it runs over the listener's own upgrade and
169
+ * the listener's own decision, never over the dialer's connection to the relay or the relay's
170
+ * decision. So it has to contain the part of {@link relayedDialBudgetMs} its clock covers, not
171
+ * the whole budget: at most the circuit leg ({@link CIRCUIT_DIAL_ROUND_TRIPS}) plus one
172
+ * {@link ADMISSION_DECISION_TIMEOUT_MS}. Optimystic's derivation does at every declaration: five
173
+ * round trips are at least four plus the allowance once the round trip reaches 2 000 ms, and the
174
+ * 10 000 ms floor covers every shorter one. `link-budget.spec.ts` pins that against Optimystic's
175
+ * own derivation, so a change on either side that breaks it fails there rather than silently on
176
+ * a slow link.
177
+ *
178
+ * NOTE: the whole {@link relayedDialBudgetMs} is longer than the listener's limit (21 500 ms
179
+ * against 17 500 ms at the default), so on a link SLOWER than the one declared, a dial over a
180
+ * relay connection the dialer already holds can outlast the listener while its own budget still
181
+ * runs (at the default, between about a 4.4 s and a 5.4 s round trip), and fails silently
182
+ * instead of with a timeout. At or below the declared link it cannot. If streams dying with
183
+ * `Unexpected EOF` are seen on such a link, declare that link's real round trip.
184
+ *
185
+ * NOTE: derived from the declaration alone, so a per-field override that raises a cadre dial
186
+ * budget above it (`controlCohort.perAddressDialTimeoutMs`, `strandBackfill.dialTimeoutMs`)
187
+ * does not raise the listener's limit; a dial that outlasts it gets the silent failure again.
188
+ * If such overrides start being used for slow links, raise the declaration instead.
189
+ *
190
+ * ── What still fails at the supported link ──
191
+ *
192
+ * - **A machine that declares a faster link than its peers.** Every machine is the listener
193
+ * for the others, so its `inboundUpgradeTimeout` — which Optimystic derives from ITS
194
+ * declaration — bounds connections other machines open to it. A peer declaring 3 500 ms
195
+ * dialing a machine that declared 500 ms gets exactly the silent failure above, once its
196
+ * dial outlasts that machine's 10 000 ms floor. Strand formation has the same
197
+ * shape: the joiner derives how long it waits for the host's reply from ITS declaration and
198
+ * the host derives its provisioning budget from its own, so a joiner declaring a faster
199
+ * link than the host gives up on a reply the host is still entitled to send. Declare the
200
+ * same link on every machine of a party.
201
+ * Relay admission too: a party-run relay derives how long a stranger has to ask for a
202
+ * reservation from ITS declaration ({@link relayAdmissionReserveDeadlineMs}), so a relay
203
+ * declaring a faster link than a client closes that client's connection before its request
204
+ * arrives.
205
+ */
206
+
207
+ import { resolveLinkDeadlines, type Libp2pConnectionTimeouts, type RpcDeadlineDefaults } from '@optimystic/db-p2p';
208
+
209
+ /**
210
+ * The link round trip cadre assumes when a host declares none, in milliseconds. A deployment
211
+ * that knows its own link states it with `NetworkConfig.linkRoundTripMs` instead.
212
+ *
213
+ * **Why 3 500.** Sereus supports two machines that reach each other only through a relay up to
214
+ * a **3-second link round trip** (1 500 ms each way — a congested mobile or satellite link;
215
+ * `docs/architecture.md` → "Relay Integration"). The worst relayed dial measured at that link,
216
+ * opening the relay connection first, was 15 113 ms, above the 15 000 ms a declaration of
217
+ * exactly 3 000 would derive, so the declaration rounds the supported link up to the next half
218
+ * second. 3 500 derives 17 500 ms of link time and leaves about 2.4 s over that dial: room for a
219
+ * phone's Noise handshakes, which the loopback measurement on a desktop does not contain. The
220
+ * admission decisions are not in that 2.4 s; they have their own allowance on top (the module
221
+ * doc's "Admission decisions").
222
+ *
223
+ * One budget outside this module does NOT move with it: `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`
224
+ * (`strand-first-sync-gate.ts`) bounds a whole first sync, which has no round-trip count, so it
225
+ * is sized from a measurement instead and re-measured when this declaration or the cohort read
226
+ * deadline moves. The per-peer cohort read deadline itself does move with it
227
+ * ({@link cohortReadDeadlineMs}).
228
+ *
229
+ * What 3 500 costs, against 2 000: a peer that is genuinely gone holds each operation longer
230
+ * before it is abandoned and retried — the link part of a relayed dial 17.5 s instead of 10 s,
231
+ * of a reservation drive 14 s instead of 8 s, and of a control-cohort dial of one peer across
232
+ * all its addresses 70 s instead of 40 s. The admission allowances add to each (see
233
+ * {@link relayedDialBudgetMs}).
234
+ * Raise it further for a link slower still; lower it only on a deployment where EVERY machine
235
+ * of the party is that close, for the reason the "What still fails" list gives.
236
+ */
237
+ export const DECLARED_LINK_ROUND_TRIP_MS = 3500;
238
+
239
+ /**
240
+ * Link round trips opening a relayed connection costs over a relay connection the dialer
241
+ * ALREADY holds: the circuit open through the relay (hop-stream negotiation and CONNECT, then
242
+ * the relay's stop stream to the called machine), then the encryption and multiplexer
243
+ * handshakes end to end through it. Measured at 8 one-way delays.
244
+ */
245
+ export const CIRCUIT_DIAL_ROUND_TRIPS = 4;
246
+
247
+ /**
248
+ * Link round trips the dialer spends opening its own connection to the relay: transport,
249
+ * encryption and multiplexer handshakes on its one hop. Measured at 2 one-way delays. libp2p's
250
+ * circuit transport opens it inside the relayed dial, under the caller's signal, whenever no
251
+ * relay connection is open yet.
252
+ */
253
+ export const RELAY_DIAL_ROUND_TRIPS = 1;
254
+
255
+ /**
256
+ * Link round trips a relayed dial is budgeted for: opening the relay connection
257
+ * ({@link RELAY_DIAL_ROUND_TRIPS}) and then the circuit over it ({@link CIRCUIT_DIAL_ROUND_TRIPS}).
258
+ * Measured at 10 one-way delays from a dialer holding no relay connection. Counted for every
259
+ * relayed dial, because a dialer cannot know beforehand whether that connection is open.
260
+ */
261
+ export const RELAYED_DIAL_ROUND_TRIPS = RELAY_DIAL_ROUND_TRIPS + CIRCUIT_DIAL_ROUND_TRIPS;
262
+
263
+ /**
264
+ * Link round trips from the moment a party-run relay admits a stranger's connection for relay
265
+ * use to the moment that stranger's RESERVE request reaches it. libp2p's reservation store
266
+ * (`@libp2p/circuit-relay-v2`'s `addRelay`) opens the hop stream the instant its dial resolves,
267
+ * without waiting for identify, so:
268
+ *
269
+ * 1. The client's last Noise handshake message and its hop-stream open arrive at the relay
270
+ * together. The relay's gate runs on the handshake message (and arms the reserve deadline),
271
+ * then the relay answers the protocol negotiation.
272
+ * 2. The answer reaches the client and the client's RESERVE request reaches the relay: one round
273
+ * trip on the client-to-relay hop, which is the whole link round trip when all of the link's
274
+ * delay is on the client's own hop (a phone on a congested mobile link).
275
+ *
276
+ * The relay's decision on that request is local time, not link time;
277
+ * {@link relayAdmissionReserveDeadlineMs} adds it separately.
278
+ */
279
+ export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
280
+
281
+ /**
282
+ * Link round trips one whole reservation drive is budgeted for
283
+ * (`relay-reservation.ts`'s `driveRelayReservation`, whose ONE deadline covers the dial, the
284
+ * reservation request and the wait together).
285
+ *
286
+ * The protocol work measured is 2 — dial the relay ({@link RELAY_DIAL_ROUND_TRIPS}) plus request
287
+ * the reservation ({@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}). It is budgeted at twice that,
288
+ * because the wait afterwards may instead be satisfied by libp2p's own relay discovery, which
289
+ * repeats both legs after identify has run; 4 is therefore the largest thing this one deadline
290
+ * can be asked to contain, and the poll interval (`DEFAULT_RELAY_RESERVE_POLL_MS`) rounds up on
291
+ * top of it.
292
+ */
293
+ export const RELAY_RESERVATION_ROUND_TRIPS = 2 * (RELAY_DIAL_ROUND_TRIPS + RELAY_RESERVE_REQUEST_ROUND_TRIPS);
294
+
295
+ /**
296
+ * Link round trips negotiating one protocol over an ALREADY-OPEN connection costs: the
297
+ * multistream-select exchange that `dialProtocol` / `newStream` runs before the stream is
298
+ * handed back. Measured at 2 one-way delays.
299
+ */
300
+ export const PROTOCOL_NEGOTIATION_ROUND_TRIPS = 1;
301
+
302
+ /**
303
+ * Link round trips one request-and-answer over an ALREADY-OPEN circuit costs: the protocol
304
+ * negotiation ({@link PROTOCOL_NEGOTIATION_ROUND_TRIPS}) plus the request and its response (1).
305
+ * It does not include a dial — a caller that may have to open the connection budgets
306
+ * {@link RELAYED_DIAL_ROUND_TRIPS} separately, which is exactly what the two-field shape of
307
+ * Optimystic's `dialTimeoutMs` / `responseTimeoutMs` pair is for.
308
+ */
309
+ export const CIRCUIT_REQUEST_ROUND_TRIPS = PROTOCOL_NEGOTIATION_ROUND_TRIPS + 1;
310
+
311
+ /**
312
+ * Link round trips one Optimystic commit is budgeted for, over connections that are ALREADY
313
+ * open. **Measured, not counted**: a commit is many sequential exchanges whose exact number
314
+ * the transactor decides, so this is a whole-operation figure and it depends on the Optimystic
315
+ * version.
316
+ *
317
+ * Measured 2026-09-23 at optimystic `9e5c1e85` with
318
+ * `packages/integration-tests/src/scenarios/relay-round-trip-measure.integration.ts`,
319
+ * `delayed` configuration (`RELAY_RRT_MEASURE=1 RELAY_RRT_CONFIG=delayed`: a 150 ms one-way
320
+ * delay on one party's link, so a 300 ms link round trip): one strand insert took 3.8–6.1 s
321
+ * (the founder 4.46, 4.43, 3.84 s; the joiner 5.11, 5.09, 6.09 s), which is up to 20.3 link
322
+ * round trips, including about 0.1–0.2 s of local work (the same inserts on an undelayed
323
+ * loopback took 57–165 ms). A static count of the write path agrees on the order: three repo
324
+ * messages through three consensus rounds each, nine sequential requests of a negotiation plus
325
+ * a request, so 18.
326
+ *
327
+ * Re-measured 2026-09-29 on `@optimystic/*` 1.8.0, same scenario and configuration: the founder
328
+ * 3.26-3.94 s and the joiner 4.45-5.17 s over three inserts each, so at most 17.2 link round
329
+ * trips. The count stands.
330
+ *
331
+ * It measures a strand insert; a control-database insert runs the same network transactor
332
+ * against the responder's own party's machines, which the module doc already says must
333
+ * declare the same link. Re-measure with that scenario, rather than recompute, whenever
334
+ * `@optimystic/*` is bumped.
335
+ */
336
+ export const COMMIT_ROUND_TRIPS = 20;
337
+
338
+ /**
339
+ * Link round trips one request-and-answer costs when the connection may first have to be OPENED,
340
+ * possibly through a relay: {@link RELAYED_DIAL_ROUND_TRIPS} + {@link CIRCUIT_REQUEST_ROUND_TRIPS}.
341
+ * The shape of cadre's own one-frame control protocols (strand wake, strand address, seed
342
+ * delivery), each of which bounds the dial and the exchange with ONE deadline. Link time only:
343
+ * {@link relayedRequestBudgetMs} adds the dial's admission allowances on top.
344
+ */
345
+ export const RELAYED_REQUEST_ROUND_TRIPS = RELAYED_DIAL_ROUND_TRIPS + CIRCUIT_REQUEST_ROUND_TRIPS;
346
+
347
+ /**
348
+ * What a block-transfer push message's BYTES get to cross, on top of the latency
349
+ * {@link CIRCUIT_REQUEST_ROUND_TRIPS} covers, in milliseconds.
350
+ *
351
+ * This part is bandwidth, not latency, so it does not scale with the declared round trip: a
352
+ * chunk is capped at `PeerJoinBackfillConfig.maxChunkBytes` (1 MiB by default) and crossing it
353
+ * costs whatever the link's throughput costs.
354
+ *
355
+ * NOTE: 6 000 ms is not a measurement of any throughput — it is the residue of the 10 000 ms
356
+ * this deadline shipped as before it was split into a latency part and a transfer part, chosen
357
+ * so that at the declaration then in force (2 000 ms) the derived value stayed 10 000 and no
358
+ * fast link got slower. Nobody has measured how long 1 MiB takes to cross a relayed mobile
359
+ * link. If pushes ever time out with the transfer only part done, measure that and raise THIS,
360
+ * not the declared round trip.
361
+ */
362
+ export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
363
+
364
+ /**
365
+ * Deadline for one admission decision, after which the fail-open outcome is
366
+ * used (connection admitted / reservation admitted). Both inbound gaters use
367
+ * it: the control node's membership gate (`membership-connection-gater.ts`),
368
+ * and the closed-strand revoked-peer gate (`strand-revocation-enforcer.ts`).
369
+ * It lives here, not beside the gaters, because the dial budgets below add it
370
+ * and this module imports nothing of cadre-core's own: importing the gater
371
+ * would close the cycle link-budget → membership-connection-gater →
372
+ * seed-bootstrap → link-budget.
373
+ *
374
+ * libp2p awaits `denyInboundEncryptedConnection` inside the inbound upgrade
375
+ * WITHOUT racing its inbound-upgrade timeout signal (unlike the pre-encryption
376
+ * `denyInboundConnection` hook), so a decision that never settles wedges that
377
+ * upgrade forever — the connection-manager's inbound-upgrade slot is taken by
378
+ * `acceptIncomingConnection` and released in the `finally` that never runs. The
379
+ * real policy reads the control DB (`listAuthorizedMembers`), which can pull
380
+ * over the network, so "never settles" is reachable. Bounding it keeps the
381
+ * fail-open contract honest: a slow decision admits rather than silently
382
+ * failing closed (or not at all).
383
+ *
384
+ * The control gate's decision waits on the link, through two live control reads
385
+ * (`Revocation`, then `CadrePeer`) that can consult the cohort, yet this deadline
386
+ * cuts it off on purpose and must not grow with the link:
387
+ *
388
+ * - **Fail-open is the designed outcome.** An expired decision admits a
389
+ * connection and nothing more: the per-protocol stream gates still refuse
390
+ * every members-only protocol from the materialized snapshot, and unplaced
391
+ * relay reservations stay capped (`MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
392
+ * - **The slow case is bring-up, not steady state.** A membership read consults
393
+ * the cohort only for a block this node does not hold, or before the
394
+ * `Revocation` ledger marker exists; once the marker exists every block the
395
+ * decision reads is held (`control-founding-consult-budget.spec.ts` pins both
396
+ * states). So the gate always fails open only on a node whose membership reads
397
+ * still consult: one consult costs about two link round trips, and asking a
398
+ * silent peer costs the per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s
399
+ * at the default declaration).
400
+ * - **The decision is spent on the dialing machine's clock.** libp2p's listener
401
+ * runs this gate before it answers the multiplexer negotiation the dialer is
402
+ * waiting on (`libp2p/dist/src/upgrader.js`), and the listener's own
403
+ * `inboundUpgradeTimeout` is already running while it does; a party-run
404
+ * relay runs the same gate on the dialer's connection to it. So
405
+ * {@link relayedDialBudgetMs}, every budget built on it, and the dial limits
406
+ * cadre states to Optimystic ({@link optimysticDialLimits}) add this deadline
407
+ * once for each of the {@link DIAL_ADMISSION_DECISIONS} gates a dial passes,
408
+ * and the listener's limit Optimystic derives contains the called machine's
409
+ * (the module doc's "Optimystic's deadlines, from the same declaration").
410
+ * Raising it therefore lengthens every dial budget with it: a slow decision
411
+ * still fits, and a dial to a peer that is gone takes that much longer to
412
+ * give up.
413
+ *
414
+ * `CONTROL_READ_RETRY_BUDGET_MS` must stay below this, which
415
+ * `control-read-retry.spec.ts` pins.
416
+ */
417
+ // eslint-disable-next-line no-restricted-syntax -- cuts off by design: an expired decision admits a connection only, and the dial budgets add it as a flat allowance; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
418
+ export const ADMISSION_DECISION_TIMEOUT_MS = 2_000;
419
+
420
+ /**
421
+ * Resolve a host's declared link round trip, falling back to
422
+ * {@link DECLARED_LINK_ROUND_TRIP_MS}.
423
+ *
424
+ * Validated here rather than downstream because nothing downstream would: every consumer
425
+ * multiplies this value into a `setTimeout` deadline, where a zero silently turns a budget into
426
+ * "give up immediately" and a `NaN` turns it into "never time out".
427
+ *
428
+ * Both node bring-up paths call this EAGERLY — `CadreNode.start()`, and
429
+ * `StrandInstanceManager.buildStrandRuntime` behind `addStrand`/`resumeStrand` — so a bad
430
+ * declaration fails the same start that Optimystic's own check on `cohortQueryTimeoutMs` fails.
431
+ * The eager call is what makes that true: every budget derived here is behind a condition (a
432
+ * node with no control storage builds no catch-up, a node with no relay addrs drives no
433
+ * reservation), so waiting for a first consumer would let a broken declaration boot and then
434
+ * throw inside a best-effort path that logs and carries on.
435
+ */
436
+ export function resolveLinkRoundTripMs(linkRoundTripMs?: number): number {
437
+ if (linkRoundTripMs === undefined) {
438
+ return DECLARED_LINK_ROUND_TRIP_MS;
439
+ }
440
+ if (!Number.isFinite(linkRoundTripMs) || linkRoundTripMs <= 0) {
441
+ throw new Error(
442
+ `network.linkRoundTripMs must be a finite number of milliseconds above zero, not ${String(linkRoundTripMs)}`
443
+ );
444
+ }
445
+ return linkRoundTripMs;
446
+ }
447
+
448
+ /**
449
+ * Admission decisions a relayed dial may wait on, each on the dialer's clock: a party-run relay's,
450
+ * on the dialer's connection to it (`denyInboundEncryptedConnection`), then the called machine's.
451
+ */
452
+ export const DIAL_ADMISSION_DECISIONS = 2;
453
+
454
+ /**
455
+ * Deadline for OPENING a connection to another machine that may only be reachable through a
456
+ * relay: {@link RELAYED_DIAL_ROUND_TRIPS} at the declared link round trip, which includes opening
457
+ * the dialer's own connection to the relay, plus one {@link ADMISSION_DECISION_TIMEOUT_MS} for
458
+ * each of the {@link DIAL_ADMISSION_DECISIONS} gates that run on this dial's clock.
459
+ * 5 x 3 500 + 2 x 2 000 = 21 500 ms at the default declaration.
460
+ *
461
+ * What it costs: a peer that is truly gone holds each dial this long before it is abandoned — a
462
+ * per-address dial 21.5 s, a control-cohort dial of one peer across its 4 addresses 86 s, and a
463
+ * wake call's two attempts 57 s — of which the relay leg and the relay's decision are 5.5 s per
464
+ * dial. A dial over a relay connection already open, or to a machine that runs no gate (the
465
+ * dedicated relay container, an open-strand node), gets the same budget; the unused part only
466
+ * lengthens a failure there, never a success.
467
+ *
468
+ * A per-field override of a deadline derived from this one
469
+ * (`network.controlCohort.perAddressDialTimeoutMs`, `strandBackfill.dialTimeoutMs`,
470
+ * `DialWakeOptions.timeoutMs`, …) replaces the whole derived value, allowances included.
471
+ */
472
+ export function relayedDialBudgetMs(linkRoundTripMs?: number): number {
473
+ return RELAYED_DIAL_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + DIAL_ADMISSION_DECISIONS * ADMISSION_DECISION_TIMEOUT_MS;
474
+ }
475
+
476
+ /**
477
+ * Deadline for what `dialProtocol` does end to end: OPEN a possibly relayed connection
478
+ * ({@link relayedDialBudgetMs}, admission allowances included) and then NEGOTIATE one protocol
479
+ * on it ({@link PROTOCOL_NEGOTIATION_ROUND_TRIPS} at the declared link round trip). 21 500 +
480
+ * 3 500 = 25 000 ms at the default declaration. The strand formation dial is bounded by this;
481
+ * the one-frame control exchanges bound the negotiation together with their request instead
482
+ * ({@link relayedRequestBudgetMs}).
483
+ */
484
+ export function relayedStreamOpenBudgetMs(linkRoundTripMs?: number): number {
485
+ return relayedDialBudgetMs(linkRoundTripMs) + PROTOCOL_NEGOTIATION_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
486
+ }
487
+
488
+ /**
489
+ * Deadline for one Optimystic commit over connections that are already open:
490
+ * {@link COMMIT_ROUND_TRIPS} at the declared link round trip, 70 000 ms at the default
491
+ * declaration. No admission allowance: a commit dials nothing, so no called machine decides
492
+ * on its clock.
493
+ */
494
+ export function commitBudgetMs(linkRoundTripMs?: number): number {
495
+ return COMMIT_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
496
+ }
497
+
498
+ /**
499
+ * Deadline for one whole relay reservation drive: {@link RELAY_RESERVATION_ROUND_TRIPS} at the
500
+ * declared link round trip, plus two {@link ADMISSION_DECISION_TIMEOUT_MS}.
501
+ * 4 x 3 500 + 2 x 2 000 = 18 000 ms at the default declaration.
502
+ *
503
+ * Two, because a party-run relay is a control node and decides twice on the drive's clock:
504
+ * once for the connection (`denyInboundEncryptedConnection`), then again for the reservation
505
+ * (`denyInboundRelayReservation`), each under the same deadline. The count's slack of 2 round
506
+ * trips over the measured protocol work happens to cover both at the default declaration, but
507
+ * not at a declared round trip under 2 000 ms; counting them makes the budget hold at every
508
+ * declaration.
509
+ */
510
+ export function relayReservationBudgetMs(linkRoundTripMs?: number): number {
511
+ return RELAY_RESERVATION_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + 2 * ADMISSION_DECISION_TIMEOUT_MS;
512
+ }
513
+
514
+ /**
515
+ * How long a party-run relay keeps a connection it admitted only for relay use
516
+ * (`membership-connection-gater.ts`'s `'admit-for-relay'`) before closing it, unless a
517
+ * reservation is admitted on it first: {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS} at the declared
518
+ * link round trip, plus one {@link ADMISSION_DECISION_TIMEOUT_MS} for the relay's own decision on
519
+ * the reservation (`denyInboundRelayReservation`), which disarms the timer only once it settles.
520
+ * 3 500 + 2 000 = 5 500 ms at the default declaration.
521
+ *
522
+ * The declaration is the RELAY's own, because the relay is the machine that decides.
523
+ *
524
+ * What it costs, against the fixed 5 000 ms it replaced: a stranger that connects and never
525
+ * reserves holds a mute connection 0.5 s longer at the default declaration.
526
+ */
527
+ export function relayAdmissionReserveDeadlineMs(linkRoundTripMs?: number): number {
528
+ return RELAY_RESERVE_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + ADMISSION_DECISION_TIMEOUT_MS;
529
+ }
530
+
531
+ /**
532
+ * Deadline for one request and its answer over a circuit that is already open —
533
+ * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip, plus `transferAllowanceMs`
534
+ * for the payload's own bytes.
535
+ */
536
+ export function circuitRequestBudgetMs(
537
+ linkRoundTripMs?: number,
538
+ transferAllowanceMs = PUSH_TRANSFER_ALLOWANCE_MS
539
+ ): number {
540
+ return CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + transferAllowanceMs;
541
+ }
542
+
543
+ /**
544
+ * Deadline for opening a (possibly relayed) connection and completing one small request on it:
545
+ * {@link relayedDialBudgetMs}, admission allowances included, plus
546
+ * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip — so
547
+ * {@link RELAYED_REQUEST_ROUND_TRIPS} of link time in all. 21 500 + 2 x 3 500 = 28 500 ms at the
548
+ * default declaration.
549
+ *
550
+ * No transfer allowance, unlike {@link circuitRequestBudgetMs}: the requests this bounds are a few
551
+ * hundred bytes to a few KB, so their bytes cost nothing a round trip does not already cover.
552
+ */
553
+ export function relayedRequestBudgetMs(linkRoundTripMs?: number): number {
554
+ return relayedDialBudgetMs(linkRoundTripMs) + CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
555
+ }
556
+
557
+ /**
558
+ * How long ONE cohort peer gets to answer ONE read-path request — Optimystic's
559
+ * `clusterPolicy.cohortQueryTimeoutMs`, which bounds the latest-revision query a read runs when
560
+ * its local copy may be stale and the archive fetch that follows it.
561
+ *
562
+ * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip: 2 x 3 500 = 7 000 ms at
563
+ * the default declaration. A read-path request opens a fresh protocol stream over a connection
564
+ * that is already open (`@optimystic/db-p2p`'s `openProtocolStream`), so it costs the protocol
565
+ * negotiation plus the request and its answer, and no dial. No admission allowance either: the
566
+ * called machine decided that when the connection was opened. From `@optimystic/db-p2p` 1.8.0
567
+ * this is the only limit on a consult; before it, a fixed 3 000 ms request dial deadline also
568
+ * bounded the negotiation and cut every consult off first at the supported link.
569
+ *
570
+ * Measured at the supported link on 1.8.0 (2026-09-29, 1 500 ms one-way,
571
+ * `strand-reattach-first-sync-measure.integration.ts`): every consult on a joining machine
572
+ * completed, 6.03-6.05 s after the one before it, which is the two round trips and about 40 ms of
573
+ * work, inside this deadline with about 0.95 s to spare.
574
+ *
575
+ * Stated to Optimystic explicitly rather than left to its own derivation from the same
576
+ * declaration, which counts three round trips (10 500 ms at the default declaration). The
577
+ * headroom over the supported link is already in the declaration (3 500 over 3 000), and the
578
+ * third round trip would lengthen every consult against a peer that is gone, which a joining
579
+ * machine's first sync pays several times over. The plugin's frozen policies have to state it
580
+ * anyway: a host that embeds the plugin without cadre-core declares no link to Optimystic.
581
+ *
582
+ * Optimystic derives its whole-reconcile-pass bound from this value, `max(5 000, 5 x per-peer)`
583
+ * (`db-p2p/src/cluster/cluster-policy.ts`): 35 000 ms at the default declaration.
584
+ *
585
+ * What it costs, against the 5 000 ms that preceded it: a peer that is truly gone holds a read
586
+ * of a block missing locally 7 s instead of 5 s before the read is declined and retried, and a
587
+ * joining machine's first sync runs several such consults. That is why
588
+ * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` is re-measured whenever this moves; the bands are on
589
+ * that constant.
590
+ *
591
+ * The plugin declares the same number as `COHORT_READ_DEADLINE_MS` (it cannot import this
592
+ * module), and `link-budget.spec.ts` pins the two equal.
593
+ */
594
+ export function cohortReadDeadlineMs(linkRoundTripMs?: number): number {
595
+ return CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
596
+ }
597
+
598
+ /** The two `NetworkConfig` fields the per-peer cohort read deadline is settled from. */
599
+ export interface DeclaredReadDeadline {
600
+ /** The host's own per-peer read deadline, which wins over the link derivation. */
601
+ cohortQueryTimeoutMs?: number;
602
+ /** The host's declared link round trip, from which the deadline is derived when no explicit one is set. */
603
+ linkRoundTripMs?: number;
604
+ }
605
+
606
+ /**
607
+ * The per-peer cohort read deadline to declare onto a network's cluster policy, or `undefined`
608
+ * for "declare nothing and take the plugin's frozen policy whole". One helper for both policy
609
+ * construction sites (`CadreNode`'s control policy and `StrandInstanceManager`'s strand policy),
610
+ * because the two networks ride one link and must get the same value.
611
+ *
612
+ * An explicit `cohortQueryTimeoutMs` wins over the derivation, so a host that set the deadline
613
+ * by hand keeps it whatever link it declared. With a declared link and no explicit deadline,
614
+ * the deadline is {@link cohortReadDeadlineMs} at that link. With neither, `undefined`: the
615
+ * plugin's frozen policy already carries `COHORT_READ_DEADLINE_MS`, which equals the derivation
616
+ * at the default declaration, and returning `undefined` lets the builders return that frozen
617
+ * object by identity, which existing specs pin.
618
+ */
619
+ export function declaredCohortReadDeadlineMs(network?: DeclaredReadDeadline): number | undefined {
620
+ return network?.cohortQueryTimeoutMs
621
+ ?? (network?.linkRoundTripMs === undefined ? undefined : cohortReadDeadlineMs(network.linkRoundTripMs));
622
+ }
623
+
624
+ /** The two deadlines one peer-join catch-up push needs, both derived from the declared link. */
625
+ export interface PeerJoinPushBudget {
626
+ /** Opening the connection to the peer being caught up — a relayed dial, in the worst case. */
627
+ dialTimeoutMs: number;
628
+ /** The push request and its answer over that connection, including the chunk's own bytes. */
629
+ responseTimeoutMs: number;
630
+ }
631
+
632
+ /**
633
+ * The dial and response deadlines a peer-join catch-up push needs at a declared link. One
634
+ * helper rather than two call-site expressions, because the two construction sites
635
+ * (`CadreNode`'s control catch-up and `StrandInstanceManager`'s per-strand one) and
636
+ * `DEFAULT_PEER_JOIN_BACKFILL` must not drift apart.
637
+ */
638
+ export function peerJoinPushBudget(linkRoundTripMs?: number): PeerJoinPushBudget {
639
+ return {
640
+ dialTimeoutMs: relayedDialBudgetMs(linkRoundTripMs),
641
+ responseTimeoutMs: circuitRequestBudgetMs(linkRoundTripMs)
642
+ };
643
+ }
644
+
645
+ /** The connection limits cadre states to Optimystic on every node it builds, beside the declared link. */
646
+ export interface OptimysticDialLimits {
647
+ /** `NodeOptions.connectionManager`: libp2p's limit on one address of a dial, and on a dial that carries no signal. */
648
+ connectionManager: Required<Pick<Libp2pConnectionTimeouts, 'addressDialTimeout' | 'dialTimeout'>>;
649
+ /** `NodeOptions.rpcDeadlines`: the dial deadline of every Optimystic request. */
650
+ rpcDeadlines: Pick<RpcDeadlineDefaults, 'dialTimeoutMs'>;
651
+ }
652
+
653
+ /**
654
+ * The three limits that cover opening a connection, as cadre states them to Optimystic on the
655
+ * control node and every strand node: Optimystic's own derivation from the declared link
656
+ * (`resolveLinkDeadlines`), which is link time only, plus one {@link ADMISSION_DECISION_TIMEOUT_MS}
657
+ * for each of the {@link DIAL_ADMISSION_DECISIONS} gates the open runs through. 39 000 ms per
658
+ * address, 39 000 ms per dial and a 42 500 ms request dial at the default declaration.
659
+ *
660
+ * Built on Optimystic's derivation rather than restating its round-trip counts, so a change to
661
+ * them reaches every node without an edit here. `dialTimeout` stays at least `addressDialTimeout`,
662
+ * as Optimystic's derivation has it, because both take the same addition. The request dial stays
663
+ * one round trip longer than `addressDialTimeout`, also as Optimystic derives them, although
664
+ * Optimystic's `rpcDeadlines` doc asks for it to be no longer: that round trip is the stream
665
+ * negotiation after the connection opens, which the per-address limit does not cover.
666
+ *
667
+ * The listener's `inboundUpgradeTimeout` and the request response deadline are left to Optimystic:
668
+ * the listener's timer runs over its own upgrade and its own decision only (the module doc's
669
+ * "Optimystic's deadlines, from the same declaration"), and a response waits on no gate.
670
+ *
671
+ * The declaration is resolved first, so an invalid one fails with cadre's message. A round trip
672
+ * above Optimystic's `MAX_LINK_ROUND_TRIP_MS` throws from Optimystic, here.
673
+ */
674
+ export function optimysticDialLimits(linkRoundTripMs?: number): OptimysticDialLimits {
675
+ const derived = resolveLinkDeadlines(resolveLinkRoundTripMs(linkRoundTripMs));
676
+ const decisionsMs = DIAL_ADMISSION_DECISIONS * ADMISSION_DECISION_TIMEOUT_MS;
677
+ return {
678
+ connectionManager: {
679
+ addressDialTimeout: derived.addressDialTimeoutMs + decisionsMs,
680
+ dialTimeout: derived.libp2pDialTimeoutMs + decisionsMs
681
+ },
682
+ rpcDeadlines: { dialTimeoutMs: derived.dialTimeoutMs + decisionsMs }
683
+ };
684
+ }