@serfab/cadre-core 1.7.0 → 1.8.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 (162) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +386 -114
  3. package/dist/cadre-node.js +887 -359
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +8 -6
  45. package/dist/index.js +15 -9
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/peer-dial.d.ts +24 -4
  59. package/dist/peer-dial.js +74 -5
  60. package/dist/peer-dial.js.map +1 -1
  61. package/dist/peer-join-backfill.d.ts +2 -1
  62. package/dist/peer-join-backfill.js +6 -5
  63. package/dist/peer-join-backfill.js.map +1 -1
  64. package/dist/push-fanout.d.ts +6 -0
  65. package/dist/push-fanout.js +6 -0
  66. package/dist/push-fanout.js.map +1 -1
  67. package/dist/relay-reservation.d.ts +3 -1
  68. package/dist/relay-reservation.js +3 -1
  69. package/dist/relay-reservation.js.map +1 -1
  70. package/dist/relay-server.d.ts +79 -0
  71. package/dist/relay-server.js +74 -0
  72. package/dist/relay-server.js.map +1 -0
  73. package/dist/seed-bootstrap.d.ts +57 -4
  74. package/dist/seed-bootstrap.js +107 -83
  75. package/dist/seed-bootstrap.js.map +1 -1
  76. package/dist/storage-scope.d.ts +33 -17
  77. package/dist/storage-scope.js +44 -23
  78. package/dist/storage-scope.js.map +1 -1
  79. package/dist/strand-addr-protocol.d.ts +58 -21
  80. package/dist/strand-addr-protocol.js +133 -85
  81. package/dist/strand-addr-protocol.js.map +1 -1
  82. package/dist/strand-database.d.ts +20 -2
  83. package/dist/strand-database.js +42 -5
  84. package/dist/strand-database.js.map +1 -1
  85. package/dist/strand-first-sync-gate.d.ts +59 -21
  86. package/dist/strand-first-sync-gate.js +58 -19
  87. package/dist/strand-first-sync-gate.js.map +1 -1
  88. package/dist/strand-formation-deadlines.d.ts +151 -0
  89. package/dist/strand-formation-deadlines.js +171 -0
  90. package/dist/strand-formation-deadlines.js.map +1 -0
  91. package/dist/strand-formation-manager.d.ts +63 -30
  92. package/dist/strand-formation-manager.js +67 -41
  93. package/dist/strand-formation-manager.js.map +1 -1
  94. package/dist/strand-formation-protocol.d.ts +45 -35
  95. package/dist/strand-formation-protocol.js +59 -122
  96. package/dist/strand-formation-protocol.js.map +1 -1
  97. package/dist/strand-instance-manager.d.ts +113 -6
  98. package/dist/strand-instance-manager.js +257 -41
  99. package/dist/strand-instance-manager.js.map +1 -1
  100. package/dist/strand-membership-writer.d.ts +6 -0
  101. package/dist/strand-membership-writer.js +11 -0
  102. package/dist/strand-membership-writer.js.map +1 -1
  103. package/dist/strand-peer-book-protocol.d.ts +13 -4
  104. package/dist/strand-peer-book-protocol.js +3 -7
  105. package/dist/strand-peer-book-protocol.js.map +1 -1
  106. package/dist/strand-peer-book.d.ts +1 -1
  107. package/dist/strand-peer-book.js +8 -1
  108. package/dist/strand-peer-book.js.map +1 -1
  109. package/dist/strand-peer-observer.js +1 -1
  110. package/dist/strand-revocation-enforcer.js +2 -1
  111. package/dist/strand-revocation-enforcer.js.map +1 -1
  112. package/dist/strand-solicitation.d.ts +36 -20
  113. package/dist/strand-solicitation.js +4 -4
  114. package/dist/strand-solicitation.js.map +1 -1
  115. package/dist/strand-wake-protocol.d.ts +80 -23
  116. package/dist/strand-wake-protocol.js +89 -64
  117. package/dist/strand-wake-protocol.js.map +1 -1
  118. package/dist/types.d.ts +128 -38
  119. package/dist/types.js +4 -3
  120. package/dist/types.js.map +1 -1
  121. package/package.json +7 -7
  122. package/src/cadre-node.ts +8367 -7776
  123. package/src/control-authorization.ts +2 -1
  124. package/src/control-cohort.ts +6 -0
  125. package/src/control-database.ts +122 -18
  126. package/src/control-formation-recorder.ts +354 -305
  127. package/src/control-read-retry.ts +14 -12
  128. package/src/control-schema.ts +67 -5
  129. package/src/control-stream.ts +25 -1
  130. package/src/control-write-retry.ts +13 -2
  131. package/src/delegate-admission.ts +23 -13
  132. package/src/diagnostics/connection-path.ts +0 -7
  133. package/src/formation-approval.ts +610 -604
  134. package/src/fs-atomic.ts +14 -4
  135. package/src/hibernation-manager.ts +638 -437
  136. package/src/index.ts +28 -10
  137. package/src/joined-strand-store.ts +247 -57
  138. package/src/key-store-file.ts +14 -5
  139. package/src/link-budget.ts +393 -74
  140. package/src/membership-connection-gater.ts +31 -45
  141. package/src/peer-dial.ts +81 -7
  142. package/src/peer-join-backfill.ts +8 -6
  143. package/src/push-fanout.ts +6 -0
  144. package/src/relay-reservation.ts +3 -1
  145. package/src/relay-server.ts +98 -0
  146. package/src/seed-bootstrap.ts +133 -91
  147. package/src/storage-scope.ts +44 -23
  148. package/src/strand-addr-protocol.ts +547 -458
  149. package/src/strand-database.ts +287 -242
  150. package/src/strand-first-sync-gate.ts +58 -19
  151. package/src/strand-formation-deadlines.ts +227 -0
  152. package/src/strand-formation-manager.ts +700 -647
  153. package/src/strand-formation-protocol.ts +103 -150
  154. package/src/strand-instance-manager.ts +287 -44
  155. package/src/strand-membership-writer.ts +12 -0
  156. package/src/strand-peer-book-protocol.ts +16 -13
  157. package/src/strand-peer-book.ts +8 -1
  158. package/src/strand-peer-observer.ts +1 -1
  159. package/src/strand-revocation-enforcer.ts +2 -1
  160. package/src/strand-solicitation.ts +519 -500
  161. package/src/strand-wake-protocol.ts +401 -355
  162. package/src/types.ts +130 -38
@@ -84,24 +84,57 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
84
84
  *
85
85
  * Every harness figure below was taken on one shape: one Windows developer machine, two
86
86
  * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
87
- * 900 ms one-way per-frame outbound delay applied to every websocket by
87
+ * one-way per-frame outbound delay applied to every websocket by
88
88
  * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
89
89
  * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
90
90
  * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
91
- * (`docs/testing.md` → "Link latency"). A round trip therefore costs about 1.8 s. Times run
92
- * from the `addStrand` call to writable.
91
+ * (`docs/testing.md` → "Link latency"). Two delays were used: 900 ms one-way, a round trip
92
+ * near 1.8 s, the band sereus supported first; and 1 500 ms one-way, the supported 3-second
93
+ * round trip. Times run from the `addStrand` call to writable.
93
94
  *
94
- * FRESH JOIN (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31 and 41 s
95
- * over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s over three
96
- * runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and 56.9 s for
97
- * the same fresh join. **The 5000 ms band, 35-57 s, is the one in force**: sereus declares
98
- * 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`), because
99
- * 1000 ms is shorter than one round trip on that link and leaves every cohort read with no
100
- * answer to corroborate against. Widening that deadline makes a consult against a peer that
101
- * cannot answer cost longer, and this phase runs several of those — which is why both bands
102
- * are recorded here, and why a change to either number has to be weighed against the other.
95
+ * FRESH JOIN at 900 ms (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31
96
+ * and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s
97
+ * over three runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and
98
+ * 56.9 s for the same fresh join. At that delay the 5000 ms band, 35-57 s, was the one in
99
+ * force: sereus then declared 5000 ms, because 1000 ms is shorter than one round trip on
100
+ * that link and leaves every cohort read with no answer to corroborate against. Widening that
101
+ * deadline makes a consult against a peer that cannot answer cost longer, and this phase runs
102
+ * several of those — which is why both bands are recorded here, and why a change to either
103
+ * number has to be weighed against the other.
103
104
  *
104
- * RE-ATTACH (2026-09-26, the opt-in
105
+ * AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
106
+ * deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
107
+ * `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
108
+ * stating its declared link to Optimystic, the same scenario, all under the coordinator debug
109
+ * channel:
110
+ * - Fresh join: writable at 63.9 and 63.8 s over two runs, the row A wrote before the join
111
+ * readable at 88.0 s in both. B's strand node connected to A's at 18.3-18.4 s. No read was
112
+ * declined: every consult on B completed, 6.03-6.05 s after the one before it (two link
113
+ * round trips each). The time did not fall, because B's consults ran one after another and
114
+ * the gate opened only after the seventh.
115
+ * - Re-attach over the store B kept: no longer bimodal. All 3 runs held everything and came
116
+ * up writable at launch (9.2 s), and the row A wrote while B was away was readable at
117
+ * 35.6 s in each, read with one consult for the collection and its data block together. No
118
+ * read was declined during the attach. The empty-store arm was not re-run.
119
+ *
120
+ * AT THE SUPPORTED LINK ON `@optimystic/*` 1.7.0 (2026-09-29), same delay, deadline and
121
+ * scenario, before cadre stated its link to Optimystic:
122
+ * - Fresh join: writable at 52.1 and 82.1 s over two runs, and 63.6 s in a third run under
123
+ * the coordinator debug channel; a fourth run with the deadline set back to 5 000 ms took
124
+ * 70.2 s. B's strand node connected to A's at 18.2-18.3 s in every run at this delay. The
125
+ * row A wrote before the join was readable at 67-124 s.
126
+ * - Re-attach over the store B kept: bimodal again. 1 of 3 runs held everything and came up
127
+ * writable at launch (9.1 s; the row at 33.4 s). The other 2 were gated and writable at
128
+ * 75.7 and 78.7 s (the row at 120.8 and 175.0 s). The empty-store arm was not re-run; at
129
+ * 900 ms it matched a fresh join.
130
+ * - What that band was NOT: a measure of the 7 000 ms deadline. Every cohort consult in those
131
+ * runs ended 3.00-3.02 s after the one before it, at 5 000 and 7 000 alike, because
132
+ * Optimystic's then-fixed 3 000 ms request dial deadline also bounded the protocol
133
+ * negotiation on an already-open connection, and one negotiation is one link round trip —
134
+ * 3 s here. So it was the band of a join whose every consult failed and whose blocks
135
+ * arrived by peer-join backfill.
136
+ *
137
+ * RE-ATTACH at 900 ms (2026-09-26, the opt-in
105
138
  * `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
106
139
  * it before changing this number): B attached once on an undelayed link, left with
107
140
  * `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
@@ -118,18 +151,23 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
118
151
  * connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
119
152
  * writes fell inside the run-to-run spread of those with 1.
120
153
  * This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
121
- * while B was away became readable only at 140-162 s.
154
+ * while B was away became readable only at 140-162 s (121-175 s at the supported link).
122
155
  *
123
156
  * Two samples from outside this harness, neither reproduced here and neither discarded: a
124
157
  * reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
125
158
  * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
126
159
  * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
127
160
  *
128
- * 300 s clears the worst harness sample (79 s) by about 3.8x, the reporter's re-attach by 2x
129
- * and the device join by about 1.7x. 240 s was rejected: it clears the device join by only
130
- * 1.35x, and that sample was not a re-attach. History: the original 30 s sat INSIDE the 1000 ms
131
- * fresh band and refused about half of those joins while their sync was progressing normally;
132
- * 120 s was sized from fresh joins only and refused the reporter's re-attach.
161
+ * 300 s clears the worst harness sample (82 s, a fresh join at the supported link on 1.7.0; the
162
+ * worst on 1.8.0 is 64 s) by about 3.6x, the reporter's re-attach by 2x and the device join by
163
+ * about 1.7x. Kept at 300 s on 2026-09-29, and again after the 1.8.0 re-measure the same day,
164
+ * by the rule that re-measure applied and the next one should: keep it while the
165
+ * worst harness sample is at most 100 s, which holds a margin of at least 3x; otherwise raise
166
+ * it to 3x the worst sample, rounded up to the next whole minute. 240 s was rejected earlier:
167
+ * it clears the device join by only 1.35x, and that sample was not a re-attach. History: the
168
+ * original 30 s sat INSIDE the 1000 ms fresh band and refused about half of those joins while
169
+ * their sync was progressing normally; 120 s was sized from fresh joins only and refused the
170
+ * reporter's re-attach.
133
171
  *
134
172
  * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
135
173
  * before it is told "not yet", so a strand none of whose members is reachable at all takes five
@@ -141,6 +179,7 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
141
179
  * "no peer at all" could then be reported at once and this budget would only ever be spent on a
142
180
  * sync that is actually in progress — worth more the longer this budget gets.
143
181
  */
182
+ // eslint-disable-next-line no-restricted-syntax -- measured, not derived: the bands above, from integration-tests' strand-reattach-first-sync-measure.integration.ts; a whole first sync has no round-trip count
144
183
  export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
145
184
 
146
185
  /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Strand-formation deadlines, derived from the declared link round trip.
3
+ *
4
+ * The wire protocol lives in `strand-formation-protocol.ts`; this module is only the
5
+ * arithmetic it runs on: the ladder of deadlines one declaration yields
6
+ * ({@link formationDeadlines}) and the clamping of a caller's configured provisioning budget
7
+ * under its session ({@link resolveProvisionTimeoutMs}, {@link splitProvisionBudget}). It
8
+ * reaches `link-budget.ts` for the counts and `formation-approval.ts` for the hook's flat
9
+ * timeout, and nothing in the protocol, so both roles and the manager can import it freely.
10
+ */
11
+
12
+ import debug from 'debug';
13
+ import { DEFAULT_APPROVAL_TIMEOUT_MS } from './formation-approval.js';
14
+ import {
15
+ CIRCUIT_REQUEST_ROUND_TRIPS,
16
+ cohortReadDeadlineMs,
17
+ commitBudgetMs,
18
+ relayedStreamOpenBudgetMs,
19
+ resolveLinkRoundTripMs
20
+ } from './link-budget.js';
21
+
22
+ const log = debug('sereus:cadre:formation-deadlines');
23
+
24
+ /**
25
+ * Every deadline of one formation session, derived from one declared link round trip
26
+ * ({@link formationDeadlines}). Each waits on work that crosses the link between the two
27
+ * parties' machines, so each is a round-trip count times the declaration rather than a
28
+ * number chosen on a fast network; the initiator's and the responder's fields are derived
29
+ * together so the ordering between the roles holds by construction.
30
+ */
31
+ export interface FormationDeadlines {
32
+ /** Initiator: open a possibly relayed connection and negotiate the formation protocol. */
33
+ dialMs: number;
34
+ /** Responder: from the handler starting to the contact frame arriving. */
35
+ awaitContactMs: number;
36
+ /**
37
+ * Responder: `validateToken` plus `validateDisclosure`, budgeted as two consulting control
38
+ * reads. Counted in the travel margin and the clamp room, not wrapped in a deadline of their
39
+ * own: the session bounds them.
40
+ */
41
+ validationMs: number;
42
+ /** Responder: the provisioning hook's work budget, after which its signal aborts. */
43
+ provisionWorkMs: number;
44
+ /** Responder: the settle grace after the abort, which must contain one control commit. */
45
+ provisionGraceMs: number;
46
+ /**
47
+ * What the initiator's await-response holds over the responder's provisioning: the
48
+ * contact frame's trip out, the result frame's trip back, and the validation reads that
49
+ * run before the responder's provisioning clock starts.
50
+ */
51
+ responseTravelMarginMs: number;
52
+ /** Initiator: the result frame read. */
53
+ initiatorAwaitResponseMs: number;
54
+ /** Both roles: the whole session. */
55
+ sessionMs: number;
56
+ }
57
+
58
+ /**
59
+ * Derive every formation deadline from the declared link round trip
60
+ * (`NetworkConfig.linkRoundTripMs`; the default when none is declared is
61
+ * `DECLARED_LINK_ROUND_TRIP_MS` in `link-budget.ts`). With `L` the declaration, `R` one
62
+ * cohort read at that link (`cohortReadDeadlineMs`, 2L: one request and its answer over an
63
+ * open circuit) and `K` one commit (`commitBudgetMs`, 20L, measured):
64
+ *
65
+ * dialMs = a relayed dial plus one protocol negotiation (5L + 2 000)
66
+ * awaitContactMs = one request over the open stream (2L)
67
+ * validationMs = 2R (4L)
68
+ * provisionWorkMs = approval hook (10 000, flat) + 2R + K (10 000 + 24L)
69
+ * provisionGraceMs = R + K (22L)
70
+ * responseTravelMarginMs = L + validationMs (5L)
71
+ * initiatorAwaitResponseMs = work + grace + margin (10 000 + 51L)
72
+ * sessionMs = dialMs + initiatorAwaitResponseMs (12 000 + 56L)
73
+ *
74
+ * Why each term:
75
+ *
76
+ * - **awaitContact**: the listener's handler starts when it answers the protocol
77
+ * negotiation; the answer reaches the joiner and the contact frame comes back, one round
78
+ * trip, budgeted as a request over an open stream (2) for the same headroom as every other
79
+ * one-exchange read in `link-budget.ts`.
80
+ * - **validation** and the reads inside **provisionWork** are budgeted at two consulting
81
+ * reads each, not one per read the code issues. What actually runs: validation is three
82
+ * control reads (`isTokenValid` reads the invite; `isTokenUsed` reads it again and counts
83
+ * its usages) plus whatever the disclosure validator does; the bound provisioning path
84
+ * (`StrandFormationManager.provisionAsResponder`) reads the invite and the strand row to
85
+ * resolve the host strand, the invite again, the approver's `ValidationKey` and the seat
86
+ * count around the outside approval hook (an HTTP call, so its own
87
+ * `DEFAULT_APPROVAL_TIMEOUT_MS` is link-independent and added flat), the strand row and its
88
+ * `StrandPartyKey` to issue the joiner's membership invite, and the host strand's stamp
89
+ * before the `FormationUsage` write; then one strand-database commit issues the invite.
90
+ * Every one of those is a read of this party's own control database, which in steady state
91
+ * touches held blocks and consults nobody (`cadre-consistency.md` → "Deadlines Over
92
+ * Optimystic's Reads and Commits"), so `R` is charged as headroom for the ones that do
93
+ * consult, not per read. Waking a hibernating host strand inside the issue is NOT counted
94
+ * either: `wakeHostStrandForFormation` cuts it off by design with a retryable rejection
95
+ * and leaves the wake running, so the joiner's retry finds the strand live.
96
+ * NOTE: if formation is seen timing out while the responder's control reads are consulting
97
+ * the cohort (a cold responder, a control database not yet converged), charge one `R` per
98
+ * read here rather than raising the flat headroom; the joiner is told 'Formation
99
+ * provisioning timed out' with its invite unspent, so the cost is a retry, not a spent
100
+ * invite.
101
+ * - **provisionGrace**: the window it exists for opens when the `FormationUsage` insert
102
+ * attempt passes its abort check. `ControlDatabase.redeemInvitation` and
103
+ * `recordFormationUsage` check the signal once per attempt inside the write lock, then run
104
+ * the seat read (`assertSeatRemains`) and the transaction, so the grace contains one read
105
+ * plus one commit. A retry after a failed attempt re-checks the abort, so only one attempt
106
+ * needs containing.
107
+ * - **responseTravelMargin**: half a round trip out, half back, plus the validation reads,
108
+ * which run on the initiator's await-response clock but before the responder's
109
+ * provisioning clock starts.
110
+ * - **session**: the initiator's dial plus its await-response. The responder's whole path
111
+ * (awaitContact + validation + work + grace = 10 000 + 52L) is inside it at every L.
112
+ *
113
+ * Ordering, strict at every L > 0, and pinned by `strand-formation-deadlines.spec.ts`:
114
+ * approval hook < provisionWork < work + grace < initiatorAwaitResponse < session, and the
115
+ * responder's whole path < session. Each layer can therefore fail and report before the
116
+ * layer above it gives up, and the work budget always outlasts the hook's own timeout, so a
117
+ * dead hook reports 'Formation approval unavailable, retry' rather than racing a
118
+ * provisioning timeout.
119
+ *
120
+ * **What this costs.** At the default declaration (3 500 ms) a joiner whose responder
121
+ * accepts the contact and then hangs waits up to 188.5 s, about three minutes, before it is
122
+ * told, where the fixed ladder this replaced told it after 15 s; the whole session is
123
+ * bounded at 208 s. A responder that is unreachable still fails at the dial, 19.5 s. A host
124
+ * that knows its party is on a fast link lowers `linkRoundTripMs` and gets roughly the old
125
+ * numbers back (at L = 100: dial 2.5 s, provisioning 14.6 s, await-response 15.1 s, session
126
+ * 17.6 s). Before this derivation, the fixed 5 s dial could not open a relayed connection
127
+ * above a 1.25-second round trip, and the fixed 2 s grace could not contain one commit at the
128
+ * supported link, so a joiner could be told 'timed out' over an invite that was in fact
129
+ * spent.
130
+ */
131
+ export function formationDeadlines(linkRoundTripMs?: number): FormationDeadlines {
132
+ const link = resolveLinkRoundTripMs(linkRoundTripMs);
133
+ const readMs = cohortReadDeadlineMs(link);
134
+ const commitMs = commitBudgetMs(link);
135
+ const dialMs = relayedStreamOpenBudgetMs(link);
136
+ const validationMs = 2 * readMs;
137
+ const provisionWorkMs = DEFAULT_APPROVAL_TIMEOUT_MS + 2 * readMs + commitMs;
138
+ const provisionGraceMs = readMs + commitMs;
139
+ const responseTravelMarginMs = link + validationMs;
140
+ const initiatorAwaitResponseMs = provisionWorkMs + provisionGraceMs + responseTravelMarginMs;
141
+ return {
142
+ dialMs,
143
+ awaitContactMs: CIRCUIT_REQUEST_ROUND_TRIPS * link,
144
+ validationMs,
145
+ provisionWorkMs,
146
+ provisionGraceMs,
147
+ responseTravelMarginMs,
148
+ initiatorAwaitResponseMs,
149
+ sessionMs: dialMs + initiatorAwaitResponseMs
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Ceiling for a resolved provisioning budget.
155
+ *
156
+ * The session budget also has to cover what PRECEDES provisioning in the role
157
+ * (`precedingMs`: the initiator's dial; the responder's contact read plus its validation
158
+ * reads), so that whole span is held back rather than triggering only on a literal overrun.
159
+ *
160
+ * `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
161
+ * lands strictly before the initiator's larger await-response budget even when both are
162
+ * clamped — without it, a configured budget large enough to clamp both roles puts the
163
+ * responder's reply on the wire at the moment the initiator's timeout fires. Capped at half
164
+ * the remaining room (like {@link splitProvisionBudget}) so a small session config still
165
+ * spends most of it working.
166
+ */
167
+ function provisionCeilingMs(sessionTimeoutMs: number, precedingMs: number, reserveMs: number): number {
168
+ const roomMs = sessionTimeoutMs - precedingMs;
169
+ return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
170
+ }
171
+
172
+ /**
173
+ * The extra room the responder's clamp holds back ({@link provisionCeilingMs}'s `reserveMs`).
174
+ *
175
+ * The initiator's clamped await-response is `session - dial`, and the responder's reply
176
+ * reaches it `responseTravelMarginMs` after provisioning ends, so provisioning must end by
177
+ * `session - dial - margin`. The responder's own room is `session - awaitContact -
178
+ * validation`; the difference between the two is what it must hold back on top. Computed
179
+ * from the responder's own derivation, since it cannot see the initiator's dial budget: the
180
+ * module doc of `link-budget.ts` says why both parties must declare the same link.
181
+ */
182
+ export function responderClampReserveMs(deadlines: FormationDeadlines, awaitContactMs: number): number {
183
+ return Math.max(0, deadlines.dialMs + deadlines.responseTravelMarginMs - awaitContactMs - deadlines.validationMs);
184
+ }
185
+
186
+ /**
187
+ * Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
188
+ * `defaultMs`). If the result would let provisioning outlive the session — no result
189
+ * frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
190
+ * {@link provisionCeilingMs} and log a warning. A budget that exactly meets the ceiling is
191
+ * not clamped: the derived defaults sit there by construction.
192
+ */
193
+ export function resolveProvisionTimeoutMs(
194
+ configured: number | undefined,
195
+ defaultMs: number,
196
+ sessionTimeoutMs: number,
197
+ precedingMs: number,
198
+ role: string,
199
+ reserveMs = 0
200
+ ): number {
201
+ const requested = configured && configured > 0 ? configured : defaultMs;
202
+ const ceilingMs = provisionCeilingMs(sessionTimeoutMs, precedingMs, reserveMs);
203
+ if (requested > ceilingMs) {
204
+ log(
205
+ '%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (preceding %dms, reserve %dms); clamping to %dms',
206
+ role, requested, sessionTimeoutMs, precedingMs, reserveMs, ceilingMs
207
+ );
208
+ return ceilingMs;
209
+ }
210
+ return requested;
211
+ }
212
+
213
+ /**
214
+ * Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
215
+ *
216
+ * The grace is carved OUT of the budget, never added on top, so the ladder in
217
+ * {@link formationDeadlines} is untouched: the listener aborts the provisioning hook when the
218
+ * work budget expires, then waits up to the grace for the work to settle anyway
219
+ * (`FormationListener.settleWithinGrace` in `strand-formation-protocol.ts`). `graceCeilingMs`
220
+ * is the derived grace, which contains one seat read plus one commit at the declared link; it
221
+ * is capped at half the budget so a small configured budget still spends at least half its
222
+ * time doing work.
223
+ */
224
+ export function splitProvisionBudget(provisionTimeoutMs: number, graceCeilingMs: number): { workMs: number; graceMs: number } {
225
+ const graceMs = Math.min(graceCeilingMs, Math.floor(provisionTimeoutMs / 2));
226
+ return { workMs: provisionTimeoutMs - graceMs, graceMs };
227
+ }