@serfab/cadre-core 1.6.0 → 1.8.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 (179) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +548 -137
  3. package/dist/cadre-node.js +1208 -383
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +13 -6
  45. package/dist/index.js +27 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +177 -0
  48. package/dist/joined-strand-store.js +431 -0
  49. package/dist/joined-strand-store.js.map +1 -0
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/key-store.d.ts +6 -0
  53. package/dist/key-store.js.map +1 -1
  54. package/dist/link-budget.d.ts +367 -69
  55. package/dist/link-budget.js +375 -72
  56. package/dist/link-budget.js.map +1 -1
  57. package/dist/membership-connection-gater.d.ts +30 -43
  58. package/dist/membership-connection-gater.js +31 -42
  59. package/dist/membership-connection-gater.js.map +1 -1
  60. package/dist/node-local-snapshot.d.ts +5 -4
  61. package/dist/node-local-snapshot.js +6 -3
  62. package/dist/node-local-snapshot.js.map +1 -1
  63. package/dist/peer-dial.d.ts +24 -4
  64. package/dist/peer-dial.js +74 -5
  65. package/dist/peer-dial.js.map +1 -1
  66. package/dist/peer-join-backfill.d.ts +35 -15
  67. package/dist/peer-join-backfill.js +115 -55
  68. package/dist/peer-join-backfill.js.map +1 -1
  69. package/dist/push-fanout.d.ts +6 -0
  70. package/dist/push-fanout.js +6 -0
  71. package/dist/push-fanout.js.map +1 -1
  72. package/dist/relay-reservation.d.ts +3 -1
  73. package/dist/relay-reservation.js +3 -1
  74. package/dist/relay-reservation.js.map +1 -1
  75. package/dist/relay-server.d.ts +79 -0
  76. package/dist/relay-server.js +74 -0
  77. package/dist/relay-server.js.map +1 -0
  78. package/dist/seed-bootstrap.d.ts +57 -4
  79. package/dist/seed-bootstrap.js +107 -83
  80. package/dist/seed-bootstrap.js.map +1 -1
  81. package/dist/storage-scope.d.ts +33 -17
  82. package/dist/storage-scope.js +44 -23
  83. package/dist/storage-scope.js.map +1 -1
  84. package/dist/strand-addr-protocol.d.ts +58 -21
  85. package/dist/strand-addr-protocol.js +133 -85
  86. package/dist/strand-addr-protocol.js.map +1 -1
  87. package/dist/strand-database.d.ts +20 -2
  88. package/dist/strand-database.js +42 -5
  89. package/dist/strand-database.js.map +1 -1
  90. package/dist/strand-first-sync-gate.d.ts +59 -21
  91. package/dist/strand-first-sync-gate.js +58 -19
  92. package/dist/strand-first-sync-gate.js.map +1 -1
  93. package/dist/strand-formation-deadlines.d.ts +151 -0
  94. package/dist/strand-formation-deadlines.js +171 -0
  95. package/dist/strand-formation-deadlines.js.map +1 -0
  96. package/dist/strand-formation-manager.d.ts +63 -30
  97. package/dist/strand-formation-manager.js +67 -41
  98. package/dist/strand-formation-manager.js.map +1 -1
  99. package/dist/strand-formation-protocol.d.ts +45 -35
  100. package/dist/strand-formation-protocol.js +59 -122
  101. package/dist/strand-formation-protocol.js.map +1 -1
  102. package/dist/strand-instance-manager.d.ts +152 -6
  103. package/dist/strand-instance-manager.js +331 -42
  104. package/dist/strand-instance-manager.js.map +1 -1
  105. package/dist/strand-membership-writer.d.ts +6 -0
  106. package/dist/strand-membership-writer.js +11 -0
  107. package/dist/strand-membership-writer.js.map +1 -1
  108. package/dist/strand-peer-book-file.d.ts +12 -0
  109. package/dist/strand-peer-book-file.js +37 -0
  110. package/dist/strand-peer-book-file.js.map +1 -0
  111. package/dist/strand-peer-book-protocol.d.ts +168 -0
  112. package/dist/strand-peer-book-protocol.js +356 -0
  113. package/dist/strand-peer-book-protocol.js.map +1 -0
  114. package/dist/strand-peer-book-swap.d.ts +104 -0
  115. package/dist/strand-peer-book-swap.js +284 -0
  116. package/dist/strand-peer-book-swap.js.map +1 -0
  117. package/dist/strand-peer-book.d.ts +159 -0
  118. package/dist/strand-peer-book.js +361 -0
  119. package/dist/strand-peer-book.js.map +1 -0
  120. package/dist/strand-peer-observer.d.ts +88 -0
  121. package/dist/strand-peer-observer.js +180 -0
  122. package/dist/strand-peer-observer.js.map +1 -0
  123. package/dist/strand-revocation-enforcer.js +2 -1
  124. package/dist/strand-revocation-enforcer.js.map +1 -1
  125. package/dist/strand-solicitation.d.ts +36 -20
  126. package/dist/strand-solicitation.js +4 -4
  127. package/dist/strand-solicitation.js.map +1 -1
  128. package/dist/strand-wake-protocol.d.ts +80 -23
  129. package/dist/strand-wake-protocol.js +89 -64
  130. package/dist/strand-wake-protocol.js.map +1 -1
  131. package/dist/types.d.ts +181 -33
  132. package/dist/types.js +4 -3
  133. package/dist/types.js.map +1 -1
  134. package/package.json +11 -7
  135. package/src/cadre-node.ts +1316 -398
  136. package/src/control-authorization.ts +2 -1
  137. package/src/control-cohort.ts +6 -0
  138. package/src/control-database.ts +122 -18
  139. package/src/control-formation-recorder.ts +354 -305
  140. package/src/control-read-retry.ts +14 -12
  141. package/src/control-schema.ts +67 -5
  142. package/src/control-stream.ts +25 -1
  143. package/src/control-write-retry.ts +13 -2
  144. package/src/delegate-admission.ts +23 -13
  145. package/src/diagnostics/connection-path.ts +0 -7
  146. package/src/formation-approval.ts +610 -604
  147. package/src/fs-atomic.ts +14 -4
  148. package/src/hibernation-manager.ts +638 -437
  149. package/src/index.ts +91 -6
  150. package/src/joined-strand-store.ts +498 -0
  151. package/src/key-store-file.ts +14 -5
  152. package/src/key-store.ts +6 -0
  153. package/src/link-budget.ts +393 -74
  154. package/src/membership-connection-gater.ts +31 -45
  155. package/src/node-local-snapshot.ts +293 -289
  156. package/src/peer-dial.ts +81 -7
  157. package/src/peer-join-backfill.ts +120 -57
  158. package/src/push-fanout.ts +6 -0
  159. package/src/relay-reservation.ts +3 -1
  160. package/src/relay-server.ts +98 -0
  161. package/src/seed-bootstrap.ts +133 -91
  162. package/src/storage-scope.ts +44 -23
  163. package/src/strand-addr-protocol.ts +547 -458
  164. package/src/strand-database.ts +287 -242
  165. package/src/strand-first-sync-gate.ts +58 -19
  166. package/src/strand-formation-deadlines.ts +227 -0
  167. package/src/strand-formation-manager.ts +700 -647
  168. package/src/strand-formation-protocol.ts +103 -150
  169. package/src/strand-instance-manager.ts +387 -45
  170. package/src/strand-membership-writer.ts +12 -0
  171. package/src/strand-peer-book-file.ts +39 -0
  172. package/src/strand-peer-book-protocol.ts +466 -0
  173. package/src/strand-peer-book-swap.ts +338 -0
  174. package/src/strand-peer-book.ts +475 -0
  175. package/src/strand-peer-observer.ts +235 -0
  176. package/src/strand-revocation-enforcer.ts +2 -1
  177. package/src/strand-solicitation.ts +519 -500
  178. package/src/strand-wake-protocol.ts +401 -355
  179. package/src/types.ts +185 -33
@@ -14,7 +14,7 @@ import { isUncommittedTransactorAggregate } from './control-write-retry.js';
14
14
  * was absorbed). This module gives reads the same bounded re-presentation with a SHORTER
15
15
  * deadline and a NARROWER failure set, because the tightest caller deadline over a control
16
16
  * read is the inbound admission gate's 2 s fail-open timeout
17
- * (`ADMISSION_DECISION_TIMEOUT_MS`, `membership-connection-gater.ts`) — a read budget that
17
+ * (`ADMISSION_DECISION_TIMEOUT_MS`, `link-budget.ts`) — a read budget that
18
18
  * does not fit inside it with headroom spends its retries after the gate has already
19
19
  * admitted.
20
20
  *
@@ -67,18 +67,20 @@ export const CONTROL_READ_RETRY_DELAYS_MS: readonly number[] = [100, 400];
67
67
  * 2 s only because each stays well under it. Do not raise these numbers without
68
68
  * re-checking against `ADMISSION_DECISION_TIMEOUT_MS`.
69
69
  *
70
- * NOTE: the obligation runs the other way too, and the number that dominates this budget is
71
- * declared in another package. One attempt whose cohort consult finds a silent peer costs
72
- * `clusterPolicy.cohortQueryTimeoutMs`, which sereus declares at 5000 ms
73
- * (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`) for relayed phone
74
- * links — more than three times this budget, so such an attempt is never retried and the
75
- * admission gate above it has already fail-opened. That is a widened cost of a deliberate
76
- * choice, not a hole (the stream gates still decide), and the whole ladder of cadre-core
77
- * deadlines against Optimystic's is audited by
78
- * `backlog/debt-cadre-deadlines-sized-against-old-optimystic-bounds`. What this budget still
79
- * buys is the failure it was built for: the ~25 ms transactor read-phase aggregate off a
80
- * stream still forming, which retries twice well inside 1500 ms.
70
+ * It cuts the loop off by design, so it does not grow with the link or with the number that
71
+ * dominates a slow attempt: an attempt whose cohort consult finds a silent peer costs
72
+ * `clusterPolicy.cohortQueryTimeoutMs`, two link round trips at the declared link
73
+ * (`cohortReadDeadlineMs` in `link-budget.ts`, 7 000 ms at the default declaration; the plugin's
74
+ * `COHORT_READ_DEADLINE_MS` is the same number). That is more than four times this budget, so
75
+ * such an attempt is never retried and the caller gets its error unchanged. That is deliberate:
76
+ * the caller this budget is sized for, the admission gate, has
77
+ * already taken its fail-open answer by then, and a retry would only spend time after it. What
78
+ * the budget still buys is the fast failures it was built for: the ~25 ms transactor read-phase
79
+ * aggregate off a stream still forming, and a `cohort-unreachable` read during bring-up, which
80
+ * fails fast because there is no connection to ask. Both retry twice well inside 1500 ms. See
81
+ * `docs/cadre-consistency.md` → "Deadlines Over Optimystic's Reads and Commits".
81
82
  */
83
+ // eslint-disable-next-line no-restricted-syntax -- cuts off by design: a slow attempt is not retried, because the admission gate this budget is sized for has already failed open by then; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
82
84
  export const CONTROL_READ_RETRY_BUDGET_MS = 1_500;
83
85
 
84
86
  /**
@@ -365,6 +365,65 @@ declare schema CadreControl {
365
365
  )
366
366
  ) with context (OwnerKey text, Signature text);
367
367
 
368
+ -- A strand THIS party joined from ANOTHER party. The founding party's control database
369
+ -- holds its Strand row, so without this table only the machine that joined would know of
370
+ -- it. Every machine of the party reads it (the strand watcher offers each row like a
371
+ -- Strand row), so a replica host keeps a copy and the party's other machines can attach.
372
+ -- A separate table rather than a Strand row with a provenance marker: Strand means "this
373
+ -- party's own strand" to every reader of it (founder derivation, FormationUsage.StrandExists,
374
+ -- unpublish, the consent branch of Strand.AuthorizedInsert), and each would need a
375
+ -- provenance check. MemberPrivateKey is the closed strand's shared read secret the
376
+ -- formation delivered — replicated in plaintext to every machine the party owns, under the
377
+ -- same accepted risk as Strand.MemberPrivateKey (docs/strands.md → "Closed-Strand Member
378
+ -- Key Handling"). Unlike that column it is not held only here: the founding party and
379
+ -- every member hold it, so a removed row is recoverable by re-forming, which is why this
380
+ -- table (unlike Strand) carries a REAP branch.
381
+ table JoinedStrand (
382
+ Id text primary key, -- the joined strand's id (the founder's Strand.Id)
383
+ Type text not null, -- 'o' | 'c', as Strand.Type
384
+ MemberPrivateKey text null, -- closed strand's shared read secret; null for open
385
+ StampId text not null unique, -- single-use authorization nonce (anti-replay).
386
+ -- \`unique\` holds over LIVE rows only; a removed row's stamp is
387
+ -- retired permanently into Revocation (NotRevoked below).
388
+ -- The approval that seated a removed row can never re-seat it. Same rationale as
389
+ -- OwnerKey.NotRevoked, stated in full there.
390
+ constraint NotRevoked check on insert (
391
+ not exists (select 1 from Revocation R where R.TableName = 'JoinedStrand' and R.StampId = new.StampId)
392
+ ),
393
+ -- A delete must file its correctly named tombstone in the same transaction. Same
394
+ -- rationale as StrandPartyKey.RevocationRecorded.
395
+ constraint RevocationRecorded check on delete (
396
+ exists (select 1 from Revocation R where R.TableName = 'JoinedStrand' and R.StampId = old.StampId and R.RowKey = old.Id)
397
+ ),
398
+ -- Insert + delete only: neither Authorized rule below covers update, so an in-place
399
+ -- rewrite would swap the read secret unsigned. Mirrors StrandPartyKey.NoUpdate.
400
+ constraint NoUpdate check on update (false),
401
+ -- Readers cast Type to 'o' | 'c' (ControlDatabase.queryJoinedStrands).
402
+ constraint KnownType check (new.Type = 'o' or new.Type = 'c'),
403
+ -- An open strand has no read gate, so no read secret. Same rule as Strand.MemberKeyClosedOnly.
404
+ constraint MemberKeyClosedOnly check (new.MemberPrivateKey is null or new.Type = 'c'),
405
+ constraint AuthorizedInsert check on insert (
406
+ -- Owners authorize by signing over THIS row (Id, Type, MemberPrivateKey, StampId),
407
+ -- MemberPrivateKey signing as '' when null, so a captured approval can only reproduce
408
+ -- the exact secret it approved. Owner-signed, deliberately NOT self-signable by an
409
+ -- enrolled CadrePeer: every always-on machine of the party downloads the strands this
410
+ -- table names, so a non-owner machine could otherwise make them all host an arbitrary
411
+ -- strand. A machine that is not an owner keeps its joins machine-local.
412
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.JoinedStrand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
413
+ ),
414
+ constraint AuthorizedDelete check on delete (
415
+ -- 'remove'-tagged digest over the STORED row (Id, StampId), so the add approval can
416
+ -- never be replayed as a removal.
417
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.JoinedStrand', 'remove', old.Id, old.StampId), context.Signature, A.Key, 'ed25519'))
418
+
419
+ -- or REAP: a COMMITTED tombstone already retires this exact row incarnation.
420
+ -- Why committed.* and why the stamp is bound: stated in full on
421
+ -- CadrePeer.AuthorizedDelete. Allowed here, unlike Strand / StrandPartyKey,
422
+ -- because MemberPrivateKey is recoverable (see the table comment).
423
+ or exists (select 1 from committed.Revocation R where R.TableName = 'JoinedStrand' and R.RowKey = old.Id and R.StampId = old.StampId)
424
+ )
425
+ ) with context (OwnerKey text, Signature text);
426
+
368
427
  -- A peer (node) that is part of the cadre, carrying a self-published,
369
428
  -- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
370
429
  -- The row IS the peer-address record: a resolver reads it, re-verifies Sig against
@@ -785,8 +844,8 @@ declare schema CadreControl {
785
844
  -- thing to suspect.
786
845
  index FormationUsageByToken on FormationUsage (Token);
787
846
 
788
- -- Append-only retirement record for the one-off StampId nonces of removed OwnerKey /
789
- -- CadrePeer / ValidationKey / Strand rows. Without it a removal was undoable: the add
847
+ -- Append-only retirement record for the one-off StampId nonces of removed rows of every
848
+ -- guarded table (listed on TableName below). Without it a removal was undoable: the add
790
849
  -- approval is a signature over
791
850
  -- (row key, stamp) that never expires, and deleting the row freed the stamp, so anyone who
792
851
  -- kept a copy of the approval could resurrect the row.
@@ -815,10 +874,12 @@ declare schema CadreControl {
815
874
  -- its own guarded TableName, 'Revocation' is not one, and ControlDatabase.queryRevocations
816
875
  -- skips the marker, so nothing reaps or re-issues it.
817
876
  table Revocation (
818
- TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'DeviceToken',
819
- -- or 'Revocation' for the ledger marker only (all confined by RowIsGone below)
877
+ TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' |
878
+ -- 'JoinedStrand' | 'DeviceToken', or 'Revocation' for the ledger marker only
879
+ -- (all confined by RowIsGone below)
820
880
  RowKey text not null, -- primary key of the removed row: OwnerKey.Key / ValidationKey.Key /
821
- -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id / StrandPartyKey.Id
881
+ -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id / StrandPartyKey.Id /
882
+ -- JoinedStrand.Id
822
883
  -- Every guarded table's RevocationRecorded CHECK requires the
823
884
  -- accompanying tombstone to carry the removed row's key here, so
824
885
  -- a REMOVAL cannot file a misnamed tombstone. A tombstone with no
@@ -869,6 +930,7 @@ declare schema CadreControl {
869
930
  or (new.TableName = 'ValidationKey' and not exists (select 1 from ValidationKey V where V.StampId = new.StampId))
870
931
  or (new.TableName = 'Strand' and not exists (select 1 from Strand S where S.StampId = new.StampId))
871
932
  or (new.TableName = 'StrandPartyKey' and not exists (select 1 from StrandPartyKey K where K.StampId = new.StampId))
933
+ or (new.TableName = 'JoinedStrand' and not exists (select 1 from JoinedStrand J where J.StampId = new.StampId))
872
934
  or (new.TableName = 'DeviceToken' and not exists (select 1 from DeviceToken D where D.StampId = new.StampId))
873
935
  or (new.TableName = 'Revocation' and new.RowKey = 'ledger' and new.StampId = 'opened')
874
936
  ),
@@ -13,7 +13,8 @@
13
13
  * deadline, write one frame, half-close, read one response, reset the stream on
14
14
  * any failure — so that shape lives here too, as {@link withDeadline} +
15
15
  * {@link exchangeFrame}; each protocol supplies only its dial options, its
16
- * request object, and its response decoder.
16
+ * request object, and its response decoder. The receiver's half — one reply
17
+ * frame, then close — is {@link replyAndClose}.
17
18
  *
18
19
  * Dependency-free by design: it imports nothing from the protocol modules, so
19
20
  * the import graph stays acyclic (`control-stream` ← `seed-bootstrap` ←
@@ -22,6 +23,10 @@
22
23
  * `decodeLengthPrefixedFrame` guard.
23
24
  */
24
25
 
26
+ import debug from 'debug';
27
+
28
+ const log = debug('sereus:cadre:control-stream');
29
+
25
30
  /**
26
31
  * Minimal libp2p 3.x stream surface: AsyncIterable for reads, `send()` for
27
32
  * writes, `close()` to half-close the write end (EOF), `abort()` to reset.
@@ -41,6 +46,25 @@ export function writeFrame(stream: ControlStream, obj: unknown): void {
41
46
  stream.send(body);
42
47
  }
43
48
 
49
+ /**
50
+ * Receiver side: write one reply frame and close the stream, best-effort on both and never
51
+ * throwing. A peer that has already gone away cannot be answered, and nothing the receiver
52
+ * decided depends on whether it heard. The close is what releases the sender, which reads the
53
+ * reply to end-of-stream ({@link exchangeFrame}).
54
+ */
55
+ export async function replyAndClose(stream: ControlStream, reply: unknown, label: string): Promise<void> {
56
+ try {
57
+ writeFrame(stream, reply);
58
+ } catch (error) {
59
+ log('%s: failed to write reply: %o', label, error);
60
+ }
61
+ try {
62
+ await stream.close();
63
+ } catch (error) {
64
+ log('%s: failed to close stream: %o', label, error);
65
+ }
66
+ }
67
+
44
68
  /**
45
69
  * Reject if `op` does not settle within `ms`. On timeout, invoke `onTimeout`
46
70
  * before rejecting — a hook for releasing whatever the op is parked on. A
@@ -77,11 +77,21 @@ export const SCHEMA_INIT_ATTEMPTS = 5;
77
77
  * This budget — not the attempt count — is what makes the policy safe to sit under every
78
78
  * control write. A transient failure (stream reset while a connection is still forming)
79
79
  * surfaces in well under a second, so it is retried and the loop adds at most ~2.2 s. A
80
- * genuinely silent cohort member fails at ~20 s (two 10 s `ClusterClient` response-deadline
81
- * attempts, measured in `control-write-degraded-cohort-member.integration.ts`), which
80
+ * genuinely silent cohort member fails at ~21 s per pend round (two `ClusterClient`
81
+ * response-deadline attempts, 10.5 s each at the default declared link; a two-round write
82
+ * measured 42.2 s in `control-write-degraded-cohort-member.integration.ts`), which
82
83
  * already exceeds this budget when attempt 1 returns — so that case is surfaced immediately
83
84
  * and retry adds ZERO latency to the case where it cannot help.
84
85
  *
86
+ * It cuts the loop off by design, so it does not grow with the link: its job is to end the
87
+ * retries of slow attempts, and the caller gets the last attempt's error unchanged. An attempt
88
+ * whose read phase consulted a silent peer ends at about one per-peer read deadline
89
+ * (`cohortReadDeadlineMs` in `link-budget.ts`: 7 s at the default declared link, 5 s before it
90
+ * was derived, 1 s at Optimystic's own default), so it still gets one retry inside this budget
91
+ * where it once got two; an attempt that runs into the `ClusterClient` response deadline
92
+ * (10.5 s at the default declared link) still gets none. See `docs/cadre-consistency.md` →
93
+ * "Deadlines Over Optimystic's Reads and Commits".
94
+ *
85
95
  * NOTE: a failed commit attempt now also pays a cancel discharge before its error returns
86
96
  * (optimystic `TransactorSource.transact`'s catch, added by upstream
87
97
  * `1-a-failed-attempt-must-discharge-its-own-pend`) — bounded by six rounds and
@@ -93,6 +103,7 @@ export const SCHEMA_INIT_ATTEMPTS = 5;
93
103
  * to do: if that case ever starts failing with `failed after 2/3 attempt(s)`, the cancel
94
104
  * discharge is where the time went.
95
105
  */
106
+ // eslint-disable-next-line no-restricted-syntax -- cuts off by design: it ends the retries of slow attempts rather than waiting them out; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
96
107
  export const CONTROL_WRITE_RETRY_BUDGET_MS = 10_000;
97
108
 
98
109
  /**
@@ -35,7 +35,7 @@
35
35
  * The receiver side is {@link DelegateAdmissionStore}. The announcer side keeps
36
36
  * its own throttle state (a "last announced at" map that `CadreNode` owns) and
37
37
  * decides from it, per reconcile pass, which relays are due a re-announce —
38
- * {@link dueRelayAnnounces} and {@link pruneStoppedStrandAnnounces} are that
38
+ * {@link dueRelayAnnounces} and {@link prunePeerStrandKeys} are that
39
39
  * decision, pure and testable, kept here beside the TTL they are derived from.
40
40
  */
41
41
 
@@ -71,14 +71,20 @@ interface DelegateGrant {
71
71
  }
72
72
 
73
73
  /**
74
- * Composite map key for the per-(peer, strand) state on both sides: the
75
- * receiver's replace-per-(announcer, strand) grant, and the announcer's
76
- * per-(target, strand) throttle timestamp.
74
+ * Composite map key for per-(peer, strand) state: the receiver's
75
+ * replace-per-(announcer, strand) grant, the announcer's per-(target, strand)
76
+ * throttle timestamp, and `CadreNode`'s per-(sibling, strand) strand-addr ask
77
+ * due time.
77
78
  */
78
79
  export function peerStrandKey(peerId: string, strandId: string): string {
79
80
  return `${peerId}\n${strandId}`;
80
81
  }
81
82
 
83
+ /** The `peerId` half of a {@link peerStrandKey} (a peer id never contains a newline). */
84
+ function peerIdOfKey(key: string): string {
85
+ return key.slice(0, key.indexOf('\n'));
86
+ }
87
+
82
88
  /** The `strandId` half of a {@link peerStrandKey}. */
83
89
  function strandIdOfKey(key: string): string {
84
90
  return key.slice(key.indexOf('\n') + 1);
@@ -233,17 +239,21 @@ export function dueRelayAnnounces(
233
239
  }
234
240
 
235
241
  /**
236
- * Drop `announceAt` entries whose strand is no longer running — a stopped
237
- * strand needs no refresh, and without this the map grows for the node's
238
- * lifetime. Mutates in place.
242
+ * Drop the entries of a {@link peerStrandKey}-keyed map whose strand is no longer
243
+ * running, and — when `livePeerIds` is given — whose peer is not among them. A
244
+ * stopped strand needs no refresh, a departed peer is owed a fresh start when it
245
+ * returns, and without this the map grows for the node's lifetime. Mutates in
246
+ * place.
239
247
  */
240
- export function pruneStoppedStrandAnnounces(
241
- announceAt: Map<string, number>,
242
- runningStrandIds: ReadonlySet<string>
248
+ export function prunePeerStrandKeys(
249
+ byKey: Map<string, number>,
250
+ runningStrandIds: ReadonlySet<string>,
251
+ livePeerIds?: ReadonlySet<string>
243
252
  ): void {
244
- for (const key of announceAt.keys()) {
245
- if (!runningStrandIds.has(strandIdOfKey(key))) {
246
- announceAt.delete(key);
253
+ for (const key of byKey.keys()) {
254
+ const peerGone = livePeerIds !== undefined && !livePeerIds.has(peerIdOfKey(key));
255
+ if (peerGone || !runningStrandIds.has(strandIdOfKey(key))) {
256
+ byKey.delete(key);
247
257
  }
248
258
  }
249
259
  }
@@ -3,13 +3,6 @@
3
3
  * (`/p2p-circuit`) vs direct, tag its transport, and summarise counts plus a
4
4
  * "stuck on relay" condition.
5
5
  *
6
- * This is the **canonical** implementation. The web reference app keeps a
7
- * deliberate, documented duplicate at
8
- * `packages/reference-app-web/src/lib/connection-path.ts` (it does not depend on
9
- * `@serfab/cadre-core`). Keep the function signatures and classification table
10
- * identical across both copies so a future shared micro-package would be a
11
- * drop-in.
12
- *
13
6
  * The whole signal is a pure function over `node.getConnections()` + a settle
14
7
  * window + `Date.now()`. A connection's path is derivable from its `remoteAddr`
15
8
  * multiaddr string alone — no I/O, no events, no stateful observer.