@serfab/cadre-core 1.3.0 → 1.5.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 (90) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +70 -34
  3. package/dist/cadre-node.js +197 -60
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +24 -10
  6. package/dist/control-database.js +48 -16
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-formation-recorder.d.ts +4 -4
  9. package/dist/control-formation-recorder.js +6 -6
  10. package/dist/control-formation-recorder.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +12 -0
  12. package/dist/control-read-retry.js +12 -0
  13. package/dist/control-read-retry.js.map +1 -1
  14. package/dist/control-write-retry.d.ts +5 -4
  15. package/dist/control-write-retry.js +24 -7
  16. package/dist/control-write-retry.js.map +1 -1
  17. package/dist/index.d.ts +5 -4
  18. package/dist/index.js +10 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/link-budget.d.ts +182 -0
  21. package/dist/link-budget.js +195 -0
  22. package/dist/link-budget.js.map +1 -0
  23. package/dist/membership-connection-gater.d.ts +18 -5
  24. package/dist/membership-connection-gater.js +50 -10
  25. package/dist/membership-connection-gater.js.map +1 -1
  26. package/dist/peer-dial.d.ts +36 -17
  27. package/dist/peer-dial.js +37 -17
  28. package/dist/peer-dial.js.map +1 -1
  29. package/dist/peer-join-backfill.d.ts +58 -4
  30. package/dist/peer-join-backfill.js +165 -20
  31. package/dist/peer-join-backfill.js.map +1 -1
  32. package/dist/relay-reservation.d.ts +59 -11
  33. package/dist/relay-reservation.js +169 -43
  34. package/dist/relay-reservation.js.map +1 -1
  35. package/dist/seed-bootstrap.js +2 -1
  36. package/dist/seed-bootstrap.js.map +1 -1
  37. package/dist/storage-scope.d.ts +50 -12
  38. package/dist/storage-scope.js +85 -12
  39. package/dist/storage-scope.js.map +1 -1
  40. package/dist/strand-first-sync-gate.d.ts +43 -5
  41. package/dist/strand-first-sync-gate.js +55 -7
  42. package/dist/strand-first-sync-gate.js.map +1 -1
  43. package/dist/strand-formation-manager.js +2 -1
  44. package/dist/strand-formation-manager.js.map +1 -1
  45. package/dist/strand-id.d.ts +35 -0
  46. package/dist/strand-id.js +46 -0
  47. package/dist/strand-id.js.map +1 -0
  48. package/dist/strand-instance-manager.d.ts +27 -2
  49. package/dist/strand-instance-manager.js +75 -15
  50. package/dist/strand-instance-manager.js.map +1 -1
  51. package/dist/strand-membership-reconciler.d.ts +200 -45
  52. package/dist/strand-membership-reconciler.js +302 -86
  53. package/dist/strand-membership-reconciler.js.map +1 -1
  54. package/dist/strand-membership-writer.d.ts +3 -2
  55. package/dist/strand-membership-writer.js +67 -90
  56. package/dist/strand-membership-writer.js.map +1 -1
  57. package/dist/strand-network-config.d.ts +3 -3
  58. package/dist/strand-network-config.js +3 -3
  59. package/dist/strand-solicitation.js +2 -1
  60. package/dist/strand-solicitation.js.map +1 -1
  61. package/dist/strand-watcher.d.ts +45 -0
  62. package/dist/strand-watcher.js +74 -13
  63. package/dist/strand-watcher.js.map +1 -1
  64. package/dist/types.d.ts +190 -24
  65. package/dist/types.js +73 -4
  66. package/dist/types.js.map +1 -1
  67. package/package.json +8 -8
  68. package/src/cadre-node.ts +7445 -7302
  69. package/src/control-database.ts +48 -16
  70. package/src/control-formation-recorder.ts +6 -6
  71. package/src/control-read-retry.ts +214 -202
  72. package/src/control-write-retry.ts +24 -7
  73. package/src/index.ts +29 -2
  74. package/src/link-budget.ts +217 -0
  75. package/src/membership-connection-gater.ts +56 -11
  76. package/src/peer-dial.ts +39 -17
  77. package/src/peer-join-backfill.ts +192 -23
  78. package/src/relay-reservation.ts +1119 -951
  79. package/src/seed-bootstrap.ts +2 -1
  80. package/src/storage-scope.ts +163 -78
  81. package/src/strand-first-sync-gate.ts +55 -7
  82. package/src/strand-formation-manager.ts +647 -646
  83. package/src/strand-id.ts +48 -0
  84. package/src/strand-instance-manager.ts +1516 -1440
  85. package/src/strand-membership-reconciler.ts +334 -92
  86. package/src/strand-membership-writer.ts +1962 -1987
  87. package/src/strand-network-config.ts +250 -250
  88. package/src/strand-solicitation.ts +500 -499
  89. package/src/strand-watcher.ts +388 -325
  90. package/src/types.ts +192 -23
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
- import type { IRawStorage, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
2
+ import type { IRawStorage, Libp2pConnectionMonitorInit, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
3
3
  import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
4
  import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
5
5
  import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
@@ -121,13 +121,13 @@ export const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeouts> = {
121
121
  * Factory functions are useful for creating per-scope storage instances.
122
122
  *
123
123
  * **The argument is an opaque scope key.** Use it directly as a file name, directory
124
- * name or database name: every key cadre-core mints stays within `[A-Za-z0-9._-]`, so
125
- * no escaping is needed and none should be assumed. Do not parse it;
126
- * `controlStorageScope` / `isControlStorageScope` (`storage-scope.ts`) are the
127
- * supported way to mint and recognize the control key. The strand arm of that charset
128
- * rule is intended but not yet checked — a strand id replicated in from another node
129
- * in the party reaches this callback verbatim; see
130
- * `tickets/backlog/bug-strand-scope-key-charset-unenforced`.
124
+ * name or database name: every key stays within `[A-Za-z0-9._-]`, so no escaping is
125
+ * needed and none should be assumed. Do not parse it; `controlStorageScope` /
126
+ * `isControlStorageScope` (`storage-scope.ts`) are the supported way to mint and
127
+ * recognize the control key. The control key holds the charset by base64url encoding;
128
+ * a strand's key — its strand id, which may have replicated in from another node in
129
+ * the party — holds it because `StrandInstanceManager.startStrand` runs
130
+ * `assertStrandScopeKey` on every launch and refuses a strand that fails it.
131
131
  *
132
132
  * **Called once per scope per runtime lifetime.** The scopes are the control database
133
133
  * (once per `CadreNode.start()`) and each strand id (once per `startStrand`).
@@ -316,7 +316,8 @@ export interface NetworkConfig {
316
316
  *
317
317
  * It buys a node that BOOTS, not a node that boots fast: `start()` still waits
318
318
  * out that first attempt, which costs the drive's whole timeout
319
- * (`DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s) against a relay that is unreachable
319
+ * (`DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, four link round trips at
320
+ * {@link linkRoundTripMs} — 8 s at its default) against a relay that is unreachable
320
321
  * rather than merely refusing (`relay-reservation.ts` polls to the deadline, in
321
322
  * case libp2p's own discovery lands a reservation independently).
322
323
  *
@@ -390,6 +391,72 @@ export interface NetworkConfig {
390
391
  * `NodeOptions.noiseCrypto`.
391
392
  */
392
393
  noiseCrypto?: NoiseCryptoInterface;
394
+ /**
395
+ * libp2p's connection monitor — the liveness ping it runs on every connection — for
396
+ * the control node and every strand node, as {@link noiseCrypto} is. When omitted,
397
+ * cadre-core applies {@link DEFAULT_CONNECTION_MONITOR} rather than leaving libp2p's
398
+ * own defaults in place; an explicit value REPLACES that default wholesale, so `{}`
399
+ * is how an app asks for libp2p's stock behaviour back.
400
+ *
401
+ * Typed from `@optimystic/db-p2p`'s re-export of libp2p's `ConnectionMonitorInit`, so
402
+ * an app does not need a direct `libp2p` dependency, and handed to db-p2p's
403
+ * `NodeOptions.connectionMonitor` unchanged.
404
+ *
405
+ * An app that widens the ping deadline itself must raise `pingInterval` above it in
406
+ * the same object, or libp2p aborts the connection on the second overlapping ping —
407
+ * see {@link DEFAULT_CONNECTION_MONITOR} for why.
408
+ */
409
+ connectionMonitor?: Libp2pConnectionMonitorInit;
410
+ /**
411
+ * How long ONE cohort peer gets to answer ONE read-path request, in milliseconds, for the
412
+ * control node and every strand node — as {@link connectionMonitor} is, and for the same
413
+ * reason: the setting describes the LINK, and a phone's control node and its strand nodes
414
+ * ride the same one. Omitted takes {@link COHORT_READ_DEADLINE_MS} (5000 ms), chosen for two
415
+ * parties reaching each other only through a relay; Optimystic's own default is 1000 ms.
416
+ *
417
+ * Raise it for a link slower still, lower it for a deployment that is all LAN and wants a
418
+ * departed peer to stop holding up a read sooner. The cost of a larger value is that a peer
419
+ * which is truly gone holds a read of a block missing locally for that long before the read
420
+ * is declined and retried, and a joining machine's first sync runs several such consults —
421
+ * so a change here should be weighed against
422
+ * {@link CadreNodeConfig.strandFirstSync}'s budget. The measurement behind the default, and
423
+ * what it costs, are on {@link COHORT_READ_DEADLINE_MS}.
424
+ *
425
+ * Handed to db-p2p's `clusterPolicy.cohortQueryTimeoutMs` unchanged and NOT re-validated
426
+ * here. Optimystic refuses a value that is not a finite number above zero, or is above its
427
+ * `MAX_COHORT_QUERY_TIMEOUT_MS` (about 4.97 days — the ceiling exists because a unit mix-up
428
+ * is the one way to exceed it), by throwing where the libp2p node is built: inside
429
+ * `CadreNode.start()` for the control network, and inside `CadreNode.addStrand` for a strand.
430
+ * Fractional values are accepted — this is a duration, not a count of peers.
431
+ */
432
+ cohortQueryTimeoutMs?: number;
433
+ /**
434
+ * The round trip this node assumes between itself and another machine, in milliseconds, for
435
+ * the control node and every strand node — as {@link cohortQueryTimeoutMs} is, and for the
436
+ * same reason: the setting describes the LINK, and a phone's control node and its strand
437
+ * nodes ride the same one. Omitted takes {@link DECLARED_LINK_ROUND_TRIP_MS} (2000 ms).
438
+ *
439
+ * This is NOT a timeout. It is the one stated assumption that cadre's own dial and
440
+ * reservation deadlines are DERIVED from, each by the number of round trips that operation
441
+ * was measured to cost: a peer-join catch-up's dial to one peer, its push response, one relay
442
+ * reservation drive, and the control-cohort dial budgets. Reaching another machine through a
443
+ * relay costs a fixed number of exchanges, so a deadline written as milliseconds has a link
444
+ * speed above which it can never open a connection — which is the defect this declaration
445
+ * exists to make impossible to reintroduce one budget at a time. The counts, the measurement
446
+ * behind them, and the ceiling that no declaration here can lift are in `link-budget.ts`.
447
+ *
448
+ * Raise it for a link slower than the relayed phone-to-phone band sereus assumes; the cost is
449
+ * the ordinary cost of longer deadlines, a peer that is genuinely gone holding each operation
450
+ * that much longer before it is abandoned and retried. Above about 2500 it buys nothing: two
451
+ * libp2p budgets that sereus cannot reach abandon the connection first
452
+ * (`tickets/blocked/how-slow-a-relayed-link-does-sereus-carry`).
453
+ *
454
+ * Refused where the libp2p node is built — inside `CadreNode.start()` for the control
455
+ * network, inside `CadreNode.addStrand` for a strand — if it is not a finite number above
456
+ * zero, because every consumer multiplies it into a deadline where a zero means "give up at
457
+ * once" and a `NaN` means "never".
458
+ */
459
+ linkRoundTripMs?: number;
393
460
  /**
394
461
  * Optional async resolver returning the multiaddrs to embed in invites
395
462
  * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
@@ -471,6 +538,66 @@ export interface NetworkConfig {
471
538
  };
472
539
  }
473
540
 
541
+ /**
542
+ * The connection-monitor settings every cadre node runs with when
543
+ * {@link NetworkConfig.connectionMonitor} is unset: a 30 second ping deadline in place
544
+ * of libp2p's 5 second one, and a 35 second gap between pings so a ping is never
545
+ * outstanding when the next one starts.
546
+ *
547
+ * WHY this is a default and not opt-in. libp2p's monitor pings every connection and,
548
+ * with its own `abortConnectionOnPingFailure: true`, aborts the connection on the FIRST
549
+ * timeout. A peer whose event loop is saturated by pure-JS Noise crypto — a slow phone
550
+ * under React Native, see {@link NetworkConfig.noiseCrypto} — misses that deadline while
551
+ * perfectly healthy; the other end aborts, the peer redials, and the new handshake
552
+ * saturates it further. Measured on gotchoices/sereus#13 at a Galaxy S7's crypto cost, a
553
+ * two-party bring-up failed 3 of 3 runs on stock settings, with 30 `aborting connection
554
+ * due to ping failure` entries in the relay's log for one run, and passed 4 of 4 in about
555
+ * 90 seconds once the deadline was widened on every node. The monitor runs on BOTH ends
556
+ * of a connection and either end's abort closes it for both, so a setting the phone alone
557
+ * applies does not cover the peer dropping it. That is what rules out scoping this to
558
+ * React Native.
559
+ *
560
+ * WHY `pingInterval` MOVES WITH THE DEADLINE, and is not left at libp2p's 10 seconds.
561
+ * The monitor opens a ping stream per connection per interval whether or not the previous
562
+ * ping has answered, and `/ipfs/ping/1.0.0` is registered by `@libp2p/ping` with
563
+ * `maxOutboundStreams: 1`. A second concurrent ping stream on one connection therefore
564
+ * fails in `Connection.newStream` with `TooManyOutboundProtocolStreamsError`, which
565
+ * reaches the monitor's own catch and aborts the connection exactly as a timeout does. A
566
+ * widened deadline alone is thus capped by the ping interval: measured against two local
567
+ * libp2p 3.1.3 nodes whose ping handler answered 600ms late, an interval of 300ms with a
568
+ * 900ms deadline aborted the connection, while a 900ms interval with the same deadline
569
+ * kept it (the same pair aborted at a 200ms stall only when the deadline was 300ms). An
570
+ * interval strictly above the deadline is what makes the 30 seconds real.
571
+ *
572
+ * WHAT IT COSTS. A dead peer is reclaimed 30 to 65 seconds after it stops answering — the
573
+ * deadline, plus up to one interval of waiting for the ping that will fail — where
574
+ * libp2p's defaults took about 5 to 15 seconds. `db-p2p` caps a node at 16 connections,
575
+ * so the worst case is those slots held about a minute longer than before; at that scale
576
+ * it is not a starvation risk.
577
+ *
578
+ * WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
579
+ * adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
580
+ * every libp2p version. Under libp2p 3.1.3, which sereus resolves today, it is already
581
+ * flat at `minTimeout`: `ConnectionMonitor` asks its `AdaptiveTimeout` for a deadline but
582
+ * never calls `cleanUp` to report how long the ping took, so the moving average the
583
+ * deadline derives from stays at zero. libp2p 3.3 does report ping durations back, and a
584
+ * ceiling above `pingInterval` would then let the deadline grow past the interval and put
585
+ * the overlapping-ping abort above straight back. Pinning both ends keeps the interval's
586
+ * margin true on the version bump instead of making it something to remember. It also
587
+ * sidesteps 3.3's other surprise: the monitor keeps one `AdaptiveTimeout` for all of a
588
+ * node's connections, so one slow peer would otherwise lengthen the deadline for every
589
+ * connection on that node.
590
+ */
591
+ export const DEFAULT_CONNECTION_MONITOR = Object.freeze({
592
+ // Strictly greater than the deadline below, so the previous ping is always resolved or
593
+ // aborted before the next one opens a stream. `types.spec.ts` holds the two apart.
594
+ pingInterval: 35_000,
595
+ // Frozen at both levels, as `CONTROL_CLUSTER_POLICY` is: one object reaches every
596
+ // libp2p node this process builds, so a mutation anywhere would move the deadline
597
+ // for all of them.
598
+ pingTimeout: Object.freeze({ minTimeout: 30_000, maxTimeout: 30_000 })
599
+ } satisfies Libp2pConnectionMonitorInit);
600
+
474
601
  /**
475
602
  * Hibernation configuration
476
603
  */
@@ -518,12 +645,16 @@ export interface ControlNetworkConfig {
518
645
  *
519
646
  * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
520
647
  * corroboration yardstick declared from the machines enrolled in this party
521
- * ({@link resolveRepairYardstick}); handed no count, each returns its frozen base
522
- * constant unchanged. A network picks the derived number up when its libp2p node is
523
- * built, which for a strand is every wake from hibernation.
648
+ * ({@link resolveRepairYardstick}) and, if the host set one, its own per-peer read
649
+ * deadline in place of {@link COHORT_READ_DEADLINE_MS}. Both arrive in one named
650
+ * declarations object, because both are plain numbers meaning unrelated things;
651
+ * declaring neither returns the frozen base constant unchanged. A network picks the
652
+ * derived numbers up when its libp2p node is built, which for a strand is every wake
653
+ * from hibernation.
524
654
  */
525
655
  export {
526
656
  MIN_CLUSTER_SIZE,
657
+ COHORT_READ_DEADLINE_MS,
527
658
  CONTROL_REPLICATION_BREADTH,
528
659
  CONTROL_CLUSTER_POLICY,
529
660
  DEFAULT_STRAND_CLUSTER_SIZE,
@@ -534,6 +665,14 @@ export {
534
665
  strandClusterPolicy
535
666
  } from '@serfab/quereus-plugin-sereus';
536
667
 
668
+ /**
669
+ * The declared link round trip every cadre-owned dial and reservation deadline is derived from,
670
+ * re-exported beside {@link NetworkConfig.linkRoundTripMs} so a host reading the setting finds
671
+ * the default it replaces. The counts, the measurement and the derivation live in
672
+ * `link-budget.ts`.
673
+ */
674
+ export { DECLARED_LINK_ROUND_TRIP_MS } from './link-budget.js';
675
+
537
676
  /**
538
677
  * Main configuration for a CadreNode
539
678
  */
@@ -667,10 +806,12 @@ export interface CadreNodeConfig {
667
806
  * to a table it has never fetched creates a private copy of it that never merges.
668
807
  * `timeoutMs` bounds how long {@link CadreNode.addStrand} waits before rejecting with
669
808
  * `StrandAwaitingFirstSyncError` (retryable — the launch stays up and keeps probing);
670
- * `pollIntervalMs` is the probe cadence. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`, 30 s;
671
- * `DEFAULT_STRAND_FIRST_SYNC_POLL_MS`, 500 ms). There is deliberately no way to disable
672
- * the gate: a machine that already holds the Header is never gated, so nothing that works
673
- * today is blocked by it.
809
+ * `pollIntervalMs` is the probe cadence. Omit for the defaults (`DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`,
810
+ * 120 s — sized for a machine joining through a relay on a slow link; that constant's doc
811
+ * comment carries the measurement behind the number and what the budget costs, and is the
812
+ * only copy of it; `DEFAULT_STRAND_FIRST_SYNC_POLL_MS`, 500 ms). There is
813
+ * deliberately no way to disable the gate: a machine that already holds the Header is never
814
+ * gated, so nothing that works today is blocked by it.
674
815
  */
675
816
  strandFirstSync?: StrandFirstSyncConfig;
676
817
 
@@ -1246,6 +1387,28 @@ export interface CadreNodeEvents {
1246
1387
  * revoked.
1247
1388
  */
1248
1389
  'strand:revoked': { strandId: string };
1390
+ /**
1391
+ * Emitted when this node holds a staged membership invitation for a CLOSED strand
1392
+ * that its own writes cannot redeem — the shape a REMOVED party hits when a manager
1393
+ * hands it a fresh invitation. Redeeming means writing this party's `Strand.Member`
1394
+ * row into the strand, and the machines that would carry that write are the ones the
1395
+ * remaining members refuse, so the attempt is made and fails. Two triggers, reported
1396
+ * at most once per re-arm of the membership loop (a further invitation staged while the
1397
+ * loop is still running does not reset the report):
1398
+ *
1399
+ * - CONFIRMED: the revoked-peer gate already flags this node as removed (the
1400
+ * `strand:revoked` case) and an invitation is staged.
1401
+ * - PROBABLE: `UNFINISHED_PASSES_BEFORE_ESCALATION` consecutive attempts left the
1402
+ * invitation staged. This one is a SUSPICION, not a verdict — the invitation's row
1403
+ * may simply not have replicated here yet on a slow strand — and the accompanying
1404
+ * warning names both causes.
1405
+ *
1406
+ * Nothing is stopped or torn down. A fresh invitation cannot re-admit a removed
1407
+ * party by itself: the remedy is a remaining manager admitting this party's member
1408
+ * key directly (`addMemberByManager`), after which the membership loop, which keeps
1409
+ * retrying, finishes the join on its own.
1410
+ */
1411
+ 'strand:rejoin-blocked': { strandId: string };
1249
1412
  /**
1250
1413
  * Emitted when the control network advertises a strand this node has no
1251
1414
  * registered `sAppConfig` for — i.e. a strand created by another member, or
@@ -1255,13 +1418,19 @@ export interface CadreNodeEvents {
1255
1418
  * Carries the full {@link StrandRow} so the app can join without re-querying
1256
1419
  * the control DB.
1257
1420
  *
1258
- * **Fired exactly ONCE per strand per session, and it can fire before your
1259
- * listener is attached.** The strand watcher's first poll runs inside
1260
- * `CadreNode.start()` (100 ms after the watcher starts), so every strand
1261
- * already stored for this party is normally offered while the embedding app is
1262
- * still inside its own `start()` await. The watcher then records the strand as
1263
- * seen and no later poll re-offers it, so a listener attached a moment late
1264
- * misses those strands for the life of the process.
1421
+ * **Fired once per strand per session, and it can fire before your listener is
1422
+ * attached.** The strand watcher's first poll runs inside `CadreNode.start()`
1423
+ * (100 ms after the watcher starts), so every strand already stored for this
1424
+ * party is normally offered while the embedding app is still inside its own
1425
+ * `start()` await. The watcher then records the strand as seen and no later poll
1426
+ * re-offers it, so a listener attached a moment late misses those strands for the
1427
+ * life of the process.
1428
+ *
1429
+ * One exception, and it does NOT produce a second event: when an `addStrand` that
1430
+ * claims a discovered strand FAILS, the watcher is told to forget the strand so a
1431
+ * later poll retries it. That claim left the sApp config registered, so the retry
1432
+ * takes the auto-launch branch — what the app sees is `strand:error` per failed
1433
+ * retry and `strand:started` when one succeeds.
1265
1434
  *
1266
1435
  * So an app that auto-joins discovered strands must **subscribe first, then
1267
1436
  * drain `CadreNode.getDiscoveredStrands()`** — the map of strands no local