@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Node-only file-backed {@link EnrolledMachineStore}: one JSON file per party under
3
+ * a configured directory (the node's state directory — `cadre-cli` passes
4
+ * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated count of
5
+ * machines this party had enrolled the last time this node looked.
6
+ *
7
+ * Nothing but the file itself lives here: the envelope, the cold-start-on-anything
8
+ * load policy (deliberately NOT the throw-on-unreadable policy its two sibling
9
+ * records use — see the `enrolled-machine-store.ts` module comment) and the
10
+ * serialised writes are cross-platform in `PersistentEnrolledMachineStore`, and the
11
+ * file is a `FileDurableSlot`. This module exists only so the `node:fs` edge stays
12
+ * out of the package's cross-platform default entry (`./index.js`) — import it from
13
+ * the dedicated subpath instead:
14
+ *
15
+ * ```ts
16
+ * import { FileEnrolledMachineStore } from '@serfab/cadre-core/enrolled-machine-store-file';
17
+ * ```
18
+ *
19
+ * A React Native / browser entry graph never resolves this path, so the `node:fs`
20
+ * edge never reaches a bundler that cannot satisfy it (same isolation pattern as
21
+ * `bootstrap-peer-store-file.ts` / `trusted-owner-store-file.ts` / `key-store-file.ts`).
22
+ */
23
+ import { FileDurableSlot } from './file-durable-slot.js';
24
+ import { PersistentEnrolledMachineStore } from './enrolled-machine-store.js';
25
+ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
26
+
27
+ /** Base name of the store file: `<dir>/enrolled-machines.<encoded partyId>.json`. */
28
+ const SLOT_NAME = 'enrolled-machines';
29
+
30
+ /**
31
+ * File-backed {@link EnrolledMachineStore}. Open via {@link open}, which loads the
32
+ * existing file and returns the cross-platform store over it. An absent, corrupt,
33
+ * wrong-party, junk-valued **or unreadable** file is a cold start (`count()` is
34
+ * `undefined`, the node declares no repair yardstick and runs at the base control
35
+ * policy) — `open` never rejects, unlike the sibling stores' file backends. That
36
+ * divergence and its reasoning live on `enrolled-machine-store.ts`.
37
+ */
38
+ export const FileEnrolledMachineStore = {
39
+ /** Load (or cold-start) the party's last recorded machine count from a file in `dir`. */
40
+ async open(dir: string, partyId: string): Promise<EnrolledMachineStore> {
41
+ return PersistentEnrolledMachineStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
42
+ }
43
+ };
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Node-local, NON-replicated record of how many machines this party had enrolled
3
+ * the last time this node looked — the block-repair corroboration yardstick the
4
+ * CONTROL network declares when its libp2p node is built.
5
+ *
6
+ * ## Why the number has to be remembered rather than read
7
+ *
8
+ * `resolveRepairYardstick` (in `@serfab/quereus-plugin-sereus`'s `cluster-size.ts`
9
+ * — read it first; the arithmetic and both clamps are explained there and are not
10
+ * repeated here) derives the yardstick from the machines that SERVE the network being
11
+ * configured. For the CONTROL network that is the machines enrolled in the party, and
12
+ * exactly so: every enrolled machine runs the control node by construction. (It is NOT
13
+ * the right number for a strand, which runs on a subset — see
14
+ * `StartStrandConfig.servingMachines`. This store is control-network-only, and that
15
+ * scope is a safety property, not an accident.) Optimystic freezes a network's
16
+ * `clusterPolicy` when its libp2p node is built and offers no runtime setter, so the
17
+ * number has to be known at construction time.
18
+ *
19
+ * The control node cannot read it when it needs it. `CadreNode.buildControlNodeOptions`
20
+ * runs inside `start()` BEFORE `createControlNode()`, which is before the
21
+ * `ControlDatabase` holding the `CadrePeer` rows exists — so at the one moment the
22
+ * number is needed, nothing can answer. That deadlock is why the control network
23
+ * declared no yardstick at all and ran at `CONTROL_CLUSTER_POLICY`'s
24
+ * `assumedClusterSize` of 2.
25
+ *
26
+ * This store breaks it the only way a chicken-and-egg is broken: by remembering
27
+ * across the restart. `CadreNode.refreshAuthorizedControlPeers` records the count
28
+ * every time membership is recomputed; `CadreNode.start()` reads it back before the
29
+ * control node is built. A node therefore declares what it last knew, and a party
30
+ * that grows applies the larger number on each node's NEXT launch. Raising the
31
+ * yardstick is the safe direction and a node still holding the old value is running
32
+ * at exactly today's behaviour, so the next natural restart is soon enough — there
33
+ * is deliberately no forced rebuild.
34
+ *
35
+ * ## Why this is NOT a `NodeLocalSnapshot` like its two siblings
36
+ *
37
+ * The trusted-owner anchor (`trusted-owner-store.ts`) and the cold-start
38
+ * bootstrap-peer store (`bootstrap-peer-store.ts`) are the same `{ version,
39
+ * partyId, ... }` envelope over a {@link DurableSlot} this record is, and this
40
+ * module is deliberately symmetrical with them — same injected-backend shape, same
41
+ * `Memory*` / `Persistent*` / `File*` split, same party scoping. It does NOT share
42
+ * their `NodeLocalSnapshot` machinery, for two reasons:
43
+ *
44
+ * - **Shape.** `NodeLocalSnapshot` is a `key -> entry` map. This record is one
45
+ * integer that must be able to go DOWN after a machine is removed.
46
+ * - **Load policy — the important one.** `NodeLocalSnapshot.open` THROWS when the
47
+ * slot is present but unreadable. That is right for the anchor (a record that
48
+ * cannot be read in full is not a trust anchor) and right for the peer store (a
49
+ * failed read reported as absent would let the next snapshot write destroy a
50
+ * stranded node's only way home). It is WRONG here. Nothing in this record is
51
+ * trust-bearing, it holds nothing that is not recomputed the moment the control
52
+ * database is up, and refusing to start a node over an unreadable *repair hint*
53
+ * is strictly worse than declaring today's 2. So every unreadable, unparsable,
54
+ * foreign-party or junk slot LOGS and COLD-STARTS, yielding `undefined`, which
55
+ * declares nothing, which is byte-for-byte today's behaviour.
56
+ *
57
+ * Do not "unify" the three modules by routing this one through `NodeLocalSnapshot`:
58
+ * that would quietly import the throw, and turn an unreadable hint into a node that
59
+ * will not start.
60
+ *
61
+ * NOTE: writes are serialised in-process only, the same caveat `NodeLocalSnapshot`
62
+ * carries. Two processes (or two browser tabs) sharing ONE slot for ONE party each
63
+ * write their own view and the loser's count is lost. Two same-origin tabs of
64
+ * `reference-app-web` do exactly that — its slot is one key of a shared IndexedDB
65
+ * database, the same last-writer-wins caveat its `node-local-slots.ts` already
66
+ * records for the other two records. Harmless here, unlike there: this value is one
67
+ * integer both tabs re-derive from the same membership rows on their next refresh,
68
+ * so the views reconverge rather than losing an entry nobody rewrites.
69
+ */
70
+ import debug from 'debug';
71
+ import type { DurableSlot } from './node-local-snapshot.js';
72
+
73
+ const log = debug('sereus:cadre:enrolled-machine-store');
74
+
75
+ /** Envelope version. Guards future migrations; an unknown version cold-starts. */
76
+ const ENVELOPE_VERSION = 1;
77
+
78
+ /** Envelope property holding the count. */
79
+ const PAYLOAD_KEY = 'enrolledMachines';
80
+
81
+ export interface EnrolledMachineStore {
82
+ /** Party this store is scoped to. */
83
+ readonly partyId: string;
84
+
85
+ /**
86
+ * Machines last recorded for this party, or `undefined` when nothing usable was
87
+ * ever recorded — a brand-new node, or a slot that could not be read. Callers
88
+ * pass this straight to `controlClusterPolicy`, whose `undefined` branch returns
89
+ * the frozen base policy unchanged, so "this node does not know" and "this node
90
+ * has never run" are the same declaration: none.
91
+ */
92
+ count(): number | undefined;
93
+
94
+ /**
95
+ * Record the current count. Reflected by {@link count} SYNCHRONOUSLY; the
96
+ * returned promise resolves once the persist attempt has settled.
97
+ *
98
+ * **Never rejects.** Its sole caller is
99
+ * `CadreNode.refreshAuthorizedControlPeers`, whose contract is never-rejects and
100
+ * which leaves the promise un-awaited (`void`) — so a rejection here would
101
+ * surface as an unhandled rejection in the embedder's process, over a hint that
102
+ * re-lands on the next refresh anyway. A failed persist is LOGGED, and the
103
+ * in-memory count stays correct for this session.
104
+ *
105
+ * A count that is not a positive integer is a caller bug: it is logged and
106
+ * IGNORED rather than persisted, so a degenerate value can never reach the slot
107
+ * and be read back as a declaration nobody chose.
108
+ */
109
+ record(count: number): Promise<void>;
110
+ }
111
+
112
+ /**
113
+ * Ephemeral in-memory store — the default when no store is injected via
114
+ * `CadreNodeConfig.enrolledMachines`. A node using this cold-starts on every
115
+ * launch: `count()` is `undefined` at `start()`, so its control node declares
116
+ * nothing and runs at today's behaviour, exactly as before this record existed.
117
+ */
118
+ export class MemoryEnrolledMachineStore implements EnrolledMachineStore {
119
+ private current: number | undefined;
120
+
121
+ constructor(readonly partyId: string) {}
122
+
123
+ count(): number | undefined {
124
+ return this.current;
125
+ }
126
+
127
+ async record(count: number): Promise<void> {
128
+ const accepted = asMachineCount(count);
129
+ if (accepted === undefined) {
130
+ log('refusing to record a non-positive-integer count %o for party %s', count, this.partyId);
131
+ return;
132
+ }
133
+ this.current = accepted;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Durable {@link EnrolledMachineStore} over an app-supplied {@link DurableSlot} —
139
+ * the cross-platform half of every persistent backend (the Node
140
+ * `FileEnrolledMachineStore` is this class over a file slot).
141
+ *
142
+ * Construct via {@link open}. Load policy is the module comment's: every failure
143
+ * mode cold-starts, and `open` never rejects.
144
+ */
145
+ export class PersistentEnrolledMachineStore implements EnrolledMachineStore {
146
+ /** Serialises persists so the last count recorded is the last one written. */
147
+ private writeChain: Promise<void> = Promise.resolve();
148
+ /** Last count accepted, whether or not it has reached the slot yet. */
149
+ private current: number | undefined;
150
+ /** Last count believed to BE in the slot; drives the unchanged-write skip. */
151
+ private persisted: number | undefined;
152
+
153
+ private constructor(
154
+ private readonly slot: DurableSlot,
155
+ readonly partyId: string,
156
+ loaded: number | undefined
157
+ ) {
158
+ this.current = loaded;
159
+ this.persisted = loaded;
160
+ }
161
+
162
+ /** Load (or cold-start) the party's last recorded machine count from `slot`. */
163
+ static async open(slot: DurableSlot, partyId: string): Promise<PersistentEnrolledMachineStore> {
164
+ return new PersistentEnrolledMachineStore(slot, partyId, await loadCount(slot, partyId));
165
+ }
166
+
167
+ count(): number | undefined {
168
+ return this.current;
169
+ }
170
+
171
+ /**
172
+ * Record the count: visible via {@link count} synchronously, then persisted.
173
+ *
174
+ * Writes are SKIPPED when the slot already holds this number, because the sole
175
+ * caller runs on every committed membership write *and* every timed reconcile
176
+ * pass — a party whose membership is stable would otherwise rewrite the same
177
+ * integer forever. The skip is keyed off {@link persisted} rather than
178
+ * {@link current}, so a write that FAILED is retried by the next refresh instead
179
+ * of being suppressed as "unchanged".
180
+ */
181
+ record(count: number): Promise<void> {
182
+ const accepted = asMachineCount(count);
183
+ if (accepted === undefined) {
184
+ log('refusing to record a non-positive-integer count %o for party %s', count, this.partyId);
185
+ return Promise.resolve();
186
+ }
187
+ this.current = accepted;
188
+ if (this.persisted === accepted) {
189
+ return Promise.resolve();
190
+ }
191
+ this.writeChain = this.writeChain.then(() => this.persistCurrent());
192
+ return this.writeChain;
193
+ }
194
+
195
+ /**
196
+ * Write whatever {@link current} holds when this link of the chain runs — so a
197
+ * burst of records collapses into one write of the latest value — and never
198
+ * throw (see {@link EnrolledMachineStore.record}).
199
+ */
200
+ private async persistCurrent(): Promise<void> {
201
+ const pending = this.current;
202
+ if (pending === undefined || pending === this.persisted) {
203
+ return;
204
+ }
205
+ const envelope = {
206
+ version: ENVELOPE_VERSION,
207
+ partyId: this.partyId,
208
+ [PAYLOAD_KEY]: pending
209
+ };
210
+ try {
211
+ await this.slot.save(JSON.stringify(envelope, null, '\t'));
212
+ this.persisted = pending;
213
+ log('recorded %d enrolled machine(s) for party %s', pending, this.partyId);
214
+ } catch (error) {
215
+ log(
216
+ 'persisting %d enrolled machine(s) for party %s failed (in-memory count stands, retried on the next refresh): %o',
217
+ pending, this.partyId, error
218
+ );
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * A usable machine count, or `undefined`. Anything that is not a positive integer
225
+ * is unknown rather than coerced — `'3'`, `0`, `-1`, `2.5`, `null` and a missing key
226
+ * all cold-start. Coercing here would be the beginning of a parser, and Optimystic
227
+ * itself treats a degenerate declaration as absent, so a rounded value would be a
228
+ * number nobody chose.
229
+ */
230
+ function asMachineCount(value: unknown): number | undefined {
231
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined;
232
+ }
233
+
234
+ /**
235
+ * Read the slot into the initial count. EVERY failure — unreadable slot, unparsable
236
+ * JSON, wrong envelope version, foreign `partyId`, junk payload — logs and returns
237
+ * `undefined` (see the module comment for why this record, unlike its two siblings,
238
+ * must not throw on an unreadable slot).
239
+ */
240
+ async function loadCount(slot: DurableSlot, partyId: string): Promise<number | undefined> {
241
+ let text: string | undefined;
242
+ try {
243
+ text = await slot.load();
244
+ } catch (error) {
245
+ // The deliberate divergence from `NodeLocalSnapshot.open`, which throws here.
246
+ log('slot for party %s is present but unreadable (cold start — this node declares no yardstick): %o', partyId, error);
247
+ return undefined;
248
+ }
249
+ if (text === undefined) {
250
+ return undefined;
251
+ }
252
+
253
+ let parsed: unknown;
254
+ try {
255
+ parsed = JSON.parse(text);
256
+ } catch (error) {
257
+ log('corrupt slot for party %s (cold start): %o', partyId, error);
258
+ return undefined;
259
+ }
260
+
261
+ if (!isRecord(parsed) || parsed.version !== ENVELOPE_VERSION || parsed.partyId !== partyId) {
262
+ // A non-string partyId cannot equal the requested one, so this covers both a
263
+ // malformed envelope and a slot reused for another party.
264
+ log('slot for party %s has an unknown shape or a foreign partyId (cold start)', partyId);
265
+ return undefined;
266
+ }
267
+
268
+ const count = asMachineCount(parsed[PAYLOAD_KEY]);
269
+ if (count === undefined) {
270
+ log('slot for party %s holds an unusable %s value %o (cold start)', partyId, PAYLOAD_KEY, parsed[PAYLOAD_KEY]);
271
+ return undefined;
272
+ }
273
+ log('loaded %d enrolled machine(s) for party %s', count, partyId);
274
+ return count;
275
+ }
276
+
277
+ /** A plain JSON object. Arrays excluded: an array envelope is not an envelope. */
278
+ function isRecord(value: unknown): value is Record<string, unknown> {
279
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
280
+ }
@@ -5,9 +5,11 @@
5
5
  * parties without cross-party leakage.
6
6
  *
7
7
  * This is the whole platform-specific part of the file-backed node-local
8
- * stores (`trusted-owner-store-file.ts`, `bootstrap-peer-store-file.ts`); the
9
- * load policy, envelope and write chain are cross-platform in
10
- * `node-local-snapshot.ts`. Like its two importers, this module imports
8
+ * stores (`trusted-owner-store-file.ts`, `bootstrap-peer-store-file.ts`,
9
+ * `enrolled-machine-store-file.ts`); the load policy, envelope and write chain
10
+ * are cross-platform — in `node-local-snapshot.ts` for the first two, and in
11
+ * `enrolled-machine-store.ts` for the third, which deliberately does not share
12
+ * that machinery. Like its three importers, this module imports
11
13
  * `node:fs/promises`, so it is kept OUT of the package's cross-platform
12
14
  * default entry and is not itself an exported subpath.
13
15
  */
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export { canonicalJson } from './canonical-json.js';
8
8
  export { CadreNode } from './cadre-node.js';
9
9
 
10
10
  // Control database
11
- export { ControlDatabase, MissingHostStrandError, buildAuthorizationMessage, formationVouchMessage, formationConsentMessage, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type MembershipChangeListener, type GuardedDeleteListener, type RevokedRowRef } from './control-database.js';
11
+ export { ControlDatabase, MissingHostStrandError, buildAuthorizationMessage, formationVouchMessage, formationConsentMessage, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef } from './control-database.js';
12
12
 
13
13
  // Bounded retry for transient control-write failures (classifier + loop behind
14
14
  // ControlDatabase.lockedWithRetry; exported so the integration package can drive the
@@ -35,6 +35,15 @@ export {
35
35
  type ControlReadRetryOptions
36
36
  } from './control-read-retry.js';
37
37
 
38
+ // The bounded retry loop both control policies share. Only the abandonment record leaves
39
+ // this package: it is the payload of the `control:write-abandoned` node event, so an
40
+ // embedding app can type a handler for it.
41
+ export type {
42
+ ControlRetryAbandonment,
43
+ ControlRetryAbandonReason,
44
+ ControlRetryAbandonListener
45
+ } from './control-retry.js';
46
+
38
47
  // Control-plane authorization field vector (the domain/action tagging every signer shares)
39
48
  export { controlAuthorizationFields, type ControlDomain, type ControlAction } from './control-authorization.js';
40
49
 
@@ -94,6 +103,25 @@ export {
94
103
  type BootstrapPeerEntry
95
104
  } from './bootstrap-peer-store.js';
96
105
 
106
+ // Node-local enrolled-machine count: the machines this party had enrolled the
107
+ // last time this node looked, remembered across restarts so the CONTROL node can
108
+ // declare a block-repair yardstick at bring-up — which is before the database
109
+ // holding the membership rows exists. Same cross-platform split as the two
110
+ // records above; Node-only file backend behind the subpath
111
+ // '@serfab/cadre-core/enrolled-machine-store-file'. Its load policy deliberately
112
+ // DIVERGES from theirs (an unreadable slot cold-starts rather than throwing) —
113
+ // see the module comment before unifying the three.
114
+ export {
115
+ MemoryEnrolledMachineStore,
116
+ PersistentEnrolledMachineStore,
117
+ type EnrolledMachineStore
118
+ } from './enrolled-machine-store.js';
119
+
120
+ // Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
121
+ // strand's key is its strand id; the control database's key carries the party id,
122
+ // so two parties on one device never share a control store.
123
+ export { controlStorageScope, isControlStorageScope } from './storage-scope.js';
124
+
97
125
  // Strand database
98
126
  export { StrandDatabase, type StrandDatabaseConfig } from './strand-database.js';
99
127
 
@@ -109,20 +137,71 @@ export {
109
137
  StrandInstanceManager,
110
138
  type StartStrandConfig,
111
139
  type ResumeStrandOverrides,
112
- getStrandStoragePath
140
+ getStrandStoragePath,
141
+ isAwaitingFirstSync,
142
+ liveStrandStatus
113
143
  } from './strand-instance-manager.js';
114
144
 
115
- // Strand peer-join block catch-up (push this strand's own blocks to each newly
116
- // connected strand peer, so a late joiner physically holds pre-join blocks).
145
+ // The joining machine's first-sync write gate: a non-founder launch withholds its
146
+ // database (status 'syncing') until the strand's Header has arrived from a peer, so a
147
+ // write before the first sync can never fork the strand's tables.
148
+ export {
149
+ StrandAwaitingFirstSyncError,
150
+ DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS,
151
+ DEFAULT_STRAND_FIRST_SYNC_POLL_MS,
152
+ strandHeaderHeld,
153
+ appTablesReadable,
154
+ strandFirstSyncComplete,
155
+ type StrandFirstSyncConfig
156
+ } from './strand-first-sync-gate.js';
157
+
158
+ // Peer-join block catch-up (push this network's own blocks to each newly
159
+ // connected peer, so a late joiner physically holds pre-join blocks). Shared
160
+ // by the strand networks (ungated) and the control network (membership-gated).
117
161
  export {
118
- StrandBackfill,
119
- DEFAULT_STRAND_BACKFILL,
162
+ PeerJoinBackfill,
163
+ DEFAULT_PEER_JOIN_BACKFILL,
120
164
  MAX_BLOCK_MESSAGE_BYTES,
121
- type StrandBackfillConfig,
122
- type StrandBackfillDeps,
123
- type StrandBackfillResult,
124
- type StrandBackfillPushClient
125
- } from './strand-backfill.js';
165
+ type PeerJoinBackfillConfig,
166
+ type PeerJoinBackfillDeps,
167
+ type PeerJoinBackfillResult,
168
+ type PeerJoinBackfillPushClient
169
+ } from './peer-join-backfill.js';
170
+
171
+ // Closed-strand revoked-peer enforcement (deny the network nodes of removed
172
+ // members at the stream and connection layers, and hang up the sessions they
173
+ // already hold — see the module doc for the settled deny-list design, the
174
+ // teardown seam, and the fail directions).
175
+ export {
176
+ StrandRevocationEnforcer,
177
+ createRevocationConnectionGater,
178
+ readStrandRevocationRows,
179
+ DEFAULT_REVOCATION_POLL_INTERVAL_MS,
180
+ type StrandRevocationEnforcementConfig,
181
+ type StrandRevocationEnforcerDeps,
182
+ type StrandRevocationRows,
183
+ type StrandMemberPeerBinding,
184
+ type StrandRevocationJudge,
185
+ type StrandRevocationNetwork,
186
+ type RevocationRefreshScheduler
187
+ } from './strand-revocation-enforcer.js';
188
+
189
+ // Closed-strand membership reconciliation: the bring-up loop that finishes a
190
+ // party's join on every machine — redeem the staged formation invitation, then
191
+ // write the durable machine→party MemberPeer binding — without ever blocking
192
+ // bring-up (see the module doc for the pass ladder and terminal states).
193
+ export {
194
+ StrandMembershipReconciler,
195
+ IDLE_PASSES_BEFORE_ESCALATION,
196
+ INITIAL_JOIN_RETRY_INTERVAL_MS,
197
+ type StrandMembershipReconcilerDeps,
198
+ type StrandMembershipReconciliationConfig,
199
+ type PendingMembershipInviteSource
200
+ } from './strand-membership-reconciler.js';
201
+
202
+ // The timer seam the self-rescheduling strand bring-up loops share (first-sync gate
203
+ // probe loop, membership retry ladder) — injected by tests as a hand-cranked clock.
204
+ export { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
126
205
 
127
206
  // Hibernation
128
207
  export {
@@ -187,8 +266,11 @@ export {
187
266
  signStrandApproval,
188
267
  generateStrandStampId,
189
268
  bootstrapFounderMembership,
269
+ PreSplitStrandIdentityError,
190
270
  issueInvite,
191
271
  consumeInvite,
272
+ burnInvite,
273
+ isStrandMember,
192
274
  cancelInvite,
193
275
  listOutstandingInvites,
194
276
  addMemberByManager,
@@ -202,6 +284,7 @@ export {
202
284
  removeManager,
203
285
  sealStrand,
204
286
  isStrandSealed,
287
+ StrandTransactionBusyError,
205
288
  STRAND_ENGINE,
206
289
  STRAND_ENGINE_VERSION,
207
290
  type FounderBootstrapParams,
@@ -220,7 +303,8 @@ export {
220
303
  type AddManagerParams,
221
304
  type AdmitManagerParams,
222
305
  type RemoveManagerParams,
223
- type SealStrandParams
306
+ type SealStrandParams,
307
+ type StrandWriteOptions
224
308
  } from './strand-membership-writer.js';
225
309
 
226
310
  // Engine-canonical datetime helper (shared by control + strand signed-write flows
@@ -334,12 +418,25 @@ export {
334
418
  // the cadence/degree defaults the proactive reconcile routine reads.
335
419
  export {
336
420
  selectControlCohortDials,
337
- DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
338
421
  DEFAULT_CONTROL_COHORT_RECONCILE_MS,
339
422
  DEFAULT_CONTROL_COHORT_TARGET_DEGREE,
340
- type ControlCohortSelection
423
+ type ControlCohortSelection,
424
+ type ControlCohortReconcileResult
341
425
  } from './control-cohort.js';
342
426
 
427
+ // Dialing one peer from several candidate addresses, each under its own time
428
+ // limit, so an address that never answers cannot starve the rest.
429
+ export {
430
+ dialPeerAddrs,
431
+ tryAddrsInTurn,
432
+ directBeforeRelayed,
433
+ DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
434
+ DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
435
+ DEFAULT_PEER_DIAL_BUDGET,
436
+ type PeerDialBudget,
437
+ type AddrDialer
438
+ } from './peer-dial.js';
439
+
343
440
  // Seed trust policy (trust anchor for incoming seeds)
344
441
  export {
345
442
  anchoredTrustPolicy,
@@ -363,6 +460,8 @@ export {
363
460
  FormationListener,
364
461
  dialFormation,
365
462
  isValidResponderCreatesResult,
463
+ isWellFormedMembershipInvite,
464
+ sanitizeStrandAddrs,
366
465
  FORMATION_PROTOCOL,
367
466
  type FormationParty,
368
467
  type FormationContactMessage,
@@ -372,6 +471,7 @@ export {
372
471
  type FormationDbConnectionInfo,
373
472
  type FormationListenerOptions,
374
473
  type FormationDialOptions,
474
+ type FormationDialResult,
375
475
  type ResponderProvisionOutcome
376
476
  } from './strand-formation-protocol.js';
377
477
 
@@ -379,6 +479,9 @@ export {
379
479
  export {
380
480
  StrandFormationManager,
381
481
  createStrandFormationManager,
482
+ MEMBERSHIP_INVITE_TTL_MS,
483
+ MEMBERSHIP_INVITE_UNAVAILABLE_REASON,
484
+ HOST_STRAND_MUST_BE_RECREATED_REASON,
382
485
  type StrandFormationManagerConfig,
383
486
  type StrandFormationManagerOptions
384
487
  } from './strand-formation-manager.js';
@@ -400,7 +503,23 @@ export {
400
503
  // Thrown out of `CadreNode.start()` when a `network.relayAddrs` reservation does
401
504
  // not land on its first attempt — the fail-fast posture of that config field now
402
505
  // that the control node reserves AFTER control-database bring-up (relay-addrs.ts).
403
- export { RelayReservationFailedError, RELAY_SEARCH_LISTEN_ADDR } from './relay-addrs.js';
506
+ // `UnbindableListenAddressError` is the same posture for the OTHER half of the listen
507
+ // config: a `network.listenAddrs` entry whose transport the node will not have refuses
508
+ // start instead of being silently dropped by libp2p's transport manager.
509
+ //
510
+ // `resolveListenAddrs` and `strandNodeAddrs` are the derivations themselves — what a
511
+ // `NetworkConfig` actually turns into for the control node and for each strand node.
512
+ // Exported so an embedder that hand-writes a `network` block (the React Native and web
513
+ // reference apps) can assert on the resulting SHAPE rather than on its own field names,
514
+ // which is the difference between a test that proves reachability and one that proves a
515
+ // spelling.
516
+ export {
517
+ RelayReservationFailedError,
518
+ UnbindableListenAddressError,
519
+ RELAY_SEARCH_LISTEN_ADDR,
520
+ resolveListenAddrs
521
+ } from './relay-addrs.js';
522
+ export { strandNodeAddrs, type StrandNodeAddrs } from './strand-network-config.js';
404
523
 
405
524
  // Relay reservation via the bare `/p2p-circuit` search listener — the one route
406
525
  // every control node takes; `network.relayAddrs` is its fail-fast posture
@@ -367,7 +367,11 @@ export class UnauthorizedReservationBudget {
367
367
  * dialer sees its "open" connection close moments later.
368
368
  *
369
369
  * Control node only: strand cohort nodes legitimately connect cross-party
370
- * peers, so `CadreNode` threads the raw configured gater to them unchanged.
370
+ * peers, so cadre membership never gates them. An OPEN strand's node receives
371
+ * the raw configured gater unchanged; a CLOSED strand's node composes its own
372
+ * revoked-peer denial onto it instead — a deny-list on positive revocation
373
+ * evidence, not this module's membership admission — see
374
+ * `strand-revocation-enforcer.ts`.
371
375
  */
372
376
  export function createMembershipConnectionGater(
373
377
  policy: InboundAdmissionPolicy,
@@ -440,9 +444,11 @@ export function createMembershipConnectionGater(
440
444
  * fail-open outcome) if it has not settled in time — see
441
445
  * {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
442
446
  * on the connection hook. The timer is always cleared so a decided call never
443
- * holds the event loop open.
447
+ * holds the event loop open. Exported for the strand revoked-peer gate
448
+ * (`strand-revocation-enforcer.ts`), which bounds its connection hooks with the
449
+ * same fail-open contract.
444
450
  */
445
- async function decideWithinDeadline<T>(
451
+ export async function decideWithinDeadline<T>(
446
452
  decide: () => Promise<T> | T,
447
453
  fallback: T,
448
454
  timeoutMs: number,