@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
package/dist/types.d.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
2
  import type { IRawStorage, Libp2pTransports } from '@optimystic/db-p2p';
3
3
  import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
- import type { StrandBackfillConfig } from './strand-backfill.js';
4
+ import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
5
+ import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
6
+ import type { StrandMembershipReconciliationConfig } from './strand-membership-reconciler.js';
7
+ import type { StrandFirstSyncConfig } from './strand-first-sync-gate.js';
5
8
  import type { StrandDatabase } from './strand-database.js';
6
9
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
7
10
  import type { KeyStore, KeyId } from './key-store.js';
8
11
  import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
9
12
  import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
13
+ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
10
14
  import type { PushNotifier } from './push-notifier.js';
11
15
  import type { RevocableTable } from './control-authorization.js';
16
+ import type { ControlRetryAbandonment } from './control-retry.js';
12
17
  /**
13
18
  * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
14
19
  * createLibp2pNode after node creation (not surfaced on the base Libp2p type).
@@ -20,7 +25,7 @@ export interface Libp2pNodeWithRepo extends Libp2p {
20
25
  * `coordinatedRepo` (`libp2p-node-base.ts`). Optional deliberately: the base
21
26
  * factory always assigns it, but it is not part of the upstream node type, so
22
27
  * declaring it required would be a claim this repo cannot enforce. The strand
23
- * backfill (`strand-backfill.ts`) logs once and stays inert when it is absent.
28
+ * backfill (`peer-join-backfill.ts`) logs once and stays inert when it is absent.
24
29
  */
25
30
  keyNetwork?: IPeerNetwork;
26
31
  }
@@ -83,12 +88,27 @@ export interface HibernationTimeouts {
83
88
  export declare const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts>;
84
89
  /**
85
90
  * Storage provider - either an `IRawStorage` instance or a factory function.
86
- * Factory functions are useful for creating strand-specific storage instances.
91
+ * Factory functions are useful for creating per-scope storage instances.
87
92
  *
88
- * **Called once per scope per runtime lifetime.** The scopes are the literal string
89
- * `'control'` (once per `CadreNode.start()`) and each strand id (once per
90
- * `startStrand`). Hibernation — `quiesceStrand` then `resumeStrand` — reuses the
91
- * store already resolved for that strand and does NOT re-enter this callback.
93
+ * **The argument is an opaque scope key.** Use it directly as a file name, directory
94
+ * name or database name: every key cadre-core mints stays within `[A-Za-z0-9._-]`, so
95
+ * no escaping is needed and none should be assumed. Do not parse it;
96
+ * `controlStorageScope` / `isControlStorageScope` (`storage-scope.ts`) are the
97
+ * supported way to mint and recognize the control key. The strand arm of that charset
98
+ * rule is intended but not yet checked — a strand id replicated in from another node
99
+ * in the party reaches this callback verbatim; see
100
+ * `tickets/backlog/bug-strand-scope-key-charset-unenforced`.
101
+ *
102
+ * **Called once per scope per runtime lifetime.** The scopes are the control database
103
+ * (once per `CadreNode.start()`) and each strand id (once per `startStrand`).
104
+ * Hibernation — `quiesceStrand` then `resumeStrand` — reuses the store already
105
+ * resolved for that strand and does NOT re-enter this callback.
106
+ *
107
+ * **The control scope is party-specific.** It is `controlStorageScope(partyId)`, not a
108
+ * fixed string, because the control database holds one party's own records — its
109
+ * strands, owner keys, peers, invitations, revocations. Two parties on one device ask
110
+ * for two different keys and MUST get two different stores, or a node started for one
111
+ * party reads the other's rows as its own.
92
112
  *
93
113
  * It IS re-entered for a scope after that scope's runtime has stopped
94
114
  * (`stopStrand`, or a `stop()` then `start()` cycle on one `CadreNode`), so it must
@@ -98,33 +118,40 @@ export declare const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeou
98
118
  * cadre-core disposes only its own cache wrapper — it never closes the store you
99
119
  * returned. Closing the underlying handle stays the embedder's job.
100
120
  *
101
- * Either form is safe with a persistent backend. One instance handed to every scope
121
+ * **The single-instance form shares data across scopes by construction** — one store
122
+ * for every strand AND for every party's control database. An embedder that can serve
123
+ * more than one party must use the factory form. One instance handed to every scope
102
124
  * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
103
125
  * which counts its holders: a scope stopping releases only its own claim, and the
104
- * wrapper is retired when the last scope releases it. The factory form is still the
105
- * better default for a different reason — it partitions each strand's data, which
126
+ * wrapper is retired when the last scope releases it. The factory form is the better
127
+ * default for the further reason that it partitions each strand's data, which
106
128
  * simplifies cleanup.
107
129
  */
108
- export type RawStorageProvider = IRawStorage | ((strandId: string) => IRawStorage);
130
+ export type RawStorageProvider = IRawStorage | ((scope: string) => IRawStorage);
109
131
  /**
110
132
  * Storage configuration for storage profile nodes
111
133
  */
112
134
  export interface StorageConfig {
113
135
  /**
114
136
  * Storage provider - either an IRawStorage instance or a factory function.
115
- * See {@link RawStorageProvider} for how often cadre-core calls it.
137
+ * See {@link RawStorageProvider} for how often cadre-core calls it, and for why
138
+ * the single-instance form cannot serve more than one party.
139
+ *
140
+ * The `scope` a factory receives is an opaque key already safe as a path or
141
+ * database-name segment (always within `[A-Za-z0-9._-]`), so each example below
142
+ * interpolates it directly.
116
143
  *
117
144
  * For Node.js environments, use FileRawStorage from @optimystic/db-p2p-storage-fs:
118
145
  * ```typescript
119
146
  * import { FileRawStorage } from '@optimystic/db-p2p-storage-fs';
120
- * storage: { provider: (strandId) => new FileRawStorage(`./data/${strandId}`) }
147
+ * storage: { provider: (scope) => new FileRawStorage(`./data/${scope}`) }
121
148
  * ```
122
149
  *
123
150
  * For React Native, use the appropriate storage from @optimystic/db-p2p-storage-rn:
124
151
  * ```typescript
125
152
  * import { LevelDBRawStorage } from '@optimystic/db-p2p-storage-rn';
126
153
  * // `db` is an open LevelDB handle (see `openOptimysticRNDb` over `rn-leveldb`)
127
- * storage: { provider: (strandId) => new LevelDBRawStorage(openDb(`sereus-${strandId}`)) }
154
+ * storage: { provider: (scope) => new LevelDBRawStorage(openDb(`sereus-${scope}`)) }
128
155
  * ```
129
156
  *
130
157
  * For in-memory storage (testing):
@@ -140,13 +167,36 @@ export interface StorageConfig {
140
167
  * Network configuration for libp2p
141
168
  */
142
169
  export interface NetworkConfig {
170
+ /**
171
+ * Addresses this machine's libp2p nodes bind. Omitted, `@optimystic/db-p2p`'s own
172
+ * default (`/ip4/0.0.0.0/tcp/0`) applies; `[]` means "cannot listen" and is honored
173
+ * as written (React Native hosts rely on that).
174
+ *
175
+ * The machine runs one control node plus one node per strand, so a FIXED port here
176
+ * can only belong to one of them. The control node binds it as configured; each
177
+ * strand node binds the same entry with its port rewritten to `0`, keeping the
178
+ * interface and transport (`/ws`, a specific-interface bind). A
179
+ * `<relay>/p2p-circuit` entry is untouched — the port inside it is the relay's, not
180
+ * a local bind. See `strand-network-config.ts`.
181
+ *
182
+ * **Only TCP, WebSocket (`/ws`, `/wss`) and circuit-relay addresses are bindable**
183
+ * unless {@link transports} supplies the factories for something else. A `/ws` entry
184
+ * switches the WebSocket transport on by itself; anything else — `/quic-v1`,
185
+ * `/webrtc`, `/webtransport` — refuses node start naming the address and the libp2p
186
+ * package it would need, rather than being dropped in silence by libp2p's transport
187
+ * manager. See `relay-addrs.ts` → `resolveTransportOptions`.
188
+ */
143
189
  listenAddrs?: string[];
144
190
  /**
145
191
  * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
146
192
  * NAT or a reverse proxy that is reachable at a different address than it binds
147
193
  * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
148
- * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`,
149
- * on this node's control node and on every strand node it runs.
194
+ * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`.
195
+ *
196
+ * **Applies to this machine's CONTROL node only.** Strand nodes drop it: any concrete
197
+ * entry names a port, and that port is the control node's, so a strand node
198
+ * advertising it would send peers to the wrong node (`strand-network-config.ts`).
199
+ * Entries are still validated at node start whichever node ends up using them.
150
200
  *
151
201
  * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
152
202
  * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
@@ -177,6 +227,8 @@ export interface NetworkConfig {
177
227
  * advertise. Reaches libp2p's `addresses.appendAnnounce` via
178
228
  * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
179
229
  *
230
+ * **Control node only**, on the same terms as {@link announceAddrs}.
231
+ *
180
232
  * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
181
233
  * precedence, applied upstream; this repo does not merge the two locally. Set one
182
234
  * or the other, not both.
@@ -197,8 +249,9 @@ export interface NetworkConfig {
197
249
  * because a listener that dials its relay from inside `libp2p.start()` put a
198
250
  * sibling in this node's cohort before its own database existed, and a sibling
199
251
  * that had not yet replicated this node's membership row refused the bring-up.
200
- * STRAND nodes inherit the older per-relay `<relay>/p2p-circuit` listener, which
201
- * has no such ordering hazard. A relay named here also becomes a
252
+ * STRAND nodes take the same search shape, one bare listener plus one reservation
253
+ * supervisor PER relay, fail-soft (`strand-network-config.ts`,
254
+ * `strand-instance-manager.ts`). A relay named here also becomes a
202
255
  * delegate-announce target, so this node's strand nodes may reserve on it too
203
256
  * (see `delegate-admission.ts`).
204
257
  *
@@ -208,11 +261,46 @@ export interface NetworkConfig {
208
261
  * must not acquire a listener by accident.
209
262
  *
210
263
  * FAIL-FAST both ways: a malformed entry throws at node start rather than being
211
- * silently dropped, and a first reservation attempt that lands no
212
- * `/p2p-circuit` address throws `RelayReservationFailedError` out of `start()`.
213
- * The fail-soft posture over the same machinery is `CadreNode.reserveRelays()`.
264
+ * silently dropped, and — while {@link requireRelay} stays at its default —
265
+ * a first reservation attempt that lands no `/p2p-circuit` address throws
266
+ * `RelayReservationFailedError` out of `start()`. The fail-soft posture over
267
+ * the same machinery is `CadreNode.reserveRelays()`.
214
268
  */
215
269
  relayAddrs?: string[];
270
+ /**
271
+ * Must a relay named in {@link relayAddrs} have granted a reservation before
272
+ * `start()` is allowed to succeed? Default `true`: an operator who names a
273
+ * relay is telling this machine it has no other reachability, so a relay
274
+ * that will not have us on the first attempt should stop the boot loudly
275
+ * rather than leave a node nobody can reach.
276
+ *
277
+ * Set `false` for a node that must still boot with no network — a phone or a
278
+ * browser tab. The first attempt is still driven at the same point in
279
+ * `start()`; a lost reservation just logs instead of throwing, and the
280
+ * retry supervisor keeps trying in the background exactly as it does after a
281
+ * `CadreNode.reserveRelays()` call. A caller on this posture cannot infer
282
+ * dialability from `start()` resolving — read
283
+ * {@link CadreNode.getRelayReservationState} to find out.
284
+ *
285
+ * It buys a node that BOOTS, not a node that boots fast: `start()` still waits
286
+ * out that first attempt, which costs the drive's whole timeout
287
+ * (`DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s) against a relay that is unreachable
288
+ * rather than merely refusing (`relay-reservation.ts` polls to the deadline, in
289
+ * case libp2p's own discovery lands a reservation independently).
290
+ *
291
+ * Softens only the RESERVATION half of {@link relayAddrs}'s fail-fast
292
+ * contract. A malformed `relayAddrs` entry still throws at config
293
+ * resolution regardless of this setting — a typo is an operator error
294
+ * whatever the posture — and a hand-written `<relay>/p2p-circuit` entry in
295
+ * {@link listenAddrs} is still rejected on the control node, since that
296
+ * rejection is about the listener shape, not about whether reachability is
297
+ * required.
298
+ *
299
+ * Has no effect on STRAND nodes, which are already fail-soft over
300
+ * `relayAddrs` regardless of this field (`strand-network-config.ts`,
301
+ * `strand-instance-manager.ts`).
302
+ */
303
+ requireRelay?: boolean;
216
304
  /**
217
305
  * Enable circuit relay server - allows this node to relay connections for other peers.
218
306
  * When undefined, defaults to true for storage profile nodes (they typically have
@@ -270,8 +358,14 @@ export interface NetworkConfig {
270
358
  * On the CONTROL node this gater is composed with the built-in membership
271
359
  * admission gate (`membership-connection-gater.ts`): every hook supplied here
272
360
  * is honored unchanged, and on inbound encrypted connections a deny from
273
- * either this gater or the membership policy denies. Strand cohort nodes
274
- * receive this gater as-is (their peers are legitimately cross-party).
361
+ * either this gater or the membership policy denies.
362
+ *
363
+ * Strand cohort nodes: an OPEN strand's node receives this gater as-is (its
364
+ * peers are legitimately cross-party). A CLOSED strand's node composes
365
+ * revoked-peer denial onto it (`strand-revocation-enforcer.ts`) — every hook
366
+ * supplied here is still honored unchanged, and on the composed hooks
367
+ * (inbound encrypted connection, outbound peer dial, relay reservation) a
368
+ * deny from either this gater or the revocation check denies.
275
369
  */
276
370
  connectionGater?: ConnectionGater;
277
371
  /**
@@ -281,7 +375,8 @@ export interface NetworkConfig {
281
375
  * Every field is optional; omit for the defaults
282
376
  * ({@link DEFAULT_CONTROL_COHORT_TARGET_DEGREE} /
283
377
  * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS} /
284
- * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} / `STRAND_PEER_ADDR_REFRESH_MS`).
378
+ * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} /
379
+ * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS} / `STRAND_PEER_ADDR_REFRESH_MS`).
285
380
  */
286
381
  controlCohort?: {
287
382
  /**
@@ -296,12 +391,20 @@ export interface NetworkConfig {
296
391
  */
297
392
  reconcileMs?: number;
298
393
  /**
299
- * Budget for ONE sibling's proactive dial, in ms. Bounds a pass at
300
- * (dialed siblings) × this, instead of leaving it to each address's own
301
- * libp2p attempt timeout multiplied by address fan-out and relay hops.
302
- * Defaults to {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}.
394
+ * Limit on dialing ONE peer — all of its candidate addresses together — in
395
+ * ms. Bounds a reconcile pass at (dialed siblings) × this. The same limit
396
+ * applies to the owner dials of `applySeed` and to `dialInvite`. Defaults to
397
+ * {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS}.
303
398
  */
304
399
  dialTimeoutMs?: number;
400
+ /**
401
+ * Limit on ONE candidate address's dial attempt, in ms, inside
402
+ * {@link dialTimeoutMs}. Each address is dialed on its own under this limit,
403
+ * so an address that never answers cannot use up the time the peer's other
404
+ * addresses needed. Defaults to
405
+ * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}.
406
+ */
407
+ perAddressDialTimeoutMs?: number;
305
408
  /**
306
409
  * How often each running strand re-resolves its siblings' strand-network
307
410
  * addresses into its own libp2p address book — a step of the reconcile pass,
@@ -352,8 +455,14 @@ export interface ControlNetworkConfig {
352
455
  * by omission); the strand breadth defaults to
353
456
  * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
354
457
  * {@link STRAND_CLUSTER_POLICY}.
458
+ *
459
+ * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
460
+ * corroboration yardstick declared from the machines enrolled in this party
461
+ * ({@link resolveRepairYardstick}); handed no count, each returns its frozen base
462
+ * constant unchanged. A network picks the derived number up when its libp2p node is
463
+ * built, which for a strand is every wake from hibernation.
355
464
  */
356
- export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize } from '@serfab/quereus-plugin-sereus';
465
+ export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize, resolveRepairYardstick, controlClusterPolicy, strandClusterPolicy } from '@serfab/quereus-plugin-sereus';
357
466
  /**
358
467
  * Main configuration for a CadreNode
359
468
  */
@@ -414,17 +523,72 @@ export interface CadreNodeConfig {
414
523
  */
415
524
  strandClusterSize?: number;
416
525
  /**
417
- * Tuning for the strand peer-join block catch-up (see `strand-backfill.ts`):
526
+ * Tuning for the strand peer-join block catch-up (see `peer-join-backfill.ts`):
418
527
  * when a strand's libp2p node connects to a peer this runtime has not yet
419
528
  * caught up, every block in the strand's own raw store is pushed to that peer,
420
529
  * so a machine that joined the strand after blocks were committed still ends
421
- * up physically holding them. Applies to every strand this node starts;
422
- * strand-only — the control network has its own row-level re-issue queue
423
- * (`drainPendingControlReplication`). Omit for the defaults
424
- * (`DEFAULT_STRAND_BACKFILL`); `{ enabled: false }` restores the
425
- * no-backfill behaviour.
530
+ * up physically holding them. Applies to every strand this node starts.
531
+ * Omit for the defaults (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }`
532
+ * restores the no-backfill behaviour. The control network runs its own,
533
+ * separately tuned catch-up — see {@link controlBackfill}.
534
+ */
535
+ strandBackfill?: PeerJoinBackfillConfig;
536
+ /**
537
+ * Tuning for the CONTROL network's peer-join block catch-up (same module,
538
+ * `peer-join-backfill.ts`, wired in `CadreNode.start`): when the control
539
+ * libp2p node connects to an AUTHORIZED party member this runtime has not yet
540
+ * caught up, every block in the control database's own raw store is pushed to
541
+ * it. This is what makes a control block committed while the writer was alone
542
+ * (collection headers written at genesis above all) physically reach members
543
+ * that joined later — without it, such a member that restarts offline reads
544
+ * the affected tables as empty. Pushes are gated on
545
+ * `CadreNode.isAuthorizedMember`, checked at push time, because the control
546
+ * network's inbound gate deliberately admits non-members in several states
547
+ * (seed delivery, enrollment, bootstrap/relay peers). Omit for the defaults
548
+ * (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }` disables it.
426
549
  */
427
- strandBackfill?: StrandBackfillConfig;
550
+ controlBackfill?: PeerJoinBackfillConfig;
551
+ /**
552
+ * Tuning for the CLOSED-strand revoked-peer gate
553
+ * (`strand-revocation-enforcer.ts`), applied to every closed strand this node
554
+ * starts: each strand node materializes the peer ids bound to REMOVED members
555
+ * (orphaned `Strand.MemberPeer` rows) and refuses them at the stream and
556
+ * connection layers, so removing a party cuts the network to its machines
557
+ * rather than only deleting its row. Open strands never arm it. Omit for the
558
+ * defaults (`DEFAULT_REVOCATION_POLL_INTERVAL_MS` refresh cadence);
559
+ * `{ enabled: false }` restores the pre-existing behaviour.
560
+ */
561
+ strandRevocationEnforcement?: StrandRevocationEnforcementConfig;
562
+ /**
563
+ * Tuning for the CLOSED-strand membership reconciler
564
+ * (`strand-membership-reconciler.ts`), applied to every closed strand this
565
+ * node launches with a party identity key: at bring-up each machine redeems a
566
+ * pending formation invitation (seating the party's `Strand.Member` row) and
567
+ * writes its own machine→party `Strand.MemberPeer` binding, retrying in the
568
+ * background without blocking bring-up. `pollIntervalMs` is the IDLE cadence
569
+ * (how often a machine nobody has admitted re-checks) and the cap of the
570
+ * unfinished-join retry ladder, NOT the retry cadence itself — an unfinished
571
+ * join retries from 1 s, doubling. Omit for the defaults (it mirrors
572
+ * {@link strandRevocationEnforcement}'s `pollIntervalMs`);
573
+ * `{ enabled: false }` disarms the loop — for test fixtures that hand-drive
574
+ * the membership writers and assert exact row sets.
575
+ */
576
+ strandMembershipReconciliation?: StrandMembershipReconciliationConfig;
577
+ /**
578
+ * Tuning for the JOINING machine's first-sync write gate
579
+ * (`strand-first-sync-gate.ts`), applied to every strand this node launches as a
580
+ * non-founder: the strand's database is withheld from the app (`StrandInstance.database`
581
+ * unset, status `'syncing'`) until the strand's `Strand.Header` has been received from
582
+ * another member and every `App` table has been read once, because a machine that writes
583
+ * to a table it has never fetched creates a private copy of it that never merges.
584
+ * `timeoutMs` bounds how long {@link CadreNode.addStrand} waits before rejecting with
585
+ * `StrandAwaitingFirstSyncError` (retryable — the launch stays up and keeps probing);
586
+ * `pollIntervalMs` is the probe cadence. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`, 30 s;
587
+ * `DEFAULT_STRAND_FIRST_SYNC_POLL_MS`, 500 ms). There is deliberately no way to disable
588
+ * the gate: a machine that already holds the Header is never gated, so nothing that works
589
+ * today is blocked by it.
590
+ */
591
+ strandFirstSync?: StrandFirstSyncConfig;
428
592
  /** Hibernation configuration */
429
593
  hibernation?: HibernationConfig;
430
594
  /** Polling interval for strand watcher in ms (default: 5000) */
@@ -508,6 +672,39 @@ export interface CadreNodeConfig {
508
672
  */
509
673
  store?: BootstrapPeerStore;
510
674
  };
675
+ /**
676
+ * Node-local record of how many machines this party had enrolled the last time
677
+ * this node looked (see `enrolled-machine-store.ts`): the block-repair
678
+ * corroboration yardstick the CONTROL node declares when its libp2p node is
679
+ * built. Sibling of {@link trustedOwners} / {@link bootstrapPeers} — same
680
+ * NON-replicated, per-party, injected-backend shape — and, like
681
+ * {@link bootstrapPeers}, nothing here is trust-bearing: it is a repair hint,
682
+ * recomputed from `CadrePeer` rows the moment the control database is up.
683
+ *
684
+ * It exists only because the control node is created BEFORE that database, so
685
+ * the live count is unreadable at the one moment it is needed; remembering it
686
+ * across the restart is what breaks that deadlock. Absent ⇒ an in-memory store
687
+ * is created at start(), which cold-starts on every launch and therefore
688
+ * declares nothing — byte-for-byte the behaviour before this record existed.
689
+ * No embedder is forced to change.
690
+ *
691
+ * A party that grows applies the larger number on each node's NEXT launch,
692
+ * deliberately: Optimystic freezes the policy at node construction and offers
693
+ * no runtime setter, and a node still holding the old value runs at exactly
694
+ * today's behaviour, so there is no forced rebuild.
695
+ *
696
+ * A new platform needs no new store class: supply a `DurableSlot` for the
697
+ * platform's storage and inject `PersistentEnrolledMachineStore.open(slot, partyId)`.
698
+ */
699
+ enrolledMachines?: {
700
+ /**
701
+ * Injected store instance — e.g. a `FileEnrolledMachineStore` from the
702
+ * Node-only subpath `@serfab/cadre-core/enrolled-machine-store-file`,
703
+ * persisted in the node's state directory. Its `partyId` must match
704
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
705
+ */
706
+ store?: EnrolledMachineStore;
707
+ };
511
708
  /**
512
709
  * Platform push-delivery for suspended mobile peers. When present, the node's
513
710
  * server fan-out can deliver strand-wake data messages over the platform push
@@ -537,9 +734,16 @@ export interface CadreNodeConfig {
537
734
  };
538
735
  }
539
736
  /**
540
- * Status of a strand instance
737
+ * Status of a strand instance.
738
+ *
739
+ * `'syncing'` is a JOINING machine whose runtime is up (strand libp2p node, background
740
+ * loops) but whose database is held back from the app until the strand's `Strand.Header`
741
+ * has been received from another member — see `strand-first-sync-gate.ts`. In that state
742
+ * `StrandInstance.database` is unset; the instance goes `'active'` (and `CadreNode` emits
743
+ * `strand:writable`) the moment the Header is readable. A founder, or a machine that has
744
+ * synced this strand before (restart, hibernation resume), never passes through it.
541
745
  */
542
- export type StrandStatus = 'starting' | 'active' | 'idle' | 'hibernating' | 'stopping' | 'stopped' | 'error';
746
+ export type StrandStatus = 'starting' | 'syncing' | 'active' | 'idle' | 'hibernating' | 'stopping' | 'stopped' | 'error';
543
747
  /**
544
748
  * App information from strand header
545
749
  */
@@ -556,9 +760,15 @@ export interface StrandInstance {
556
760
  strandId: string;
557
761
  status: StrandStatus;
558
762
  sAppInfo?: SAppInfo;
559
- /** The libp2p node for this strand (only when active/idle) */
763
+ /** The libp2p node for this strand (only when live: syncing/active/idle) */
560
764
  libp2pNode?: Libp2p;
561
- /** The Quereus database for this strand (only when active/idle) */
765
+ /**
766
+ * The Quereus database for this strand — set only while the strand is live AND
767
+ * this machine may write to it. A joining machine that has not yet received the
768
+ * strand's `Strand.Header` from another member runs with `libp2pNode` set and this
769
+ * unset (status `'syncing'`); it is published when the Header arrives
770
+ * (`strand:writable`). Unset while hibernating.
771
+ */
562
772
  database?: StrandDatabase;
563
773
  /** Membership info for closed strands */
564
774
  memberKey?: string;
@@ -600,6 +810,17 @@ export interface StrandRow {
600
810
  Id: string;
601
811
  MemberPrivateKey: string | null;
602
812
  Type: 'o' | 'c';
813
+ /**
814
+ * ed25519 (base64url) owner key of the MACHINE that published this row — the
815
+ * signer of the owner-signed `Strand` insert (`== context.OwnerKey`, pinned by
816
+ * the schema's `AuthorizedInsert`). Identifies the founding machine, so a
817
+ * launch with no explicit `founder` flag derives "am I the founder?" by
818
+ * comparing it to the node's own owner key (see `CadreNode.launchStrand`).
819
+ * `null` on a consent-seated strand (the unsigned `FormationUsage` branch
820
+ * records no trustworthy signer). Provenance, not content —
821
+ * `strandRowMismatches` excludes it from the identical-content comparison.
822
+ */
823
+ FounderOwnerKey: string | null;
603
824
  }
604
825
  /**
605
826
  * sApp configuration provided by the hosting application when creating a strand.
@@ -637,10 +858,93 @@ export interface StrandConfig {
637
858
  * paths; the same party that calls {@link CadreNode.publishStrand}). The founder
638
859
  * runs the one-time membership bootstrap at bring-up (writes `Strand.Header`, and
639
860
  * for a closed strand the founding `Member`+`Manager`). A joiner leaves this
640
- * `false`/unset and writes nothing — it receives those rows via Optimystic sync.
641
- * Defaults to `false`.
861
+ * unset and writes nothing — it receives those rows via Optimystic sync.
862
+ *
863
+ * When unset, founder-ness is DERIVED from the row: this node founds iff
864
+ * {@link StrandRow.FounderOwnerKey} equals its own owner key. An explicit
865
+ * `true`/`false` wins over the derivation — the formation/responder flows pass
866
+ * it deliberately, since a consent-seated row carries a null column.
642
867
  */
643
868
  founder?: boolean;
869
+ /**
870
+ * THIS party's own strand membership private key (base64 protobuf, as
871
+ * `generateStrandMemberKey` mints) for a CLOSED strand — the identity whose public
872
+ * key seats the founding `Member`/`Manager`. The explicit sibling of
873
+ * {@link founder}: normally the launch reads it from the party's control-layer
874
+ * `StrandPartyKey` row (minting one on the founding machine when absent), and an
875
+ * explicit value here WINS over that read — for callers whose row carries no
876
+ * founder provenance to heal against (an explicit `founder: true` over a hand-built
877
+ * row, as the test harness does). Deliberately NOT
878
+ * {@link StrandRow.MemberPrivateKey}, the strand-wide read secret every joining
879
+ * party receives — that key derives nobody's identity.
880
+ */
881
+ partyMemberPrivateKey?: string;
882
+ /**
883
+ * Whether {@link CadreNode.addStrand} waits for a JOINING machine's first sync before it
884
+ * resolves. Default `true`: the returned instance is writable (`database` set, status
885
+ * `'active'`), or the call rejects with `StrandAwaitingFirstSyncError` after
886
+ * `CadreNodeConfig.strandFirstSync.timeoutMs` — retryable, the launch stays up. `false`
887
+ * returns as soon as the runtime is launched, possibly `'syncing'` with no `database`;
888
+ * the caller then wires its own peers (a hand-dialed test fixture) and awaits
889
+ * {@link CadreNode.whenStrandWritable} or the `strand:writable` event. A founder, or a
890
+ * machine that already holds the strand's `Strand.Header`, is never gated either way.
891
+ */
892
+ awaitFirstSync?: boolean;
893
+ }
894
+ /**
895
+ * What {@link CadreNode.foundStrand} needs to publish a strand's control row AND start it
896
+ * locally as its founder — the resumable one-call form of `publishStrand` + `addStrand`.
897
+ *
898
+ * No `strandRow`: the row is the OUTPUT of founding, either freshly published or read back
899
+ * from an interrupted earlier attempt. `StrandConfig` (the join/attach shape) takes one
900
+ * because there the row already exists and came from the control network.
901
+ */
902
+ export interface FoundStrandConfig {
903
+ /** Unique strand identifier. Trimmed; blank is rejected. */
904
+ strandId: string;
905
+ /** `'o'` for open (default) or `'c'` for closed. */
906
+ type?: 'o' | 'c';
907
+ /**
908
+ * Membership key gating a closed strand. Required in practice for `type: 'c'` (the
909
+ * founder bootstrap rejects a null key), and IGNORED when the strand is already
910
+ * published — the stored key wins, so read the resolved key back off the returned
911
+ * instance rather than trusting a freshly minted one.
912
+ */
913
+ memberPrivateKey?: string;
914
+ /** sApp configuration the hosting application provides, as for {@link StrandConfig}. */
915
+ sAppConfig: SAppConfig;
916
+ /**
917
+ * Forwarded to the attach ({@link StrandConfig.awaitFirstSync}). Matters only when the
918
+ * call ATTACHES rather than founds — the stored row was published by a sibling machine
919
+ * that won the founding race — in which case this machine is a joiner whose database
920
+ * is withheld until that sibling's `Strand.Header` reaches it; default `true` waits
921
+ * for that inside the call. A founding launch is never gated.
922
+ */
923
+ awaitFirstSync?: boolean;
924
+ }
925
+ /**
926
+ * What {@link CadreNode.foundStrand} hands back: the running instance PLUS the `Strand` row
927
+ * the strand actually runs under.
928
+ *
929
+ * The row is returned separately rather than left for the caller to read off the instance
930
+ * because on a resumed founding it is NOT the caller's input — a closed strand adopts the
931
+ * stored `MemberPrivateKey`, discarding a freshly minted one. A caller that must carry the
932
+ * membership key onward (to mint an invitation, say) reads it from HERE.
933
+ */
934
+ export interface FoundStrandResult {
935
+ /** The active local instance, as {@link CadreNode.addStrand} would have returned. */
936
+ instance: StrandInstance;
937
+ /** The live control-plane row: freshly published, or the one already there. */
938
+ strandRow: StrandRow;
939
+ /**
940
+ * Whether THIS machine actually founded (ran / will have run the one-time
941
+ * founder bootstrap). `false` when the resolved row was published by a
942
+ * DIFFERENT machine's owner key — e.g. a sibling won a concurrent founding
943
+ * race — in which case the call attached as a joiner instead, which is the
944
+ * correct outcome (two machines bootstrapping one strand on separate replicas
945
+ * is the double-`Header` hazard).
946
+ */
947
+ founded: boolean;
644
948
  }
645
949
  /**
646
950
  * Result of creating a new cadre peer
@@ -682,6 +986,26 @@ export interface OpenInvitation {
682
986
  /** Bootstrap addresses to contact the inviter's cadre */
683
987
  bootstrap: string[];
684
988
  }
989
+ /**
990
+ * A single-use strand membership invitation carried on a closed-strand formation
991
+ * result: the `Strand.Invite` keypair the responder (the host strand's founder party)
992
+ * issued for this redemption. The joiner spends it — `consumeInvite` on its own strand
993
+ * replica — to seat a `Strand.Member` row under its OWN party key, so joining no longer
994
+ * hands out the founder's identity. Structurally identical to
995
+ * `strand-membership-writer`'s `IssuedInvite`; declared here so the wire/type layer does
996
+ * not import the writer.
997
+ */
998
+ export interface StrandMembershipInvite {
999
+ /** The invite ed25519 PUBLIC key (base64url) — the `Strand.Invite.Key` row. */
1000
+ inviteKey: string;
1001
+ /**
1002
+ * The invite ed25519 PRIVATE seed (base64url). A single-use bearer credential:
1003
+ * whoever holds it can `consumeInvite` exactly once. Same sensitivity class and
1004
+ * handling as `memberPrivateKey` — delivered only inside the validated,
1005
+ * post-approval formation result, never written to either side's control DB.
1006
+ */
1007
+ invitePrivateKey: string;
1008
+ }
685
1009
  /**
686
1010
  * Result of forming a strand via open invitation
687
1011
  */
@@ -699,6 +1023,30 @@ export interface FormStrandResult {
699
1023
  * from {@link invitePrivateKey} (the initiator's own generated signing key).
700
1024
  */
701
1025
  memberPrivateKey?: string;
1026
+ /**
1027
+ * The joiner's own single-use membership invitation into the (closed, bound) host
1028
+ * strand — see {@link StrandMembershipInvite}. Present only when the responder's
1029
+ * host strand is closed and its runtime issued one; absent for open strands and the
1030
+ * responder-provisions (unbound) path. `CadreNode.formStrand` itself persists the
1031
+ * joiner's party key and caches this invitation for the strand bring-up to redeem,
1032
+ * so an embedding app normally never touches it.
1033
+ */
1034
+ membershipInvite?: StrandMembershipInvite;
1035
+ /**
1036
+ * The responder's live STRAND-network multiaddrs for the formed strand — the only
1037
+ * cross-party discovery seed there is, since the strand-addr RPC that resolves a
1038
+ * sibling's strand addresses answers own-party callers only.
1039
+ *
1040
+ * Always an array, frequently EMPTY: the responder discloses none when it holds no
1041
+ * live strand node for the strand it just provisioned (the responder-provisions path
1042
+ * mints a strand that has not launched yet). `CadreNode.formStrand` records these
1043
+ * itself, so an embedding app only needs them to seed a strand it launches OUTSIDE
1044
+ * that node.
1045
+ *
1046
+ * One-shot and in-memory: they are the responder's addresses at the moment of
1047
+ * formation and are never re-resolved — see `docs/strands.md`.
1048
+ */
1049
+ strandAddrs: string[];
702
1050
  }
703
1051
  /**
704
1052
  * Disclosure object provided during strand formation.
@@ -751,12 +1099,62 @@ export interface CadreNodeEvents {
751
1099
  'strand:waking': {
752
1100
  strandId: string;
753
1101
  };
1102
+ /**
1103
+ * Emitted when a strand that came up `'syncing'` — a joining machine whose database was
1104
+ * withheld until it received the strand's `Strand.Header` from another member — becomes
1105
+ * writable: `StrandInstance.database` is now set and the status is `'active'`. A strand
1106
+ * whose `strand:started` already reported `'active'` (a founder, or a machine that had
1107
+ * synced before) never emits this. The event form of what {@link CadreNode.addStrand}
1108
+ * awaits; an app that attaches without waiting (`awaitFirstSync: false`, or a strand the
1109
+ * watcher auto-launched) hangs its "waiting for the other member" screen on the pair.
1110
+ */
1111
+ 'strand:writable': {
1112
+ strandId: string;
1113
+ };
1114
+ /**
1115
+ * Emitted when this node discovers it is no longer a member of a CLOSED
1116
+ * strand — its party was removed by a manager, or it left. Detected by the
1117
+ * strand's revoked-peer gate (`strand-revocation-enforcer.ts`) seeing this
1118
+ * node's own peer id in the revoked set, so it is BEST-EFFORT: it fires only
1119
+ * if the removal replicated here before the rest of the strand cut us off,
1120
+ * and a node that was already offline may never see it at all.
1121
+ *
1122
+ * Nothing is stopped or torn down for you. Treat it as "this strand will no
1123
+ * longer sync": remaining members refuse this node's streams, dials, and
1124
+ * connections, so reads keep working against whatever is stored locally while
1125
+ * writes stop propagating. Stopping the strand (`stopStrand`) or deleting its
1126
+ * data is the app's decision. Emitted once per removal — re-armed if a manager
1127
+ * re-admits this party, so a second removal is reported again — and a
1128
+ * hibernation wake rebuilds the gate, which may re-emit for a strand still
1129
+ * revoked.
1130
+ */
1131
+ 'strand:revoked': {
1132
+ strandId: string;
1133
+ };
754
1134
  /**
755
1135
  * Emitted when the control network advertises a strand this node has no
756
- * registered `sAppConfig` for — i.e. a strand created by another member. The
757
- * hosting app decides whether to join it (register a config + `addStrand`,
758
- * e.g. via a chat `joinChatStrand` helper). Carries the full {@link StrandRow}
759
- * so the app can join without re-querying the control DB.
1136
+ * registered `sAppConfig` for — i.e. a strand created by another member, or
1137
+ * one this node ran in a previous session (sApp configs are in-memory only and
1138
+ * do not survive `stop()`). The hosting app decides whether to join it
1139
+ * (register a config + `addStrand`, e.g. via a chat `joinChatStrand` helper).
1140
+ * Carries the full {@link StrandRow} so the app can join without re-querying
1141
+ * the control DB.
1142
+ *
1143
+ * **Fired exactly ONCE per strand per session, and it can fire before your
1144
+ * listener is attached.** The strand watcher's first poll runs inside
1145
+ * `CadreNode.start()` (100 ms after the watcher starts), so every strand
1146
+ * already stored for this party is normally offered while the embedding app is
1147
+ * still inside its own `start()` await. The watcher then records the strand as
1148
+ * seen and no later poll re-offers it, so a listener attached a moment late
1149
+ * misses those strands for the life of the process.
1150
+ *
1151
+ * So an app that auto-joins discovered strands must **subscribe first, then
1152
+ * drain `CadreNode.getDiscoveredStrands()`** — the map of strands no local
1153
+ * config claims. In that order a strand discovered between the two steps is
1154
+ * handled twice rather than not at all, which is why the join handler needs to
1155
+ * be idempotent (guard on an in-flight set, not only on
1156
+ * `getStrands().has(id)` — the strand manager tracks an instance only once
1157
+ * `addStrand` has resolved).
760
1158
  */
761
1159
  'strand:discovered': {
762
1160
  strandId: string;
@@ -764,6 +1162,25 @@ export interface CadreNodeEvents {
764
1162
  };
765
1163
  'control:connected': void;
766
1164
  'control:disconnected': void;
1165
+ /**
1166
+ * Emitted when the control-write retry funnel GAVE UP on a local control write — the
1167
+ * classifier declined the failure as non-transient, or every attempt (or the elapsed
1168
+ * budget) ran out. One event per abandoned write, carrying the operation label, how far
1169
+ * it got, why it stopped and the error.
1170
+ *
1171
+ * Exists because an abandoned BACKGROUND write is otherwise invisible: the node's own
1172
+ * self-address republish, the post-connect drain and the replication drain all fire
1173
+ * their writes unawaited with a `debug`-only catch, and that namespace is off unless
1174
+ * something enabled it — so the write is lost and nobody is told. A FOREGROUND write's
1175
+ * caller sees the rethrown error as well as this event.
1176
+ *
1177
+ * What an app does with it is its own call; the node itself only escalates ONE case to
1178
+ * the operator (a self-address republish that has been failing longer than a peer
1179
+ * record stays fresh, at which point other machines are already discarding this node's
1180
+ * address). Treat the rest as telemetry — a control write that was lost, not a strand
1181
+ * or connection state change.
1182
+ */
1183
+ 'control:write-abandoned': ControlRetryAbandonment;
767
1184
  /** Emitted when a seed is received via the seed protocol */
768
1185
  'seed:received': {
769
1186
  partyId: string;
@@ -837,6 +1254,8 @@ export type CadrePeerVoucherFields = Pick<CadrePeerRow, 'peerId' | 'stampId' | '
837
1254
  * `ControlDatabase.queryRevocations`: the identity triple plus the `ReissuedAt`
838
1255
  * counter an owner bumps (`ControlDatabase.reissueRevocations`) to re-write — and
839
1256
  * therefore re-broadcast — a tombstone that committed while the node was alone.
1257
+ * Never the ledger marker: that row names no guarded table, and `queryRevocations`
1258
+ * skips it, which is what keeps `tableName` a {@link RevocableTable}.
840
1259
  */
841
1260
  export interface RevocationRow {
842
1261
  /** Which guarded table's stamp was retired. */
@@ -848,6 +1267,13 @@ export interface RevocationRow {
848
1267
  /** Monotonic re-issue counter; carries no semantics (see the schema comment). */
849
1268
  reissuedAt: number;
850
1269
  }
1270
+ /**
1271
+ * Outcome of `ControlDatabase.openRevocationLedger`: `'opened'` when this call filed the
1272
+ * singleton `Revocation` ledger marker, `'already-open'` when it was already filed (seen
1273
+ * by the in-lock guard, or filed first by another owner and refused on the primary key).
1274
+ * Either way the marker exists afterwards.
1275
+ */
1276
+ export type RevocationLedgerOpenResult = 'opened' | 'already-open';
851
1277
  /**
852
1278
  * Outcome of {@link CadreNode.registerSelf}, surfaced so callers (e.g. the CLI
853
1279
  * `--owner` branch) can log what happened: