@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,227 +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 (6L + 4 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 (14 000 + 57L)
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 213.5 s. A responder that is unreachable still fails at the dial, 25 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 4.6 s, provisioning 14.6 s, await-response 15.1 s, session
126
- * 19.7 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
- }
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 (6L + 4 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 (14 000 + 57L)
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 213.5 s. A responder that is unreachable still fails at the dial, 25 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 4.6 s, provisioning 14.6 s, await-response 15.1 s, session
126
+ * 19.7 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
+ }