@serfab/cadre-core 1.16.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +11 -2
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +652 -1052
  151. package/src/cadre-join-request.ts +24 -3
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +886 -789
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -59,6 +59,8 @@ export const CONTROL_TABLES = [
59
59
  'FormationUsage',
60
60
  'CadreInvite',
61
61
  'CadreInviteUsage',
62
+ 'OwnerGrant',
63
+ 'OwnerGrantUsage',
62
64
  'Revocation',
63
65
  ] as const;
64
66
 
@@ -76,17 +78,17 @@ export type ControlTable = typeof CONTROL_TABLES[number];
76
78
  * {@link ControlTable} rather than a fresh literal list, so a renamed table is a
77
79
  * compile error here instead of a silently dead branch.
78
80
  *
79
- * `CadreInvite` is the one member whose rows are never deleted: its tombstone is a
80
- * withdrawal over a row that stays (`NotRevoked` refuses re-seating the withdrawn
81
- * row; there is no `RevocationRecorded`, since nothing is ever removed), so it is
82
- * revocable without being removable — `control-database.ts`'s `RemovableTable`
83
- * excludes it.
81
+ * `CadreInvite` and `OwnerGrant` are the two members whose rows are never deleted: a
82
+ * tombstone there is a withdrawal over a row that stays (`NotRevoked` refuses re-seating
83
+ * the withdrawn row; there is no `RevocationRecorded`, since nothing is ever removed), so
84
+ * they are revocable without being removable — `control-database.ts`'s `RemovableTable`
85
+ * excludes them.
84
86
  *
85
87
  * Lives in this import-free module rather than beside its main consumer
86
88
  * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
87
89
  * `revocationDigest` — can type against it without pulling in the runtime.
88
90
  */
89
- export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'JoinRequest' | 'DeviceToken' | 'CadreInvite'>;
91
+ export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'JoinRequest' | 'DeviceToken' | 'CadreInvite' | 'OwnerGrant'>;
90
92
 
91
93
  /**
92
94
  * What a signature authorizes, scoped to one table rule — or, for
@@ -122,10 +124,11 @@ export type BoundControlDomain = Exclude<ControlDomain, UnboundControlDomain>;
122
124
  * owner key.
123
125
  * - `'consent'` — a peer self-signs its OWN `FormationUsage` or
124
126
  * `CadreInviteUsage` redemption (the joiner proving it agreed to join), with
125
- * its own key. Distinct from the approver's `'vouch'` over the same table so
126
- * the two stored signatures are never interchangeable.
127
- * - `'redeem'` — the holder of a `CadreInvite`'s private key proves possession
128
- * on a `CadreInviteUsage` row. Distinct from the device's `'consent'` over the
127
+ * its own key, or the key an `OwnerGrantUsage` seats signs that it is held.
128
+ * Distinct from the approver's `'vouch'` over the same table so the two
129
+ * stored signatures are never interchangeable.
130
+ * - `'redeem'` — the holder of a `CadreInvite`'s or `OwnerGrant`'s private key
131
+ * proves possession on the usage row. Distinct from the `'consent'` over the
129
132
  * same fields: the two signatures are by different keys and neither may stand
130
133
  * in for the other.
131
134
  * - `'reissue'` — an owner re-writes an existing `Revocation` tombstone,
@@ -145,7 +148,6 @@ export type ControlAction = 'add' | 'remove' | 'vouch' | 'publish' | 'consent' |
145
148
  export interface CadreInviteSignedFields {
146
149
  key: string;
147
150
  peerId: string | null;
148
- grantsOwner: boolean;
149
151
  expiresAt: string | null;
150
152
  totalUses: number | null;
151
153
  stampId: string;
@@ -156,14 +158,45 @@ export interface CadreInviteSignedFields {
156
158
  * (`control-database.ts`'s `cadreInviteAddMessage`, raw bytes) and the verifier
157
159
  * (`peer-authorization.ts`'s `cadreInviteAddDigest`, base64url) so the two cannot
158
160
  * drift. SQL mirror, in `CadreInvite.AuthorizedInsert`:
159
- * `new.Key, coalesce(new.PeerId, ''), cast(new.GrantsOwner as text),
160
- * coalesce(cast(new.ExpiresAt as text), ''), coalesce(cast(new.TotalUses as text), ''), new.StampId`.
161
+ * `new.Key, coalesce(new.PeerId, ''), coalesce(cast(new.ExpiresAt as text), ''),
162
+ * coalesce(cast(new.TotalUses as text), ''), new.StampId`.
161
163
  */
162
164
  export function cadreInviteRowFields(row: CadreInviteSignedFields): string[] {
163
165
  return [
164
166
  row.key,
165
167
  row.peerId ?? '',
166
- row.grantsOwner ? '1' : '0',
168
+ row.expiresAt ?? '',
169
+ row.totalUses === null ? '' : String(row.totalUses),
170
+ row.stampId,
171
+ ];
172
+ }
173
+
174
+ /**
175
+ * The `OwnerGrant` columns its `'add'` signature binds, in the schema's order and with
176
+ * the row's stored forms (`expiresAt` canonical, as {@link CadreInviteSignedFields}).
177
+ * `label` is the label the seated `OwnerKey` row must carry: the issuer's choice, bound
178
+ * here so the holder cannot seat the key under another name. Structurally satisfied by
179
+ * an `OwnerGrantRow` (`types.ts`), which adds the issuer pair the signature does not cover.
180
+ */
181
+ export interface OwnerGrantSignedFields {
182
+ key: string;
183
+ label: string | null;
184
+ expiresAt: string | null;
185
+ totalUses: number | null;
186
+ stampId: string;
187
+ }
188
+
189
+ /**
190
+ * The row-field half of an `OwnerGrant` `'add'` digest, shared by the signer
191
+ * (`control-database.ts`'s `ownerGrantAddMessage`) and the verifier
192
+ * (`peer-authorization.ts`'s `ownerGrantAddDigest`), as {@link cadreInviteRowFields} is.
193
+ * SQL mirror, in `OwnerGrant.AuthorizedInsert`: `new.Key, coalesce(new.Label, ''),
194
+ * coalesce(cast(new.ExpiresAt as text), ''), coalesce(cast(new.TotalUses as text), ''), new.StampId`.
195
+ */
196
+ export function ownerGrantRowFields(row: OwnerGrantSignedFields): string[] {
197
+ return [
198
+ row.key,
199
+ row.label ?? '',
167
200
  row.expiresAt ?? '',
168
201
  row.totalUses === null ? '' : String(row.totalUses),
169
202
  row.stampId,
@@ -1,128 +1,127 @@
1
- /**
2
- * Control-cohort dial selection: the pure policy behind
3
- * {@link CadreNode.reconcileControlCohort}. Decides WHICH known cadre siblings a
4
- * node proactively dials each pass to keep the `CadreControl` collections' cohort
5
- * connected (and thus replicating), without devolving into an N² mesh.
6
- *
7
- * FRET only needs a *connected* graph with the block clusters reachable, not a
8
- * full mesh. Storage/owner nodes are the stable, publicly-dialable backbone
9
- * that hold the control blocks, so the policy is **backbone-preferential with a
10
- * bounded out-degree**:
11
- *
12
- * - **Always dial every backbone (owner) member** — the owner set is
13
- * small by design, and routing cohort formation through publicly-dialable
14
- * owner nodes keeps the cohort ≥2 where owner-signed writes originate.
15
- * - **Fill the remainder** up to `targetDegree` with non-owner members,
16
- * ordered deterministically (peerId sort) so every node makes the same stable
17
- * choice across passes and load spreads predictably.
18
- *
19
- * For a small party (members ≤ degree) this degenerates to a full mesh, which is
20
- * correct, cheap, and matches what the convergence tests do by hand; for a large
21
- * party it bounds out-degree to `backbone + targetDegree`.
22
- */
23
-
24
- import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
25
- import type { CohortPeerRow } from './strand-cohort.js';
26
-
27
- /**
28
- * Default recurring cadence for {@link CadreNode.reconcileControlCohort} (ms).
29
- * In the same spirit as the 5s strand-watch poll but lighter, since a control
30
- * cohort changes far less often than strand activity.
31
- *
32
- * An interval between pass starts, not a deadline on a pass. Passes are
33
- * single-flight, so a pass that runs longer than this is joined rather than
34
- * stacked, and the effective cadence becomes the pass length: one unreachable
35
- * sibling's dial alone can take the whole per-peer dial budget (86 s at the
36
- * default declared link).
37
- */
38
- export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15_000;
39
-
40
- /**
41
- * Default cap on the number of **non-owner** members a single reconcile pass
42
- * proactively dials. Backbone (owner) members are always dialed and do NOT
43
- * count against this cap.
44
- */
45
- export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
46
-
47
- // The per-sibling and per-address dial time limits live in `peer-dial.ts`
48
- // (`DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS`,
49
- // `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS`), beside the dial they
50
- // bound, which `seed-bootstrap.ts` also uses and cannot import from here.
51
-
52
- /** Outcome of {@link selectControlCohortDials}: who to dial + what the cap dropped. */
53
- export interface ControlCohortSelection {
54
- /**
55
- * Siblings to dial this pass, backbone first then the bounded non-owner
56
- * fill. Already self-excluded (the caller must pass self-excluded `siblings`).
57
- * Still includes peers that may turn out to be already-connected — the caller
58
- * diffs against live connections separately.
59
- */
60
- dials: CohortPeerRow[];
61
- /** Non-owner members dropped by the `targetDegree` cap (0 when none). */
62
- cappedNonOwner: number;
63
- }
64
-
65
- /** What one {@link CadreNode.reconcileControlCohort} pass did. */
66
- export interface ControlCohortReconcileResult {
67
- /**
68
- * Peer ids this pass dialled and whose dial resolved, in dial order: selected
69
- * siblings on the steady-state pass, retained bootstrap peers on the cold-start
70
- * pass (the pass takes one branch or the other, never both). A peer already
71
- * connected when the pass began is never listed — the pass skips it. A resolved
72
- * dial can still be refused moments later by the remote's membership gate, so
73
- * an entry means "this pass opened a connection", not "that connection is still
74
- * open".
75
- */
76
- dialed: string[];
77
- }
78
-
79
- /** Stable peerId ordering so the bounded fill is identical across passes/nodes. */
80
- function byPeerId(a: CohortPeerRow, b: CohortPeerRow): number {
81
- if (a.peerId < b.peerId) return -1;
82
- if (a.peerId > b.peerId) return 1;
83
- return 0;
84
- }
85
-
86
- /**
87
- * Backbone-preferential, bounded-out-degree selection of control-cohort siblings
88
- * to dial.
89
- *
90
- * A member is **backbone** iff the ed25519 key derived from its peerId
91
- * (see {@link ed25519PublicKeyB64FromPeerId}) is in `ownerKeys`. All backbone
92
- * members are selected; non-owner members fill up to `targetDegree` in
93
- * deterministic peerId order.
94
- *
95
- * @param siblings - cadre members EXCLUDING self (the caller filters self out).
96
- * @param ownerKeys - the converged `OwnerKey` set; may be empty/partial
97
- * before owner convergence, in which case no member classifies as backbone
98
- * yet and the bounded fill still makes progress (preference sharpens later).
99
- * @param targetDegree - cap on non-owner dials (negative is treated as 0).
100
- */
101
- export function selectControlCohortDials(
102
- siblings: CohortPeerRow[],
103
- ownerKeys: ReadonlySet<string>,
104
- targetDegree: number,
105
- ): ControlCohortSelection {
106
- const backbone: CohortPeerRow[] = [];
107
- const rest: CohortPeerRow[] = [];
108
-
109
- for (const peer of siblings) {
110
- const pubKey = ed25519PublicKeyB64FromPeerId(peer.peerId);
111
- if (pubKey !== null && ownerKeys.has(pubKey)) {
112
- backbone.push(peer);
113
- } else {
114
- rest.push(peer);
115
- }
116
- }
117
-
118
- backbone.sort(byPeerId);
119
- rest.sort(byPeerId);
120
-
121
- const cap = Math.max(0, targetDegree);
122
- const fill = rest.slice(0, cap);
123
-
124
- return {
125
- dials: [...backbone, ...fill],
126
- cappedNonOwner: rest.length - fill.length,
127
- };
128
- }
1
+ /**
2
+ * Control-cohort dial selection: the pure policy behind
3
+ * {@link CadreNode.reconcileControlCohort}. Decides WHICH known cadre siblings a
4
+ * node proactively dials each pass to keep the `CadreControl` collections' cohort
5
+ * connected (and thus replicating), without devolving into an N² mesh.
6
+ *
7
+ * FRET only needs a *connected* graph with the block clusters reachable, not a
8
+ * full mesh. Always-on (`storage` profile) machines are the stable, dialable
9
+ * backbone that hold the control blocks, so the policy is **backbone-preferential
10
+ * with a bounded out-degree**:
11
+ *
12
+ * - **Always dial every backbone (always-on) member** — a party runs few of them,
13
+ * and they are the machines that stay reachable while phones sleep or roam.
14
+ * - **Fill the remainder** up to `targetDegree` with the other members (phones,
15
+ * and machines that have not yet published a profile), ordered
16
+ * deterministically (peerId sort) so every node makes the same stable choice
17
+ * across passes and load spreads predictably.
18
+ *
19
+ * For a small party (members ≤ degree) this degenerates to a full mesh, which is
20
+ * correct, cheap, and matches what the convergence tests do by hand; for a large
21
+ * party it bounds out-degree to `backbone + targetDegree`.
22
+ */
23
+
24
+ import type { CohortPeerRow } from './strand-cohort.js';
25
+
26
+ /**
27
+ * Default recurring cadence for {@link CadreNode.reconcileControlCohort} (ms).
28
+ * In the same spirit as the 5s strand-watch poll but lighter, since a control
29
+ * cohort changes far less often than strand activity.
30
+ *
31
+ * An interval between pass starts, not a deadline on a pass. Passes are
32
+ * single-flight, so a pass that runs longer than this is joined rather than
33
+ * stacked, and the effective cadence becomes the pass length: one unreachable
34
+ * sibling's dial alone can take the whole per-peer dial budget (86 s at the
35
+ * default declared link).
36
+ */
37
+ export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15_000;
38
+
39
+ /**
40
+ * Default cap on the number of **non-backbone** members a single reconcile pass
41
+ * proactively dials. Backbone (always-on) members are always dialed and do NOT
42
+ * count against this cap.
43
+ */
44
+ export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
45
+
46
+ // The per-sibling and per-address dial time limits live in `peer-dial.ts`
47
+ // (`DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS`,
48
+ // `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS`), beside the dial they
49
+ // bound, which `seed-bootstrap.ts` also uses and cannot import from here.
50
+
51
+ /** Outcome of {@link selectControlCohortDials}: who to dial + what the cap dropped. */
52
+ export interface ControlCohortSelection {
53
+ /**
54
+ * Siblings to dial this pass, backbone first then the bounded non-backbone
55
+ * fill. Already self-excluded (the caller must pass self-excluded `siblings`).
56
+ * Still includes peers that may turn out to be already-connected — the caller
57
+ * diffs against live connections separately.
58
+ */
59
+ dials: CohortPeerRow[];
60
+ /** Non-backbone members dropped by the `targetDegree` cap (0 when none). */
61
+ cappedNonBackbone: number;
62
+ }
63
+
64
+ /** What one {@link CadreNode.reconcileControlCohort} pass did. */
65
+ export interface ControlCohortReconcileResult {
66
+ /**
67
+ * Peer ids this pass dialled and whose dial resolved, in dial order: selected
68
+ * siblings on the steady-state pass, retained bootstrap peers on the cold-start
69
+ * pass (the pass takes one branch or the other, never both). A peer already
70
+ * connected when the pass began is never listed — the pass skips it. A resolved
71
+ * dial can still be refused moments later by the remote's membership gate, so
72
+ * an entry means "this pass opened a connection", not "that connection is still
73
+ * open".
74
+ */
75
+ dialed: string[];
76
+ }
77
+
78
+ /** Stable peerId ordering so the bounded fill is identical across passes/nodes. */
79
+ function byPeerId(a: CohortPeerRow, b: CohortPeerRow): number {
80
+ if (a.peerId < b.peerId) return -1;
81
+ if (a.peerId > b.peerId) return 1;
82
+ return 0;
83
+ }
84
+
85
+ /**
86
+ * Backbone-preferential, bounded-out-degree selection of control-cohort siblings
87
+ * to dial.
88
+ *
89
+ * A member is **backbone** iff its `CadrePeer` row declares the `storage` profile
90
+ * (an always-on machine). All backbone members are selected; the rest fill up to
91
+ * `targetDegree` in deterministic peerId order. A member whose profile is still
92
+ * unknown (it has not self-published) is not backbone until it publishes one.
93
+ * A profile is a dial preference only: it is self-declared, and a machine
94
+ * misdeclaring one costs extra dials, never access.
95
+ *
96
+ * @param siblings - cadre members EXCLUDING self (the caller filters self out).
97
+ * @param targetDegree - cap on non-backbone dials (negative is treated as 0).
98
+ */
99
+ export function selectControlCohortDials(
100
+ siblings: CohortPeerRow[],
101
+ targetDegree: number,
102
+ ): ControlCohortSelection {
103
+ const backbone: CohortPeerRow[] = [];
104
+ const rest: CohortPeerRow[] = [];
105
+
106
+ // NOTE: the backbone is uncapped, so out-degree grows with the number of always-on
107
+ // machines (and with any sibling that declares `storage` without being one); if a party
108
+ // ever runs more than a handful, cap the backbone too.
109
+ for (const peer of siblings) {
110
+ if (peer.profile === 'storage') {
111
+ backbone.push(peer);
112
+ } else {
113
+ rest.push(peer);
114
+ }
115
+ }
116
+
117
+ backbone.sort(byPeerId);
118
+ rest.sort(byPeerId);
119
+
120
+ const cap = Math.max(0, targetDegree);
121
+ const fill = rest.slice(0, cap);
122
+
123
+ return {
124
+ dials: [...backbone, ...fill],
125
+ cappedNonBackbone: rest.length - fill.length,
126
+ };
127
+ }