@serfab/cadre-core 1.5.0 → 1.7.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 (68) hide show
  1. package/dist/cadre-node.d.ts +206 -67
  2. package/dist/cadre-node.js +399 -98
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +17 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/joined-strand-store.d.ts +112 -0
  8. package/dist/joined-strand-store.js +263 -0
  9. package/dist/joined-strand-store.js.map +1 -0
  10. package/dist/key-store.d.ts +6 -0
  11. package/dist/key-store.js.map +1 -1
  12. package/dist/link-budget.d.ts +95 -53
  13. package/dist/link-budget.js +97 -53
  14. package/dist/link-budget.js.map +1 -1
  15. package/dist/node-local-snapshot.d.ts +5 -4
  16. package/dist/node-local-snapshot.js +6 -3
  17. package/dist/node-local-snapshot.js.map +1 -1
  18. package/dist/peer-dial.d.ts +3 -3
  19. package/dist/peer-dial.js +3 -3
  20. package/dist/peer-join-backfill.d.ts +35 -16
  21. package/dist/peer-join-backfill.js +117 -57
  22. package/dist/peer-join-backfill.js.map +1 -1
  23. package/dist/relay-reservation.d.ts +2 -2
  24. package/dist/relay-reservation.js +2 -2
  25. package/dist/seed-bootstrap.js +1 -1
  26. package/dist/strand-first-sync-gate.d.ts +88 -43
  27. package/dist/strand-first-sync-gate.js +92 -46
  28. package/dist/strand-first-sync-gate.js.map +1 -1
  29. package/dist/strand-instance-manager.d.ts +39 -0
  30. package/dist/strand-instance-manager.js +80 -3
  31. package/dist/strand-instance-manager.js.map +1 -1
  32. package/dist/strand-peer-book-file.d.ts +12 -0
  33. package/dist/strand-peer-book-file.js +37 -0
  34. package/dist/strand-peer-book-file.js.map +1 -0
  35. package/dist/strand-peer-book-protocol.d.ts +159 -0
  36. package/dist/strand-peer-book-protocol.js +360 -0
  37. package/dist/strand-peer-book-protocol.js.map +1 -0
  38. package/dist/strand-peer-book-swap.d.ts +104 -0
  39. package/dist/strand-peer-book-swap.js +284 -0
  40. package/dist/strand-peer-book-swap.js.map +1 -0
  41. package/dist/strand-peer-book.d.ts +159 -0
  42. package/dist/strand-peer-book.js +354 -0
  43. package/dist/strand-peer-book.js.map +1 -0
  44. package/dist/strand-peer-observer.d.ts +88 -0
  45. package/dist/strand-peer-observer.js +180 -0
  46. package/dist/strand-peer-observer.js.map +1 -0
  47. package/dist/types.d.ts +84 -17
  48. package/dist/types.js +6 -0
  49. package/dist/types.js.map +1 -1
  50. package/package.json +11 -7
  51. package/src/cadre-node.ts +7776 -7445
  52. package/src/index.ts +68 -0
  53. package/src/joined-strand-store.ts +308 -0
  54. package/src/key-store.ts +6 -0
  55. package/src/link-budget.ts +100 -53
  56. package/src/node-local-snapshot.ts +293 -289
  57. package/src/peer-dial.ts +3 -3
  58. package/src/peer-join-backfill.ts +709 -647
  59. package/src/relay-reservation.ts +2 -2
  60. package/src/seed-bootstrap.ts +1 -1
  61. package/src/strand-first-sync-gate.ts +352 -306
  62. package/src/strand-instance-manager.ts +106 -3
  63. package/src/strand-peer-book-file.ts +39 -0
  64. package/src/strand-peer-book-protocol.ts +463 -0
  65. package/src/strand-peer-book-swap.ts +338 -0
  66. package/src/strand-peer-book.ts +468 -0
  67. package/src/strand-peer-observer.ts +235 -0
  68. package/src/types.ts +86 -17
package/src/index.ts CHANGED
@@ -117,6 +117,73 @@ export {
117
117
  type EnrolledMachineStore
118
118
  } from './enrolled-machine-store.js';
119
119
 
120
+ // Node-local record of the strands this node joined from ANOTHER party, re-offered on
121
+ // every start as `strand:discovered`. Cross-platform: its durable form rides the
122
+ // KeyStore seam above (the record carries a closed strand's read secret), so there is
123
+ // no file-backed subpath.
124
+ export {
125
+ MemoryJoinedStrandStore,
126
+ KeyStoreJoinedStrandStore,
127
+ type JoinedStrandStore,
128
+ type JoinedStrandRecord
129
+ } from './joined-strand-store.js';
130
+
131
+ // Node-local strand peer book: per strand, the strand peers this node has met and
132
+ // their last-known addresses, dialed first on every launch so a restarted machine
133
+ // re-finds the other parties. Same cross-platform split as the bootstrap-peer store
134
+ // above — interface + in-memory + slot-backed stores here, Node-only file backend
135
+ // behind the subpath '@serfab/cadre-core/strand-peer-book-file'.
136
+ export {
137
+ MemoryStrandPeerBookStore,
138
+ PersistentStrandPeerBookStore,
139
+ mergeStrandPeerEntry,
140
+ sanitizeStrandPeerEntry,
141
+ strandPeerFreshness,
142
+ MAX_STRAND_PEERS,
143
+ STRAND_PEER_MAX_AGE_MS,
144
+ type StrandPeerBookStore,
145
+ type StrandPeerBookOptions,
146
+ type StrandPeerEntry
147
+ } from './strand-peer-book.js';
148
+ export {
149
+ StrandPeerObserver,
150
+ dialableAddrs,
151
+ connectedIdentifiedPeers,
152
+ STRAND_PEER_OBSERVE_THROTTLE_MS,
153
+ type StrandPeerObservation,
154
+ type StrandPeerObserverDeps,
155
+ type StrandPeerObserverOptions,
156
+ type ConnectedIdentifiedPeer
157
+ } from './strand-peer-observer.js';
158
+ // The signed book SWAP between strand peers (`/sereus/strand-peers/1.0.0`): the wire
159
+ // protocol, signing and verification, and the per-strand driver that arms it.
160
+ export {
161
+ STRAND_PEER_BOOK_PROTOCOL,
162
+ SIGNED_STRAND_PEER_ENTRY_VERSION,
163
+ STRAND_PEER_ISSUED_AT_SKEW_MS,
164
+ MAX_BOOK_FRAME_SIZE,
165
+ StrandPeerBookService,
166
+ exchangeStrandPeerBook,
167
+ signStrandPeerEntry,
168
+ signedStrandPeerEntryPayload,
169
+ verifySignedStrandPeerEntry,
170
+ verifyStrandPeerBookFrame,
171
+ trimBookFrameToFit,
172
+ assertBookFrameFits,
173
+ type SignedStrandPeerEntry,
174
+ type StrandPeerBookFrame,
175
+ type StrandPeerEntryVerdict,
176
+ type StrandPeerBookServiceDeps,
177
+ type ExchangeStrandPeerBookOptions
178
+ } from './strand-peer-book-protocol.js';
179
+ export {
180
+ StrandPeerBookSwap,
181
+ STRAND_PEER_BOOK_SWAP_THROTTLE_MS,
182
+ OWN_ENTRY_RESIGN_DEBOUNCE_MS,
183
+ type StrandPeerBookSwapDeps,
184
+ type StrandPeerBookSwapOptions
185
+ } from './strand-peer-book-swap.js';
186
+
120
187
  // Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
121
188
  // strand's key is its strand id; the control database's key carries the party id,
122
189
  // so two parties on one device never share a control store. Every key stays within
@@ -457,6 +524,7 @@ export {
457
524
  PUSH_TRANSFER_ALLOWANCE_MS,
458
525
  resolveLinkRoundTripMs,
459
526
  relayedDialBudgetMs,
527
+ connectionManagerTimeouts,
460
528
  relayReservationBudgetMs,
461
529
  circuitRequestBudgetMs,
462
530
  peerJoinPushBudget,
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Node-local record of the strands this node joined from ANOTHER party.
3
+ *
4
+ * A party's own strands come back after a restart through its control database: the
5
+ * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
6
+ * config claims is offered as `strand:discovered`. A strand joined through formation with
7
+ * another party (`CadreNode.formStrand`) has no row there — the other party's control
8
+ * database holds it — so nothing re-offered it after a restart, and every embedding app
9
+ * kept its own list of joins, read secret included (gotchoices/sereus#18).
10
+ *
11
+ * `CadreNode` records each such join here, hands the records to its strand watcher beside
12
+ * the control rows ({@link JoinedStrandRows}), and forgets one on `forgetJoinedStrand` or
13
+ * when this party is removed from the strand.
14
+ *
15
+ * Two implementations:
16
+ * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
17
+ * node configured with no `keyStore`.
18
+ * - {@link KeyStoreJoinedStrandStore} — one {@link KeyStore} slot per join. A record holds
19
+ * the closed strand's shared read secret, and the KeyStore is the one seam every
20
+ * platform already routes secrets through (the platform enclave on React Native, the
21
+ * state directory for `FileKeyStore`), so the record goes there rather than into a
22
+ * `DurableSlot` like the bootstrap-peer store.
23
+ *
24
+ * Party-scoped like every other node-local store, because one KeyStore can serve several
25
+ * parties: the React Native app keeps one per device and lets the user switch party. A
26
+ * join made for one party must never be offered to a node started for another.
27
+ *
28
+ * Dependency-free beyond the KeyStore seam, so safe in every entry graph.
29
+ */
30
+ import debug from 'debug';
31
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
32
+ import type { KeyId, KeyStore } from './key-store.js';
33
+ import { assertStrandScopeKey, isValidStrandScopeKey } from './storage-scope.js';
34
+ import type { StrandRow } from './types.js';
35
+
36
+ const log = debug('sereus:cadre:joined-strand-store');
37
+
38
+ /** One strand this node joined from ANOTHER party — nothing in this party's control DB names it. */
39
+ export interface JoinedStrandRecord {
40
+ /** The strand id (a valid scope key, see storage-scope.ts). */
41
+ Id: string;
42
+ Type: 'o' | 'c';
43
+ /** The closed strand's shared read secret the formation delivered; null for an open strand. */
44
+ MemberPrivateKey: string | null;
45
+ /** Wall-clock ms this node first recorded the join. Diagnostics only. */
46
+ joinedAt: number;
47
+ }
48
+
49
+ export interface JoinedStrandStore {
50
+ /** Party this store is scoped to. */
51
+ readonly partyId: string;
52
+
53
+ /** Every remembered join. A call reflects every {@link record}/{@link forget} that resolved before it was made. */
54
+ list(): Promise<JoinedStrandRecord[]>;
55
+
56
+ /** Remember a join, REPLACING any record with the same `Id`. */
57
+ record(record: JoinedStrandRecord): Promise<void>;
58
+
59
+ /** Forget a join. A no-op when there is no record for `strandId`. */
60
+ forget(strandId: string): Promise<void>;
61
+ }
62
+
63
+ /**
64
+ * The {@link StrandRow} a record stands for — the shape `addStrand` and `strand:discovered`
65
+ * carry. `FounderOwnerKey` is null because a joiner never founds; `CadreNode.launchStrand`
66
+ * reads null as "not mine" and joins.
67
+ */
68
+ export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
69
+ return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null };
70
+ }
71
+
72
+ /**
73
+ * Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
74
+ * asked to remember, because a join held only here is not re-offered after a restart —
75
+ * exactly the gotchoices/sereus#18 shape, and it should show in a log rather than pass
76
+ * silently.
77
+ */
78
+ export class MemoryJoinedStrandStore implements JoinedStrandStore {
79
+ private readonly records = new Map<string, JoinedStrandRecord>();
80
+ private warned = false;
81
+
82
+ constructor(readonly partyId: string) {}
83
+
84
+ async list(): Promise<JoinedStrandRecord[]> {
85
+ return [...this.records.values()];
86
+ }
87
+
88
+ async record(record: JoinedStrandRecord): Promise<void> {
89
+ this.warnOnce();
90
+ this.records.set(record.Id, { ...record });
91
+ }
92
+
93
+ async forget(strandId: string): Promise<void> {
94
+ this.records.delete(strandId);
95
+ }
96
+
97
+ private warnOnce(): void {
98
+ if (this.warned) {
99
+ return;
100
+ }
101
+ this.warned = true;
102
+ console.warn(
103
+ 'CadreNode has no keyStore and no joinedStrands.store, so strands joined from another party ' +
104
+ 'are remembered in memory only and will NOT be re-offered after a restart. Configure a keyStore, ' +
105
+ 'or inject joinedStrands: { store: new KeyStoreJoinedStrandStore(<a durable KeyStore>, partyId) }.'
106
+ );
107
+ }
108
+ }
109
+
110
+ /** Slot id prefix for every joined-strand record; the party segment and the strand id follow. */
111
+ const SLOT_PREFIX = 'cadre/joined-strand/';
112
+
113
+ /**
114
+ * Durable store over a {@link KeyStore}: one slot per join, id
115
+ * `cadre/joined-strand/<base64url party id>/<strand id>`, holding the record's UTF-8 JSON.
116
+ * The party segment is base64url for the reason `controlStorageScope` gives: a party id is
117
+ * arbitrary text, and base64url never contains the `/` that ends the segment.
118
+ *
119
+ * Loads every slot once, at the first {@link list}, then answers from memory and writes
120
+ * through, so the strand watcher's five-second poll does not read the enclave on every
121
+ * pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
122
+ * is rethrown rather than read as "no joins" and retried at the next call, exactly as
123
+ * `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
124
+ * record for its own id is dropped with a log and its siblings kept: refusing to start
125
+ * repairs nothing, and the other joins are still good.
126
+ */
127
+ export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
128
+ private readonly slotPrefix: string;
129
+ /** The loaded records; null until a load succeeds. */
130
+ private records: Map<string, JoinedStrandRecord> | null = null;
131
+ /** Runs every KeyStore access in call order, so a load never interleaves with a write. */
132
+ private queue: Promise<unknown> = Promise.resolve();
133
+
134
+ constructor(private readonly keyStore: KeyStore, readonly partyId: string) {
135
+ this.slotPrefix = SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url') + '/';
136
+ }
137
+
138
+ list(): Promise<JoinedStrandRecord[]> {
139
+ return this.inOrder(async () => [...(await this.loaded()).values()]);
140
+ }
141
+
142
+ /** Throws `InvalidStrandIdError` for an id that could never load back, before anything is written. */
143
+ record(record: JoinedStrandRecord): Promise<void> {
144
+ assertStrandScopeKey(record.Id);
145
+ const stored: JoinedStrandRecord = { ...record };
146
+ return this.inOrder(async () => {
147
+ await this.keyStore.set(this.slotId(record.Id), uint8ArrayFromString(JSON.stringify(stored), 'utf8'));
148
+ this.records?.set(record.Id, stored);
149
+ log('joined strand remembered (party=%s, strand=%s, type=%s)', this.partyId, record.Id, record.Type);
150
+ });
151
+ }
152
+
153
+ forget(strandId: string): Promise<void> {
154
+ return this.inOrder(async () => {
155
+ await this.keyStore.delete(this.slotId(strandId));
156
+ this.records?.delete(strandId);
157
+ log('joined strand forgotten (party=%s, strand=%s)', this.partyId, strandId);
158
+ });
159
+ }
160
+
161
+ private slotId(strandId: string): KeyId {
162
+ return this.slotPrefix + strandId;
163
+ }
164
+
165
+ private inOrder<T>(op: () => Promise<T>): Promise<T> {
166
+ const result = this.queue.then(op);
167
+ // The caller receives `result`, rejection included; the queue only needs to know it settled.
168
+ this.queue = result.catch(() => undefined);
169
+ return result;
170
+ }
171
+
172
+ // NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
173
+ // over a backend that gates reads behind an unlock prompt the user keeps declining, that
174
+ // is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
175
+ // (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
176
+ // off the retry here.
177
+ private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
178
+ if (this.records) {
179
+ return this.records;
180
+ }
181
+ const records = new Map<string, JoinedStrandRecord>();
182
+ for (const slot of await this.keyStore.list()) {
183
+ if (!slot.startsWith(this.slotPrefix)) {
184
+ continue;
185
+ }
186
+ const strandId = slot.slice(this.slotPrefix.length);
187
+ const bytes = await this.keyStore.get(slot);
188
+ if (!bytes) {
189
+ continue;
190
+ }
191
+ const record = parseRecord(strandId, bytes);
192
+ if (record) {
193
+ records.set(strandId, record);
194
+ }
195
+ }
196
+ this.records = records;
197
+ log('joined strands loaded (party=%s, count=%d)', this.partyId, records.size);
198
+ return records;
199
+ }
200
+ }
201
+
202
+ /** The record a slot holds, or undefined (logged) when it is not one for `strandId`. */
203
+ function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord | undefined {
204
+ let parsed: unknown;
205
+ try {
206
+ parsed = JSON.parse(uint8ArrayToString(bytes, 'utf8'));
207
+ } catch (error) {
208
+ log('dropping joined-strand slot for %s: not JSON (%o)', strandId, error);
209
+ return undefined;
210
+ }
211
+ const { Id, Type, MemberPrivateKey, joinedAt } = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
212
+ const valid = Id === strandId
213
+ && isValidStrandScopeKey(strandId)
214
+ && (Type === 'o' || Type === 'c')
215
+ && (MemberPrivateKey === null || typeof MemberPrivateKey === 'string')
216
+ && typeof joinedAt === 'number';
217
+ if (!valid) {
218
+ log('dropping joined-strand slot for %s: not a joined-strand record for that id', strandId);
219
+ return undefined;
220
+ }
221
+ return { Id: strandId, Type: Type as 'o' | 'c', MemberPrivateKey: MemberPrivateKey as string | null, joinedAt: joinedAt as number };
222
+ }
223
+
224
+ /**
225
+ * What the strand watcher sees of the joined-strand store: the control rows plus one row per
226
+ * remembered join, which is how a join comes back as `strand:discovered` (or auto-launches)
227
+ * after a restart with no second offer path. One instance per node session.
228
+ *
229
+ * Holds the two pieces of state a plain union would get wrong, both of which would otherwise
230
+ * make the watcher detach running strands, since a joined row missing from a poll reads to it
231
+ * as a removal:
232
+ * - the last list the store answered, reused when a later list fails, so a read failure is
233
+ * not "every join was forgotten";
234
+ * - joins forgotten because this party was removed from the strand
235
+ * ({@link forgetAfterThisSession}): gone from the store, so the next start does not
236
+ * re-attach them, but still offered until this session ends, because `strand:revoked`
237
+ * promises that nothing is torn down for the app.
238
+ */
239
+ export class JoinedStrandRows {
240
+ private lastListed: JoinedStrandRecord[] = [];
241
+ private readonly keptForSession = new Map<string, JoinedStrandRecord>();
242
+
243
+ constructor(private readonly store: JoinedStrandStore) {}
244
+
245
+ /**
246
+ * `control` plus a row for every remembered join. A control row wins an id collision: the
247
+ * strand is this party's own now (its founding party enrolled this machine, say), the
248
+ * control database remembers it, and the record is stale — so it is forgotten here.
249
+ */
250
+ async withControlRows(control: readonly StrandRow[]): Promise<StrandRow[]> {
251
+ const controlIds = new Set(control.map((row) => row.Id));
252
+ const joined = new Map<string, JoinedStrandRecord>(this.keptForSession);
253
+ for (const record of await this.listed()) {
254
+ joined.set(record.Id, record);
255
+ }
256
+ const rows = [...control];
257
+ for (const record of joined.values()) {
258
+ if (controlIds.has(record.Id)) {
259
+ await this.forgetSuperseded(record.Id);
260
+ } else {
261
+ rows.push(joinedStrandRow(record));
262
+ }
263
+ }
264
+ return rows;
265
+ }
266
+
267
+ /** Forget a join for good: the app is leaving the strand. */
268
+ async forget(strandId: string): Promise<void> {
269
+ this.keptForSession.delete(strandId);
270
+ await this.store.forget(strandId);
271
+ }
272
+
273
+ /**
274
+ * Forget a join durably, but keep offering it until this session ends. For a strand this
275
+ * party was removed from: it must not re-attach on every launch, and it must not be torn
276
+ * down underneath the app either. A no-op for a strand with no record.
277
+ */
278
+ async forgetAfterThisSession(strandId: string): Promise<void> {
279
+ const record = (await this.listed()).find((listed) => listed.Id === strandId);
280
+ if (!record) {
281
+ return;
282
+ }
283
+ // Kept BEFORE the store forgets, so no poll can list the store without it and union
284
+ // without the kept copy.
285
+ this.keptForSession.set(strandId, record);
286
+ await this.store.forget(strandId);
287
+ }
288
+
289
+ private async listed(): Promise<JoinedStrandRecord[]> {
290
+ try {
291
+ this.lastListed = await this.store.list();
292
+ } catch (error) {
293
+ log('joined-strand store list failed; offering the last list it answered (%d join(s)): %o',
294
+ this.lastListed.length, error);
295
+ }
296
+ return this.lastListed;
297
+ }
298
+
299
+ private async forgetSuperseded(strandId: string): Promise<void> {
300
+ this.keptForSession.delete(strandId);
301
+ try {
302
+ await this.store.forget(strandId);
303
+ log('joined strand %s now has a control row — record forgotten', strandId);
304
+ } catch (error) {
305
+ log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
306
+ }
307
+ }
308
+ }
package/src/key-store.ts CHANGED
@@ -32,6 +32,12 @@ export const DEFAULT_IDENTITY_KEY_ID: KeyId = 'cadre/identity';
32
32
  * Key material is raw bytes (e.g. libp2p protobuf-serialized private keys).
33
33
  * Text-only backends encode/decode (base64) internally; byte-native backends
34
34
  * (LevelDB) store as-is.
35
+ *
36
+ * Not only keys: `KeyStoreJoinedStrandStore` keeps one small UTF-8 JSON record
37
+ * (about 200 bytes, a closed strand's read secret among its fields) per strand
38
+ * this node joined from another party, under ids beginning
39
+ * `cadre/joined-strand/`. {@link list} therefore returns those ids too, and a
40
+ * backend must accept `/` in an id and values of that size.
35
41
  */
36
42
  export interface KeyStore {
37
43
  /** Resolves to the stored material, or undefined if the slot is empty. */
@@ -35,63 +35,80 @@
35
35
  *
36
36
  * | operation | link round trips | at the declared default |
37
37
  * | ---------------------------------------------------- | ---------------- | ----------------------- |
38
- * | open a relayed connection to another machine | 4 | 8 000 ms |
39
- * | dial the relay itself | 1 | 2 000 ms |
40
- * | request a reservation on an open relay connection | 1 | 2 000 ms |
41
- * | negotiate a protocol over an established circuit | 1 | 2 000 ms |
42
- *
43
- * **Measured** 2026-09-26, one Windows machine, loopback dedicated relay, re-run for this
44
- * change: a relayed dial took 20-25 ms at no delay, **7 255-7 279 ms at 900 ms one-way**, and
45
- * **12 066-12 094 ms at 1 500 ms one-way**. Four link round trips of pure delay would be 7 200
46
- * and 12 000, so the real cost sits about 60-95 ms ABOVE the arithmetic — the handshakes and a
47
- * phone's pure-JS crypto, which no delay figure contains. That residue is small but it is why
48
- * {@link DECLARED_LINK_ROUND_TRIP_MS} carries headroom over the band sereus supports rather
49
- * than matching it exactly: a declaration equal to the measured round trip would derive a
50
- * budget marginally BELOW the dial it has to contain.
51
- *
52
- * ── The ceiling this does NOT lift ──
53
- *
54
- * Above roughly 1 250 ms one-way — a 2.5-second link round trip — no relayed connection can be
55
- * established by this stack whatever cadre declares here. Two budgets of 10 000 ms each inside
56
- * libp2p bound the same dial and cannot be reached from sereus at all:
57
- * `connectionManager.dialTimeout` (libp2p's own default, which `@optimystic/db-p2p`'s
58
- * `libp2p-node-base.ts` neither sets nor exposes) and `connectionManager.inboundUpgradeTimeout`
59
- * (which it sets to 10 000). Both appear in the same re-run: at 1 500 ms one-way the dial under
60
- * libp2p's default failed with `The operation was aborted due to timeout`, and with the shipped
61
- * inbound-upgrade budget the DIALER's own 12 094 ms dial resolved while the LISTENER had
62
- * already thrown the half-built connection away — so the first stream over it died with
38
+ * | open a relayed connection to another machine | 4 | 14 000 ms |
39
+ * | dial the relay itself | 1 | 3 500 ms |
40
+ * | request a reservation on an open relay connection | 1 | 3 500 ms |
41
+ * | negotiate a protocol over an established circuit | 1 | 3 500 ms |
42
+ *
43
+ * **Measured** 2026-09-26, one Windows machine, loopback dedicated relay: a relayed dial took
44
+ * 20-25 ms at no delay, **7 255-7 279 ms at 900 ms one-way**, and **12 066-12 094 ms at 1 500 ms
45
+ * one-way**. Four link round trips of pure delay would be 7 200 and 12 000, so the real cost
46
+ * sits about 60-95 ms ABOVE the arithmetic — the handshakes, which no delay figure contains.
47
+ * That residue is why {@link DECLARED_LINK_ROUND_TRIP_MS} carries headroom over the link sereus
48
+ * supports rather than matching it exactly: a declaration equal to the supported round trip
49
+ * would derive a budget marginally BELOW the dial it has to contain.
50
+ *
51
+ * ── libp2p's own two limits ──
52
+ *
53
+ * Two limits inside libp2p bound the same relayed dial as cadre's budgets do, and both were
54
+ * 10 000 ms until `@optimystic/db-p2p` 1.7.0 let an embedder set them: the DIALER's
55
+ * `connectionManager.dialTimeout`, which bounds every dial that carries no abort signal of its
56
+ * own, and the LISTENER's `connectionManager.inboundUpgradeTimeout`, which is how long the
57
+ * machine being called lets a half-built connection finish its handshakes. At 10 000 ms each,
58
+ * no relayed connection could be opened above about 1 250 ms one-way (a 2.5-second link round
59
+ * trip) whatever cadre declared. The listener's limit is the one that makes that failure
60
+ * silent: at 1 500 ms one-way the dialer's own 12 094 ms dial RESOLVED while the listener had
61
+ * already thrown the connection away at 10 s, so the first stream over it died with
63
62
  * `Unexpected EOF - stream closed while reading 0/1 bytes` and the listener reported no peer at
64
- * all. That is why a too-slow link produces silence rather than an error.
65
- *
66
- * Two consequences for a reader choosing a declaration. Below that ceiling, the DEFAULT
67
- * declaration of 2 000 ms does not reach it: four round trips of 2 000 ms is 8 000 ms, which
68
- * covers a one-way delay up to 1 000 ms, so a deployment somewhere between 1 000 and 1 250 ms
69
- * one-way has to declare its own (about 2 200-2 500) to use the band libp2p still allows. And
70
- * declaring much ABOVE 2 500 buys nothing: the budgets here grow, and
71
- * the connection still fails inside libp2p. Lifting that needs an upstream change and a
72
- * decision about how slow a link sereus intends to carry —
73
- * `tickets/blocked/how-slow-a-relayed-link-does-sereus-carry`. A reader who raised these
74
- * numbers and still cannot connect at 1 500 ms one-way has met that ceiling, not this module.
63
+ * all. cadre now declares both from this module ({@link connectionManagerTimeouts}) on the
64
+ * control node and every strand node.
65
+ *
66
+ * ── What still fails at the supported link ──
67
+ *
68
+ * - **Optimystic's own request dials.** `@optimystic/db-p2p`'s RPC clients dial with fixed
69
+ * 3 000 ms deadlines of their own (`DEFAULT_DIAL_TIMEOUT_MS`, the `pushDialTimeoutMs`
70
+ * defaults), which neither limit above reaches because a caller's signal replaces
71
+ * `dialTimeout`. So an Optimystic request that has to OPEN a relayed connection fails above
72
+ * 375 ms one-way. A request over a connection that is already open does not dial, and the
73
+ * connections cadre opens itself are budgeted here. Upstream:
74
+ * `debt-rpc-dial-deadlines-cannot-open-a-slow-relayed-connection` in optimystic.
75
+ * - **Cadre deadlines still typed as milliseconds.** The strand wake, strand-address and seed
76
+ * delivery deadlines, and the cohort read deadline, bound exchanges over the same link but do
77
+ * not derive from it: `tickets/backlog/debt-three-more-dial-deadlines-ignore-the-declared-link`.
78
+ * - **A machine that declares a faster link than its peers.** Every machine is the listener
79
+ * for the others, so its `inboundUpgradeTimeout` — derived from ITS declaration — bounds
80
+ * connections other machines open to it. A peer declaring 3 500 ms dialing a machine that
81
+ * declared 500 ms gets exactly the silent failure above. Declare the same link on every
82
+ * machine of a party.
75
83
  */
76
84
 
85
+ import type { Libp2pConnectionTimeouts } from '@optimystic/db-p2p';
86
+
77
87
  /**
78
88
  * The link round trip cadre assumes when a host declares none, in milliseconds. A deployment
79
89
  * that knows its own link states it with `NetworkConfig.linkRoundTripMs` instead.
80
90
  *
81
- * **Why 2 000.** It states the slowest link sereus already claims to support, and two other
82
- * budgets fix that claim independently: `COHORT_READ_DEADLINE_MS`
83
- * (`quereus-plugin-sereus/src/cluster-size.ts`, 5 000 ms) and
84
- * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` (`strand-first-sync-gate.ts`, 120 000 ms) were both
85
- * sized against two parties reaching each other only through a public relay at a round trip
86
- * near 1.8 s. Declaring 2 000 rounds that band up and leaves about 720 ms over the worst
87
- * relayed dial measured at it (7 279 ms against a derived 8 000 ms).
88
- *
89
- * Raising it is how a deployment that KNOWS its link is slower moves every derived budget at
90
- * once. The cost is the ordinary cost of a longer deadline: a peer that is genuinely gone holds
91
- * the operation for that much longer before it is abandoned and retried. Above about 2 500 it
92
- * buys nothing — see the module doc's ceiling paragraph.
91
+ * **Why 3 500.** Sereus supports two machines that reach each other only through a relay up to
92
+ * a **3-second link round trip** (1 500 ms each way — a congested mobile or satellite link;
93
+ * `docs/architecture.md` → "Relay Integration"). The worst relayed dial measured at that link
94
+ * was 12 094 ms, above the 12 000 ms a declaration of exactly 3 000 would derive, so the
95
+ * declaration rounds the supported link up to the next half second. 3 500 derives 14 000 ms
96
+ * and leaves about 1.9 s over that dial: room for a phone's Noise handshake, which the
97
+ * loopback measurement on a desktop does not contain. (The previous 2 000 followed the same
98
+ * convention for the 1.8 s band sereus supported before, with about 720 ms to spare.)
99
+ *
100
+ * Two budgets outside this module were sized against the 1.8 s band and do NOT move with it:
101
+ * `COHORT_READ_DEADLINE_MS` (`quereus-plugin-sereus/src/cluster-size.ts`, 5 000 ms) and
102
+ * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` (`strand-first-sync-gate.ts`). The module doc's
103
+ * "What still fails" list carries the first.
104
+ *
105
+ * What 3 500 costs, against 2 000: a peer that is genuinely gone holds each operation longer
106
+ * before it is abandoned and retried — a relayed dial or a reservation drive 14 s instead of
107
+ * 8 s, and a control-cohort dial of one peer across all its addresses 56 s instead of 32 s.
108
+ * Raise it further for a link slower still; lower it only on a deployment where EVERY machine
109
+ * of the party is that close, for the reason the "What still fails" list gives.
93
110
  */
94
- export const DECLARED_LINK_ROUND_TRIP_MS = 2000;
111
+ export const DECLARED_LINK_ROUND_TRIP_MS = 3500;
95
112
 
96
113
  /**
97
114
  * Link round trips one relayed connection setup costs: transport, encryption and multiplexer
@@ -132,10 +149,10 @@ export const CIRCUIT_REQUEST_ROUND_TRIPS = 2;
132
149
  *
133
150
  * NOTE: 6 000 ms is not a measurement of any throughput — it is the residue of the 10 000 ms
134
151
  * this deadline shipped as before it was split into a latency part and a transfer part, chosen
135
- * so that at the default declaration the derived value is the same 10 000 and no fast link got
136
- * slower. Nobody has measured how long 1 MiB takes to cross a relayed mobile link. If pushes
137
- * ever time out with the transfer only part done, measure that and raise THIS, not the declared
138
- * round trip.
152
+ * so that at the declaration then in force (2 000 ms) the derived value stayed 10 000 and no
153
+ * fast link got slower. Nobody has measured how long 1 MiB takes to cross a relayed mobile
154
+ * link. If pushes ever time out with the transfer only part done, measure that and raise THIS,
155
+ * not the declared round trip.
139
156
  */
140
157
  export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
141
158
 
@@ -175,6 +192,36 @@ export function relayedDialBudgetMs(linkRoundTripMs?: number): number {
175
192
  return RELAYED_DIAL_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
176
193
  }
177
194
 
195
+ /**
196
+ * libp2p's two connection-manager limits for a node at the declared link, handed to
197
+ * `@optimystic/db-p2p`'s `NodeOptions.connectionManager` on the control node and every strand
198
+ * node.
199
+ *
200
+ * Both bound the same thing — opening a relayed connection, measured at 8 one-way link delays,
201
+ * which is {@link RELAYED_DIAL_ROUND_TRIPS} (4) link round trips — so both are
202
+ * {@link relayedDialBudgetMs}: 4 x 3 500 = 14 000 ms at the default declaration, covering the
203
+ * 12 094 ms measured at the supported 3-second link.
204
+ *
205
+ * The listener's limit is deliberately NOT smaller than the dialer's. The listener's clock
206
+ * starts only when the relay hands it the circuit, a few one-way delays after the dialer's
207
+ * started, and both sides finish the handshakes at about the same moment; so with equal limits
208
+ * the listener never discards a connection that the dialer's own dial would still accept. A
209
+ * listener limit below the dialer's is what produced the silent failure in the module doc.
210
+ *
211
+ * The cost of a longer `inboundUpgradeTimeout` is that a peer which opens a connection and then
212
+ * stalls its handshake holds that half-built connection 14 s instead of 10 s.
213
+ *
214
+ * NOTE: derived from the declaration alone, so a per-field override that raises a cadre dial
215
+ * budget above it (`controlCohort.perAddressDialTimeoutMs`, `strandBackfill.dialTimeoutMs`)
216
+ * does not raise these; a dial that outlasts the listener's limit gets the silent failure
217
+ * again. If such overrides start being used for slow links, raise the declaration instead or
218
+ * take the largest configured dial budget here.
219
+ */
220
+ export function connectionManagerTimeouts(linkRoundTripMs?: number): Libp2pConnectionTimeouts {
221
+ const budgetMs = relayedDialBudgetMs(linkRoundTripMs);
222
+ return { dialTimeout: budgetMs, inboundUpgradeTimeout: budgetMs };
223
+ }
224
+
178
225
  /**
179
226
  * Deadline for one whole relay reservation drive: {@link RELAY_RESERVATION_ROUND_TRIPS} at the
180
227
  * declared link round trip.