@serfab/cadre-core 1.2.0 → 1.4.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 (46) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +32 -19
  3. package/dist/cadre-node.js +121 -31
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-formation-recorder.d.ts +4 -4
  6. package/dist/control-formation-recorder.js +6 -6
  7. package/dist/control-formation-recorder.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +5 -3
  10. package/dist/index.js.map +1 -1
  11. package/dist/membership-connection-gater.d.ts +18 -5
  12. package/dist/membership-connection-gater.js +50 -10
  13. package/dist/membership-connection-gater.js.map +1 -1
  14. package/dist/storage-scope.d.ts +50 -12
  15. package/dist/storage-scope.js +85 -12
  16. package/dist/storage-scope.js.map +1 -1
  17. package/dist/strand-formation-manager.js +2 -1
  18. package/dist/strand-formation-manager.js.map +1 -1
  19. package/dist/strand-id.d.ts +35 -0
  20. package/dist/strand-id.js +46 -0
  21. package/dist/strand-id.js.map +1 -0
  22. package/dist/strand-instance-manager.js +20 -2
  23. package/dist/strand-instance-manager.js.map +1 -1
  24. package/dist/strand-network-config.d.ts +3 -3
  25. package/dist/strand-network-config.js +3 -3
  26. package/dist/strand-solicitation.js +2 -1
  27. package/dist/strand-solicitation.js.map +1 -1
  28. package/dist/strand-watcher.d.ts +45 -0
  29. package/dist/strand-watcher.js +74 -13
  30. package/dist/strand-watcher.js.map +1 -1
  31. package/dist/types.d.ts +118 -15
  32. package/dist/types.js +59 -0
  33. package/dist/types.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/cadre-node.ts +127 -31
  36. package/src/control-formation-recorder.ts +6 -6
  37. package/src/index.ts +563 -554
  38. package/src/membership-connection-gater.ts +56 -11
  39. package/src/storage-scope.ts +163 -78
  40. package/src/strand-formation-manager.ts +647 -646
  41. package/src/strand-id.ts +48 -0
  42. package/src/strand-instance-manager.ts +21 -2
  43. package/src/strand-network-config.ts +250 -250
  44. package/src/strand-solicitation.ts +500 -499
  45. package/src/strand-watcher.ts +388 -325
  46. package/src/types.ts +121 -15
package/README.md CHANGED
@@ -236,9 +236,11 @@ A **scope key** is what the factory receives. It is the strand id for each stran
236
236
  which holds one party's own records and must not be shared between parties. Every key is
237
237
  opaque and already safe as a file, directory or database name (always within
238
238
  `[A-Za-z0-9._-]`): use it verbatim, do not parse it. `isControlStorageScope(scope)` tells
239
- the two kinds apart. Caveat, until `bug-strand-scope-key-charset-unenforced` lands: the
240
- charset is enforced for the control key but only *conventional* for a strand id, which
241
- reaches the factory verbatim even when the strand row replicated in from another node.
239
+ the two kinds apart. The control key holds the charset by base64url encoding; a strand
240
+ id holds it by check — `assertStrandScopeKey` runs on every strand launch, so a strand
241
+ whose row replicated in from another node with an unusable id is refused rather than
242
+ handed to the factory, and on `publishStrand`, so this node never writes such an id into
243
+ the party's control database to begin with.
242
244
 
243
245
  Available storage implementations:
244
246
  | Package | Environment | Description |
@@ -279,8 +279,10 @@ export declare class CadreNode implements SAppIdLookup {
279
279
  * dead entry behind; recovery today is a fresh invitation.
280
280
  *
281
281
  * NOTE: entries are kept for the node's lifetime, including across a
282
- * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
283
- * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
282
+ * {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
283
+ * (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
284
+ * the session) and must still get its seed. Each strand's list is capped at
285
+ * `MAX_STRAND_ADDRS`, but the
284
286
  * number of KEYS is bounded only by the strands this node has ever formed as an
285
287
  * initiator (not by time); if a node ever forms strands at scale, evict on
286
288
  * `unpublishStrand` or cap the map.
@@ -732,6 +734,12 @@ export declare class CadreNode implements SAppIdLookup {
732
734
  * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
733
735
  * `/optimystic/<networkName>/...`), so the node options and the block-transfer
734
736
  * protocol prefix the control backfill dials can never drift apart.
737
+ *
738
+ * NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
739
+ * today because a party id is locally configured rather than replicated in, and both
740
+ * ends of a connection derive this string identically — an odd party id yields an odd
741
+ * but consistent protocol id, not a mismatch or an escaped name. If a party id ever
742
+ * arrives from the network, encode it here as the storage scope key already does.
735
743
  */
736
744
  private controlNetworkName;
737
745
  /**
@@ -1862,17 +1870,17 @@ export declare class CadreNode implements SAppIdLookup {
1862
1870
  *
1863
1871
  * A rejected call leaves nothing running but DOES leave the sApp config
1864
1872
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1865
- * automatic relaunch need it. That background relaunch covers only a strand the
1866
- * WATCHER launched: it forgets a row whose `onStrandAdded` threw and re-attempts it
1867
- * on a later poll (each failure re-emitting `strand:error`) until it succeeds. A
1868
- * strand claimed here after it was DISCOVERED gets no such retry — the watcher
1869
- * already recorded it in its `knownStrands` when it offered it as
1870
- * `strand:discovered`, and no later poll re-offers a strand it knows. This call has
1871
- * also already dropped it from {@link getDiscoveredStrands}, so a failed claim
1872
- * leaves the strand dormant until the caller retries or the node restarts (see
1873
- * `backlog/bug-discovered-strand-lost-when-claim-fails`).
1874
- * {@link detachStrand} (reached via {@link stopStrand}) is what abandons a strand
1875
- * for good.
1873
+ * automatic relaunch need it. A failed launch here hands the strand back to that
1874
+ * relaunch — `StrandWatcher.forgetStrand` drops the id from the watcher's
1875
+ * `knownStrands` and records the backoff, so a later poll re-attempts it on the same
1876
+ * `pollInterval * 2^(failures-1)` ladder a watcher-driven failure gets. Because the
1877
+ * config stays registered, the retry takes `handleStrandAdded`'s auto-launch branch:
1878
+ * what the app sees is `strand:error` per failed retry and `strand:started` when one
1879
+ * succeeds, never a second `strand:discovered`. {@link detachStrand} (reached via
1880
+ * {@link stopStrand}) is what abandons a strand for good, and its stop suppresses the
1881
+ * strand in the watcher so this retry cannot resurrect it — a call to THIS method lifts
1882
+ * that suppression again, since an explicit claim is a deliberate reversal of the
1883
+ * deliberate stop.
1876
1884
  */
1877
1885
  addStrand(config: StrandConfig): Promise<StrandInstance>;
1878
1886
  /**
@@ -1945,7 +1953,8 @@ export declare class CadreNode implements SAppIdLookup {
1945
1953
  * @returns The live `Strand` row — the one just inserted, or the matching one already
1946
1954
  * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
1947
1955
  * on a repeat it is the stored key, not the argument.
1948
- * @throws if the node is not started, exposes no owner signing key, the id is blank, a
1956
+ * @throws if the node is not started, exposes no owner signing key, the id is blank or
1957
+ * unusable as a storage scope key (`InvalidStrandIdError` — see `storage-scope.ts`), a
1949
1958
  * row with the same id holds different content, or the control DB rejects the
1950
1959
  * (unauthorized) insert.
1951
1960
  */
@@ -2465,11 +2474,11 @@ export declare class CadreNode implements SAppIdLookup {
2465
2474
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
2466
2475
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
2467
2476
  * intact, so on the next node RESTART the strand is rediscovered and surfaces as
2468
- * `strand:discovered` again — not on the next watcher poll, which never re-offers a
2469
- * strand it has already seen (`StrandWatcher.knownStrands` retains the id for the life
2470
- * of the process). The stop also drops the strand from {@link getDiscoveredStrands},
2471
- * so a drain cannot undo a deliberate stop. Party-wide removal is
2472
- * {@link unpublishStrand}.
2477
+ * `strand:discovered` again — but never again in THIS session: the stop suppresses the
2478
+ * id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
2479
+ * {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
2480
+ * deliberate stop. Only an explicit {@link addStrand} does, which is the caller
2481
+ * reversing its own decision. Party-wide removal is {@link unpublishStrand}.
2473
2482
  */
2474
2483
  stopStrand(strandId: string): Promise<void>;
2475
2484
  /**
@@ -2490,6 +2499,10 @@ export declare class CadreNode implements SAppIdLookup {
2490
2499
  * readings are the intended one: `handleStrandRemoved` arrives because the control row
2491
2500
  * is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
2492
2501
  * strand may be re-offered to a later `getDiscoveredStrands()` drain.
2502
+ *
2503
+ * The watcher-level suppression that makes a stop permanent is deliberately NOT here,
2504
+ * only in {@link stopStrand}: the two callers diverge on it. A vanished control row
2505
+ * that reappears is a fresh strand and must be offered again.
2493
2506
  */
2494
2507
  private detachStrand;
2495
2508
  /**
@@ -4,11 +4,11 @@ import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
4
4
  import { createLibp2pNode } from '@optimystic/db-p2p';
5
5
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
6
6
  import { multiaddr } from '@multiformats/multiaddr';
7
- import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
7
+ import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
8
8
  import { sign } from '@optimystic/quereus-plugin-crypto';
9
9
  import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
10
10
  import { strandTransportKey } from './strand-transport-key.js';
11
- import { controlStorageScope } from './storage-scope.js';
11
+ import { assertScopeKeyCharset, assertStrandScopeKey, controlStorageScope, InvalidStrandIdError, isValidStrandScopeKey } from './storage-scope.js';
12
12
  import { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
13
13
  import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError } from './strand-membership-writer.js';
14
14
  import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
@@ -394,8 +394,10 @@ export class CadreNode {
394
394
  * dead entry behind; recovery today is a fresh invitation.
395
395
  *
396
396
  * NOTE: entries are kept for the node's lifetime, including across a
397
- * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
398
- * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
397
+ * {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
398
+ * (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
399
+ * the session) and must still get its seed. Each strand's list is capped at
400
+ * `MAX_STRAND_ADDRS`, but the
399
401
  * number of KEYS is bounded only by the strands this node has ever formed as an
400
402
  * initiator (not by time); if a node ever forms strands at scale, evict on
401
403
  * `unpublishStrand` or cap the map.
@@ -1184,6 +1186,12 @@ export class CadreNode {
1184
1186
  * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
1185
1187
  * `/optimystic/<networkName>/...`), so the node options and the block-transfer
1186
1188
  * protocol prefix the control backfill dials can never drift apart.
1189
+ *
1190
+ * NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
1191
+ * today because a party id is locally configured rather than replicated in, and both
1192
+ * ends of a connection derive this string identically — an odd party id yields an odd
1193
+ * but consistent protocol id, not a mismatch or an escaped name. If a party id ever
1194
+ * arrives from the network, encode it here as the storage scope key already does.
1187
1195
  */
1188
1196
  controlNetworkName() {
1189
1197
  return `control-${this.config.controlNetwork.partyId}`;
@@ -1293,6 +1301,11 @@ export class CadreNode {
1293
1301
  // party's records, so two parties on one device must not land in one store.
1294
1302
  // Same key as the cache label, which also makes the shared pool's `stats()` readable.
1295
1303
  const scope = controlStorageScope(this.config.controlNetwork.partyId);
1304
+ // The second seam that hands a scope key to an embedder's provider; the first
1305
+ // (`StrandInstanceManager.startStrand`) asserts the stricter strand rule. Holds by
1306
+ // construction today — base64url is inside the charset — so this guards a future
1307
+ // edit to `controlStorageScope`, not a reachable input.
1308
+ assertScopeKeyCharset(scope);
1296
1309
  const resolved = typeof provider === 'function' ? provider(scope) : provider;
1297
1310
  this.controlStorage = wrapStorageWithCache(resolved, scope);
1298
1311
  return this.controlStorage;
@@ -1387,6 +1400,12 @@ export class CadreNode {
1387
1400
  arachnode: { enableRingZulu: profile === 'storage' },
1388
1401
  ...(identityKey && { privateKey: identityKey }),
1389
1402
  ...(network?.transports && { transports: network.transports }),
1403
+ ...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
1404
+ // Always present, unlike the conditional spreads around it: an absent
1405
+ // `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
1406
+ // whose event loop is saturated by pure-JS Noise crypto. A configured value
1407
+ // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1408
+ connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1390
1409
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1391
1410
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1392
1411
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -3669,6 +3688,23 @@ export class CadreNode {
3669
3688
  }
3670
3689
  async handleStrandAdded(strand) {
3671
3690
  log('Handling strand added from control network: %s', strand.Id);
3691
+ // A replicated row carries whatever id the founding node wrote, and the id becomes a
3692
+ // storage scope key and a libp2p protocol prefix. Checked FIRST — above the
3693
+ // discovery branch below — so a malformed id is never offered to the hosting app as
3694
+ // a strand it could join; that app's `addStrand` could only fail on it.
3695
+ //
3696
+ // Suppressed rather than retried: the id is a property of the row, so every later
3697
+ // attempt fails identically, and without this the watcher's ladder would re-emit
3698
+ // `strand:error` every five minutes for the life of the process. The throw still
3699
+ // matters — the watcher's catch runs `forgetStrand`, dropping the id from
3700
+ // `knownStrands`, so its removed-strand loop never detaches a strand that never ran.
3701
+ if (!isValidStrandScopeKey(strand.Id)) {
3702
+ const error = new InvalidStrandIdError(strand.Id);
3703
+ log('Refusing strand %s: %s', strand.Id, error.message);
3704
+ this.emit('strand:error', { strandId: strand.Id, error });
3705
+ this.strandWatcher?.suppressStrand(strand.Id);
3706
+ throw error;
3707
+ }
3672
3708
  // Check if we have sApp config for this strand
3673
3709
  const sAppConfig = this.sAppConfigs.get(strand.Id);
3674
3710
  if (!sAppConfig) {
@@ -3680,10 +3716,12 @@ export class CadreNode {
3680
3716
  // already present) keeps auto-starting below, unchanged.
3681
3717
  //
3682
3718
  // Recorded BEFORE the emit so a handler that synchronously drains
3683
- // `getDiscoveredStrands()` sees this strand too. The watcher never offers
3684
- // the same strand twice (its `knownStrands` retains the id for the life of
3685
- // the process), so this map — not the event — is what a late subscriber
3686
- // reads. See the `strand:discovered` doc in types.ts.
3719
+ // `getDiscoveredStrands()` sees this strand too. The watcher does not offer the
3720
+ // same strand twice — its `knownStrands` retains the id, and the one thing that
3721
+ // un-retains it (a failed `addStrand`, via `StrandWatcher.forgetStrand`) leaves the
3722
+ // sApp config registered, so the retry takes the auto-launch branch below rather
3723
+ // than this one. So this map — not the event — is what a late subscriber reads.
3724
+ // See the `strand:discovered` doc in types.ts.
3687
3725
  this.discoveredStrands.set(strand.Id, strand);
3688
3726
  this.emit('strand:discovered', { strandId: strand.Id, strand });
3689
3727
  return;
@@ -4083,23 +4121,26 @@ export class CadreNode {
4083
4121
  *
4084
4122
  * A rejected call leaves nothing running but DOES leave the sApp config
4085
4123
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
4086
- * automatic relaunch need it. That background relaunch covers only a strand the
4087
- * WATCHER launched: it forgets a row whose `onStrandAdded` threw and re-attempts it
4088
- * on a later poll (each failure re-emitting `strand:error`) until it succeeds. A
4089
- * strand claimed here after it was DISCOVERED gets no such retry — the watcher
4090
- * already recorded it in its `knownStrands` when it offered it as
4091
- * `strand:discovered`, and no later poll re-offers a strand it knows. This call has
4092
- * also already dropped it from {@link getDiscoveredStrands}, so a failed claim
4093
- * leaves the strand dormant until the caller retries or the node restarts (see
4094
- * `backlog/bug-discovered-strand-lost-when-claim-fails`).
4095
- * {@link detachStrand} (reached via {@link stopStrand}) is what abandons a strand
4096
- * for good.
4124
+ * automatic relaunch need it. A failed launch here hands the strand back to that
4125
+ * relaunch — `StrandWatcher.forgetStrand` drops the id from the watcher's
4126
+ * `knownStrands` and records the backoff, so a later poll re-attempts it on the same
4127
+ * `pollInterval * 2^(failures-1)` ladder a watcher-driven failure gets. Because the
4128
+ * config stays registered, the retry takes `handleStrandAdded`'s auto-launch branch:
4129
+ * what the app sees is `strand:error` per failed retry and `strand:started` when one
4130
+ * succeeds, never a second `strand:discovered`. {@link detachStrand} (reached via
4131
+ * {@link stopStrand}) is what abandons a strand for good, and its stop suppresses the
4132
+ * strand in the watcher so this retry cannot resurrect it — a call to THIS method lifts
4133
+ * that suppression again, since an explicit claim is a deliberate reversal of the
4134
+ * deliberate stop.
4097
4135
  */
4098
4136
  async addStrand(config) {
4099
4137
  if (!this._running) {
4100
4138
  throw new Error('CadreNode not running');
4101
4139
  }
4102
4140
  const { strandRow, sAppConfig, founder, partyMemberPrivateKey } = config;
4141
+ // Before anything is recorded: an unusable id must not leave a registered sApp
4142
+ // config behind, and must not lift a suppression this node set deliberately.
4143
+ assertStrandScopeKey(strandRow.Id);
4103
4144
  if (partyMemberPrivateKey !== undefined && strandRow.Type !== 'c') {
4104
4145
  // Only a closed strand seats a Member/Manager, so an open launch would drop this
4105
4146
  // key silently. Refuse instead: the caller has confused the party identity key
@@ -4112,13 +4153,41 @@ export class CadreNode {
4112
4153
  // unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
4113
4154
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
4114
4155
  this.discoveredStrands.delete(strandRow.Id);
4156
+ // A claim also overrides any earlier `stopStrand` of this id: the watcher must track
4157
+ // the strand again, or a party-wide removal would never stop it here.
4158
+ this.strandWatcher?.unsuppressStrand(strandRow.Id);
4115
4159
  log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
4116
4160
  // An unset `founder` is DERIVED from the row inside launchStrand (this node founds
4117
4161
  // iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
4118
4162
  // formation/responder flows pass one deliberately, since their consent-seated rows
4119
4163
  // carry a null column. See StrandConfig.founder. Same rule for the explicit
4120
4164
  // partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
4121
- const instance = await this.launchStrand(strandRow, sAppConfig, founder, partyMemberPrivateKey);
4165
+ // Only the launch is wrapped, NOT the first-sync wait below: a wait that times out
4166
+ // rejects with the retryable StrandAwaitingFirstSyncError and deliberately leaves the
4167
+ // instance running, so there is nothing to re-offer and forgetting it would only have
4168
+ // the watcher re-enter a launch for a strand that is already up.
4169
+ let instance;
4170
+ try {
4171
+ instance = await this.launchStrand(strandRow, sAppConfig, founder, partyMemberPrivateKey);
4172
+ }
4173
+ catch (error) {
4174
+ // Hand the strand back to the watcher's retry ladder. Harmless on the founder path
4175
+ // (`foundStrand` → `publishStrand` + this call), where the watcher may never have
4176
+ // offered the strand: the `knownStrands` delete is a no-op and the recorded backoff
4177
+ // only delays the watcher's own first attempt by one poll interval, which is what
4178
+ // should happen right after an attempt that just failed.
4179
+ //
4180
+ // NOTE: the retry relaunches from the CONTROL row plus the registered config, not
4181
+ // from the row and arguments passed here — so a caller that enriched either (a
4182
+ // synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
4183
+ // is retried with less than it asked for. Inert today: founder-ness re-derives from
4184
+ // `FounderOwnerKey`, the party key re-reads from `StrandPartyKey`, and the row's
4185
+ // shared `MemberPrivateKey` is read only by the founder bootstrap. If a joiner
4186
+ // launch ever starts depending on that key, gate this hand-back on the passed row
4187
+ // matching the control one.
4188
+ this.strandWatcher?.forgetStrand(strandRow.Id);
4189
+ throw error;
4190
+ }
4122
4191
  // The first-sync write gate: a JOINING machine's database is withheld until it has
4123
4192
  // received the strand's Header from another member, because a write before that
4124
4193
  // forks every table it touches (see strand-first-sync-gate.ts). Waiting here is what
@@ -4207,7 +4276,8 @@ export class CadreNode {
4207
4276
  * @returns The live `Strand` row — the one just inserted, or the matching one already
4208
4277
  * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
4209
4278
  * on a repeat it is the stored key, not the argument.
4210
- * @throws if the node is not started, exposes no owner signing key, the id is blank, a
4279
+ * @throws if the node is not started, exposes no owner signing key, the id is blank or
4280
+ * unusable as a storage scope key (`InvalidStrandIdError` — see `storage-scope.ts`), a
4211
4281
  * row with the same id holds different content, or the control DB rejects the
4212
4282
  * (unauthorized) insert.
4213
4283
  */
@@ -4216,6 +4286,11 @@ export class CadreNode {
4216
4286
  // Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
4217
4287
  // trims too, and an untrimmed row would be unreachable by the same string.
4218
4288
  const trimmed = requireNonBlank(strandId, 'strand id');
4289
+ // Refuse an unusable id HERE, not only at launch. `addStrand` already asserts, but it
4290
+ // runs after this row is written, and this row replicates: an id no node can turn into
4291
+ // a storage scope key would otherwise reach the whole party's control database and be
4292
+ // declined once per member. Asserted after the trim so the id checked is the id stored.
4293
+ assertStrandScopeKey(trimmed);
4219
4294
  // FounderOwnerKey records THIS machine as the row's publisher (the schema pins it to
4220
4295
  // the signing owner), which is what later lets a relaunch derive founder-ness.
4221
4296
  const desired = {
@@ -4456,13 +4531,20 @@ export class CadreNode {
4456
4531
  // one never cleared).
4457
4532
  await this.strandManager.clearOwnMemberPeerBinding(trimmed);
4458
4533
  // Converge locally now rather than waiting up to a poll interval. The watcher fires
4459
- // onStrandRemoved for a strand it tracked; the explicit stop below covers a node whose
4460
- // strandFilter never admitted this strand (the watcher never knew it, so it will never
4461
- // fire). StrandInstanceManager.stopStrand no-ops on an unknown id, so the two paths
4462
- // cannot double-stop into an error.
4534
+ // onStrandRemoved for a strand it tracked; the explicit teardown below covers a node
4535
+ // whose strandFilter never admitted this strand (the watcher never knew it, so it will
4536
+ // never fire). StrandInstanceManager.stopStrand no-ops on an unknown id, so the two
4537
+ // paths cannot double-stop into an error.
4538
+ //
4539
+ // detachStrand, NOT stopStrand: this is a party-wide removal, not a local abandonment
4540
+ // of a strand that still exists, so it must not SUPPRESS the id in the watcher. The
4541
+ // row is already gone — there is nothing to suppress — and an id re-published before
4542
+ // the next poll's suppression cleanup would then never be offered again this session.
4543
+ // (Same reasoning as handleStrandRemoved, which also avoids stopStrand's `_running`
4544
+ // guard; here the guard is already satisfied by requireOwnerSigningKey above.)
4463
4545
  await this.strandWatcher?.forcePoll();
4464
4546
  if (this.strandManager.getInstance(trimmed)) {
4465
- await this.stopStrand(trimmed);
4547
+ await this.detachStrand(trimmed);
4466
4548
  }
4467
4549
  log('Unpublished strand %s from control DB under owner %s', trimmed, signingKey.publicKeyB64);
4468
4550
  }
@@ -5312,16 +5394,20 @@ export class CadreNode {
5312
5394
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
5313
5395
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
5314
5396
  * intact, so on the next node RESTART the strand is rediscovered and surfaces as
5315
- * `strand:discovered` again — not on the next watcher poll, which never re-offers a
5316
- * strand it has already seen (`StrandWatcher.knownStrands` retains the id for the life
5317
- * of the process). The stop also drops the strand from {@link getDiscoveredStrands},
5318
- * so a drain cannot undo a deliberate stop. Party-wide removal is
5319
- * {@link unpublishStrand}.
5397
+ * `strand:discovered` again — but never again in THIS session: the stop suppresses the
5398
+ * id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
5399
+ * {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
5400
+ * deliberate stop. Only an explicit {@link addStrand} does, which is the caller
5401
+ * reversing its own decision. Party-wide removal is {@link unpublishStrand}.
5320
5402
  */
5321
5403
  async stopStrand(strandId) {
5322
5404
  if (!this._running) {
5323
5405
  throw new Error('CadreNode not running');
5324
5406
  }
5407
+ // Suppressed HERE and not in detachStrand: the other caller of that method is
5408
+ // `handleStrandRemoved`, which arrives precisely because the control row is gone, and
5409
+ // a row that later reappears is a fresh strand that should be offered again.
5410
+ this.strandWatcher?.suppressStrand(strandId);
5325
5411
  await this.detachStrand(strandId);
5326
5412
  }
5327
5413
  /**
@@ -5342,6 +5428,10 @@ export class CadreNode {
5342
5428
  * readings are the intended one: `handleStrandRemoved` arrives because the control row
5343
5429
  * is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
5344
5430
  * strand may be re-offered to a later `getDiscoveredStrands()` drain.
5431
+ *
5432
+ * The watcher-level suppression that makes a stop permanent is deliberately NOT here,
5433
+ * only in {@link stopStrand}: the two callers diverge on it. A vanished control row
5434
+ * that reappears is a fresh strand and must be offered again.
5345
5435
  */
5346
5436
  async detachStrand(strandId) {
5347
5437
  this.hibernationManager.untrackStrand(strandId);