@serfab/cadre-core 1.17.0 → 1.19.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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1182 -566
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +17 -14
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -172,9 +172,9 @@ export class PersistentEnrolledMachineStore implements EnrolledMachineStore {
172
172
  * Record the count: visible via {@link count} synchronously, then persisted.
173
173
  *
174
174
  * Writes are SKIPPED when the slot already holds this number, because the sole
175
- * caller runs on every committed membership write *and* every timed reconcile
176
- * pass — a party whose membership is stable would otherwise rewrite the same
177
- * integer forever. The skip is keyed off {@link persisted} rather than
175
+ * caller runs on every membership refresh — each committed membership write, each
176
+ * replicated membership change, and each reconcile pass down to its backstop — so a
177
+ * party whose membership is stable would otherwise rewrite the same integer forever. The skip is keyed off {@link persisted} rather than
178
178
  * {@link current}, so a write that FAILED is retried by the next refresh instead
179
179
  * of being suppressed as "unchanged".
180
180
  */
@@ -0,0 +1,63 @@
1
+ import type { FormationRejectionCode } from './strand-formation-rejection.js';
2
+
3
+ /** The `FormationInvite` columns a formation session acts on. */
4
+ export interface FormationInviteFacts {
5
+ sAppId: string;
6
+ /** Epoch ms of the stored `ExpiresAt`; null when the invitation never expires. */
7
+ expiresAtMs: number | null;
8
+ /** Null is unlimited. */
9
+ totalUses: number | null;
10
+ /** A bound invitation's host strand; null on the responder-provisions path. */
11
+ strandId: string | null;
12
+ /** The outside approval hook; null when no approval is required. */
13
+ validationUrl: string | null;
14
+ }
15
+
16
+ /**
17
+ * One formation token as read once at the start of a responder session. Every later step of
18
+ * the session takes this value instead of reading the invite again.
19
+ */
20
+ export interface FormationTokenRead {
21
+ token: string;
22
+ /** Null: no invite row here (it may not have replicated to this machine yet). */
23
+ invite: FormationInviteFacts | null;
24
+ /** Uses recorded so far; counted only for a metered, unexpired invite, else null. */
25
+ usesRecorded: number | null;
26
+ }
27
+
28
+ /**
29
+ * What the responder's token check yields; an invalid token names which way it failed. The
30
+ * valid arm carries the session's {@link FormationTokenRead}, absent only when the manager has
31
+ * no recorder and so accepts every token with nothing read.
32
+ */
33
+ export type FormationTokenCheck =
34
+ | { valid: true; read?: FormationTokenRead }
35
+ | { valid: false; code: Extract<FormationRejectionCode, 'token-unknown' | 'token-spent'> };
36
+
37
+ /**
38
+ * `expiresAtMs <= nowMs` is expired: the same boundary `hasOutstandingFormationInvite` applies,
39
+ * so an invite the formation handler refuses can never hold the connection gate open.
40
+ */
41
+ export function isInviteExpired(invite: FormationInviteFacts, nowMs: number): boolean {
42
+ return invite.expiresAtMs !== null && invite.expiresAtMs <= nowMs;
43
+ }
44
+
45
+ /** Used up: a metered invite whose recorded uses reached `totalUses`. An unknown token is not used up. */
46
+ export function isTokenReadSpent(read: FormationTokenRead): boolean {
47
+ const totalUses = read.invite?.totalUses ?? null;
48
+ return totalUses !== null && read.usesRecorded !== null && read.usesRecorded >= totalUses;
49
+ }
50
+
51
+ /**
52
+ * No invite row is `'token-unknown'` (retryable: the row may not have replicated to this
53
+ * machine yet); an expired or used-up invite is `'token-spent'` (final).
54
+ */
55
+ export function classifyTokenRead(read: FormationTokenRead, nowMs: number): FormationTokenCheck {
56
+ if (read.invite === null) {
57
+ return { valid: false, code: 'token-unknown' };
58
+ }
59
+ if (isInviteExpired(read.invite, nowMs) || isTokenReadSpent(read)) {
60
+ return { valid: false, code: 'token-spent' };
61
+ }
62
+ return { valid: true, read };
63
+ }
package/src/index.ts CHANGED
@@ -16,12 +16,13 @@ export { MachineRemovalRefusedError } from './machine-removal.js';
16
16
  export { OwnerKeyAddRefusedError, OwnerKeyRemovalRefusedError } from './owner-keys.js';
17
17
 
18
18
  // Control database
19
- export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, OwnerGrantIssuerUnknownError, IssuedRowNotHereYetError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, ownerGrantAddMessage, ownerGrantRedeemMessage, ownerGrantConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type ControlSign, type OwnerGrantRedemptionFields } from './control-database.js';
19
+ export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, OwnerGrantIssuerUnknownError, IssuedRowNotHereYetError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, ownerGrantAddMessage, ownerGrantRedeemMessage, ownerGrantConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type FoundingRows, type FoundingInvite, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type ControlSign, type SelfRecordSigner, type CadrePeerRetouchMode, type CadrePeerRetouchOutcome, type OwnerGrantRedemptionFields } from './control-database.js';
20
20
 
21
- // What `start()` throws when the control store was written with a control schema this build
22
- // cannot apply to it (recreate procedure: docs/architecture.md)
23
- export { CONTROL_SCHEMA_VERSION, CONTROL_SCHEMA_BASELINE_VERSION } from './control-schema.js';
24
- export { ControlStoreIncompatibleError } from './control-schema-version.js';
21
+ // The control schema version a store is migrated to on start, and what `start()` throws, with a
22
+ // reason, when the store must not be migrated (docs/architecture.md → "Control schema versions and
23
+ // migrations")
24
+ export { CONTROL_SCHEMA_VERSION, CONTROL_SCHEMA_BASELINE_VERSION, CONTROL_SCHEMA_VERSION_TAG } from './control-schema.js';
25
+ export { ControlStoreIncompatibleError, type ControlStoreIncompatibility } from './control-schema-version.js';
25
26
 
26
27
  // Bounded retry for transient control-write failures (classifier + loop behind
27
28
  // ControlDatabase.lockedWithRetry; exported so the integration package can drive the
@@ -29,9 +30,11 @@ export { ControlStoreIncompatibleError } from './control-schema-version.js';
29
30
  export {
30
31
  CONTROL_WRITE_ATTEMPTS,
31
32
  CONTROL_WRITE_RETRY_BUDGET_MS,
33
+ LOST_UPDATE_RETRY_POLICY,
32
34
  SCHEMA_INIT_ATTEMPTS,
33
35
  SCHEMA_INIT_RETRY_POLICY,
34
36
  isRetriableControlWriteFailure,
37
+ isRetriableLostUpdateFailure,
35
38
  isRetriableSchemaInitFailure,
36
39
  retryControlWrite,
37
40
  type ControlWriteRetryOptions
@@ -236,10 +239,7 @@ export {
236
239
  export {
237
240
  StrandAwaitingFirstSyncError,
238
241
  DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS,
239
- DEFAULT_STRAND_FIRST_SYNC_POLL_MS,
240
- strandHeaderHeld,
241
- appTablesReadable,
242
- strandFirstSyncComplete,
242
+ DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS,
243
243
  type StrandFirstSyncConfig
244
244
  } from './strand-first-sync-gate.js';
245
245
 
@@ -249,7 +249,6 @@ export {
249
249
  export {
250
250
  PeerJoinBackfill,
251
251
  DEFAULT_PEER_JOIN_BACKFILL,
252
- MAX_BLOCK_MESSAGE_BYTES,
253
252
  PEER_JOIN_BACKFILL_WARN_AFTER_FAILURES,
254
253
  type PeerJoinBackfillConfig,
255
254
  type PeerJoinBackfillDeps,
@@ -269,14 +268,15 @@ export {
269
268
  StrandRevocationEnforcer,
270
269
  createRevocationConnectionGater,
271
270
  readStrandRevocationRows,
272
- DEFAULT_REVOCATION_POLL_INTERVAL_MS,
271
+ DEFAULT_REVOCATION_BACKSTOP_MS,
272
+ REVOCATION_REFRESH_RETRY_MS,
273
+ REVOCATION_WIDE_STRAND_REFRESH_MS,
273
274
  type StrandRevocationEnforcementConfig,
274
275
  type StrandRevocationEnforcerDeps,
275
276
  type StrandRevocationRows,
276
277
  type StrandMemberPeerBinding,
277
278
  type StrandRevocationJudge,
278
- type StrandRevocationNetwork,
279
- type RevocationRefreshScheduler
279
+ type StrandRevocationNetwork
280
280
  } from './strand-revocation-enforcer.js';
281
281
 
282
282
  // Closed-strand membership reconciliation: the bring-up loop that finishes a
@@ -285,6 +285,7 @@ export {
285
285
  // bring-up (see the module doc for the pass ladder and terminal states).
286
286
  export {
287
287
  StrandMembershipReconciler,
288
+ DEFAULT_MEMBERSHIP_RECONCILE_INTERVAL_MS,
288
289
  IDLE_PASSES_BEFORE_ESCALATION,
289
290
  INITIAL_JOIN_RETRY_INTERVAL_MS,
290
291
  type StrandMembershipReconcilerDeps,
@@ -292,8 +293,8 @@ export {
292
293
  type PendingMembershipInviteSource
293
294
  } from './strand-membership-reconciler.js';
294
295
 
295
- // The timer seam the self-rescheduling strand bring-up loops share (first-sync gate
296
- // probe loop, membership retry ladder) — injected by tests as a hand-cranked clock.
296
+ // The timer seam the self-rescheduling loops share (membership retry ladder, the table
297
+ // change schedule the first-sync gate probes on) — injected by tests as a hand-cranked clock.
297
298
  export { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
298
299
 
299
300
  // Hibernation
@@ -324,6 +325,7 @@ export {
324
325
  type FormationUsageRecorder,
325
326
  type FormationUsageParams,
326
327
  type AuthorizedFormationUsage,
328
+ type BoundHostStrand,
327
329
  type ResolvedHostStrand,
328
330
  type StrandProvisioner,
329
331
  type FormationResponseValidator,
@@ -420,6 +422,7 @@ export {
420
422
  SeedBootstrapService,
421
423
  SEED_PROTOCOL,
422
424
  mergeSeedPeers,
425
+ seedDialTargets,
423
426
  seedDigest,
424
427
  type SeedBootstrapConfig,
425
428
  type SeedEventCallbacks
@@ -532,11 +535,12 @@ export {
532
535
  } from './push-fanout.js';
533
536
 
534
537
  // Control-cohort dial selection (backbone-preferential, bounded out-degree) +
535
- // the cadence/degree defaults the proactive reconcile routine reads.
538
+ // the backstop, redial and degree defaults the proactive reconcile routine reads.
536
539
  export {
537
540
  selectControlCohortDials,
538
541
  DEFAULT_CONTROL_COHORT_RECONCILE_MS,
539
542
  DEFAULT_CONTROL_COHORT_TARGET_DEGREE,
543
+ CONTROL_COHORT_REDIAL_MS,
540
544
  type ControlCohortSelection,
541
545
  type ControlCohortReconcileResult
542
546
  } from './control-cohort.js';
@@ -694,9 +698,16 @@ export {
694
698
  type FormationDialOptions,
695
699
  type FormationDialResult,
696
700
  type FormationRejection,
697
- type FormationTokenCheck,
698
701
  type ResponderProvisionOutcome
699
702
  } from './strand-formation-protocol.js';
703
+ // A responder session's one read of its formation token, and the check made from it
704
+ export {
705
+ classifyTokenRead,
706
+ isTokenReadSpent,
707
+ type FormationInviteFacts,
708
+ type FormationTokenRead,
709
+ type FormationTokenCheck
710
+ } from './formation-token.js';
700
711
  // How a formation dial fails: a typed refusal (with a fixed code) or no answer at all
701
712
  export {
702
713
  FormationRejectedError,
@@ -736,6 +747,7 @@ export {
736
747
  type RedeemAtMembersOptions,
737
748
  type RedeemInPassesOptions,
738
749
  type RedeemSigner,
750
+ type RedeemSelfRecord,
739
751
  type CadreInviteRedemption
740
752
  } from './cadre-invite-protocol.js';
741
753
  // Owner grant redemption transport: the bundle an owner hands out, the wire messages, the
@@ -788,7 +800,7 @@ export {
788
800
  CADRE_INVITATION_ADDRS_PER_SIBLING
789
801
  } from './invitation-bootstrap.js';
790
802
  // The pending-join retry loop behind CadreNode.requestJoin (PendingJoinStatus is in types.ts)
791
- export { PENDING_JOIN_POLL_MS, PENDING_JOIN_MAX_BACKOFF_MS, MAX_PENDING_JOIN_MS } from './pending-join-runner.js';
803
+ export { PENDING_JOIN_BACKSTOP_MS, PENDING_JOIN_MAX_BACKOFF_MS, MAX_PENDING_JOIN_MS } from './pending-join-runner.js';
792
804
 
793
805
  // Strand Formation manager (drives the native transport)
794
806
  export {
@@ -2,7 +2,7 @@
2
2
  * The strands this party joined from ANOTHER party, held in two records.
3
3
  *
4
4
  * A party's own strands come back after a restart through its control database: the
5
- * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
5
+ * `Strand` table holds their rows, the strand watcher reads it, and each row no sApp
6
6
  * config claims is offered as `strand:discovered`. A strand joined through formation with
7
7
  * another party (`CadreNode.formStrand`) has its `Strand` row in the OTHER party's control
8
8
  * database, so it needs a record of its own (gotchoices/sereus#18):
@@ -17,7 +17,7 @@
17
17
  * published". A machine that is not an owner never publishes; its joins stay here.
18
18
  *
19
19
  * {@link JoinedStrandSession} moves joins between the two and is what the strand watcher
20
- * polls beside the control rows.
20
+ * reads beside the control rows.
21
21
  *
22
22
  * Two store implementations:
23
23
  * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
@@ -76,6 +76,10 @@ export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
76
76
  return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null, FounderPeerId: null };
77
77
  }
78
78
 
79
+ function isSameJoin(record: JoinedStrandRecord, row: StrandRow): boolean {
80
+ return record.Type === row.Type && record.MemberPrivateKey === row.MemberPrivateKey;
81
+ }
82
+
79
83
  /**
80
84
  * Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
81
85
  * asked to remember, because a join held only here is not re-offered after a restart —
@@ -124,9 +128,9 @@ const SLOT_PREFIX = 'cadre/joined-strand/';
124
128
  * arbitrary text, and base64url never contains the `/` that ends the segment.
125
129
  *
126
130
  * Loads every slot once, at the first {@link list}, then answers from memory and writes
127
- * through, so the strand watcher's five-second poll does not read the enclave on every
128
- * pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
129
- * is rethrown rather than read as "no joins" and retried at the next call, exactly as
131
+ * through, so a strand watcher run does not read the enclave each time. A load that
132
+ * fails — `KeyStoreAccessError` from a refused unlock prompt, say — is rethrown rather
133
+ * than read as "no joins" and retried at the next call, exactly as
130
134
  * `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
131
135
  * record for its own id is dropped with a log and its siblings kept: refusing to start
132
136
  * repairs nothing, and the other joins are still good.
@@ -176,9 +180,10 @@ export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
176
180
  return result;
177
181
  }
178
182
 
179
- // NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
180
- // over a backend that gates reads behind an unlock prompt the user keeps declining, that
181
- // is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
183
+ // NOTE: a failed load is retried at the next list(), which is every strand-watcher run (a
184
+ // change to `Strand` or `JoinedStrand`, a local join, a retry, the backstop); over a backend
185
+ // that gates reads behind an unlock prompt the user keeps declining, that is a prompt per
186
+ // run. Every KeyStore shipped today is ungated for this reason
182
187
  // (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
183
188
  // off the retry here.
184
189
  private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
@@ -251,7 +256,7 @@ export interface PartyJoinedStrandLedger {
251
256
  * the moves between them. Rebuilt at every start, which is what ends the per-session state
252
257
  * below.
253
258
  *
254
- * The watcher reads a row missing from a poll as a removal and detaches the strand, so this
259
+ * The watcher reads a row missing from a run's read as a removal and detaches the strand, so this
255
260
  * holds the state a plain union would get wrong:
256
261
  * - the last list each record answered, reused when a later read fails, so a failed read
257
262
  * is not "every join was forgotten";
@@ -262,6 +267,8 @@ export interface PartyJoinedStrandLedger {
262
267
  export class JoinedStrandSession {
263
268
  private lastUnpublished: JoinedStrandRecord[] = [];
264
269
  private lastPartyWide: StrandRow[] = [];
270
+ /** Whether the last party-wide read in {@link withControlRows} succeeded; see {@link lastReadPartial}. */
271
+ private lastPartyWideFresh = true;
265
272
  private readonly keptForSession = new Map<string, StrandRow>();
266
273
  /** Strands whose party-wide row {@link syncWithParty} removes, queued by a self-revocation. */
267
274
  private readonly pendingRemovals = new Set<string>();
@@ -272,13 +279,23 @@ export class JoinedStrandSession {
272
279
  */
273
280
  private exclusive: Promise<unknown> = Promise.resolve();
274
281
 
275
- constructor(private readonly store: JoinedStrandStore, private readonly ledger: PartyJoinedStrandLedger) {}
282
+ /**
283
+ * @param onLocalChange - Called after a move changes what {@link withControlRows} merges in
284
+ * from outside the control database (this machine's unpublished joins, the joins kept for
285
+ * this session), which no control-table change event reports. `CadreNode` wakes its strand
286
+ * watcher with it.
287
+ */
288
+ constructor(
289
+ private readonly store: JoinedStrandStore,
290
+ private readonly ledger: PartyJoinedStrandLedger,
291
+ private readonly onLocalChange: () => void = () => {}
292
+ ) {}
276
293
 
277
294
  /**
278
295
  * `control` plus every join, one row per id, in this precedence: the control rows (the
279
296
  * party's own strands), then the party-wide joins, then this machine's unpublished joins
280
297
  * and the joins kept for this session. A local record named by either of the first two in
281
- * a read that succeeded this poll is stale — published, or the strand is the party's own
298
+ * a read that succeeded this run is stale — published, or the strand is the party's own
282
299
  * now — so it is forgotten here; this also cleans up after a crash between a publish and
283
300
  * its local forget. One named only by the last-good party-wide list is kept: that list may
284
301
  * predate a leave the record re-joined after.
@@ -289,7 +306,7 @@ export class JoinedStrandSession {
289
306
  const offered = new Map<string, StrandRow>(control.map((row) => [row.Id, row]));
290
307
  const partyWide = await this.partyWide();
291
308
  // NOTE: a party-wide join whose id the party's own `Strand` table also holds (the party
292
- // founded AND joined it — contrived) is shadowed here, not deleted: a watcher poll must
309
+ // founded AND joined it — contrived) is shadowed here, not deleted: a watcher run must
293
310
  // not make owner-signed writes. If the party later unpublishes its own row the join row
294
311
  // resurfaces; if that is ever seen, remove the join row inside `unpublishStrand`.
295
312
  for (const row of partyWide.rows) {
@@ -317,10 +334,18 @@ export class JoinedStrandSession {
317
334
  return [...offered.values()];
318
335
  }
319
336
 
337
+ /**
338
+ * True when the last {@link withControlRows} offered the party-wide joins from the last
339
+ * list that answered, because its read of `JoinedStrand` failed.
340
+ */
341
+ lastReadPartial(): boolean {
342
+ return !this.lastPartyWideFresh;
343
+ }
344
+
320
345
  /**
321
346
  * This machine's unpublished joins, straight from the store (never the last-good list a
322
347
  * failed read stands in with). What a strand move exports beside the `JoinedStrand`
323
- * table: a machine publishes only while it holds a control connection, so until then its
348
+ * table: a machine publishes only while a sibling is reachable, so until then its
324
349
  * joins live only here.
325
350
  */
326
351
  unpublishedRecords(): Promise<JoinedStrandRecord[]> {
@@ -331,21 +356,32 @@ export class JoinedStrandSession {
331
356
  async remember(record: JoinedStrandRecord): Promise<void> {
332
357
  this.pendingRemovals.delete(record.Id);
333
358
  await this.store.record(record);
359
+ this.onLocalChange();
334
360
  }
335
361
 
336
362
  /**
337
363
  * Record `row`, joined by `addStrand`, unless this party's control database already names
338
364
  * it (its own strand, or a join already published) or an identical record is already here
339
- * (an app re-claiming a remembered join on every start). Keeps the first `joinedAt`. Like
365
+ * (an app re-claiming a remembered join on every start, or `addStrand` right after
366
+ * `formStrand`). The store is asked first: it answers from memory once loaded, so the
367
+ * common re-claim reads no control row. Keeps the first `joinedAt`. Like
340
368
  * {@link remember}, cancels a queued removal.
341
369
  */
342
370
  async rememberForeign(row: StrandRow): Promise<void> {
343
371
  this.pendingRemovals.delete(row.Id);
372
+ const early = await this.storedRecord(row.Id).catch((error: unknown) => {
373
+ // Asked again below only when the party does not name the strand; a second failure throws.
374
+ log('joined strand %s: store lookup failed; asking the control database first: %o', row.Id, error);
375
+ return null;
376
+ });
377
+ if (early && isSameJoin(early, row)) {
378
+ return;
379
+ }
344
380
  if (await this.namedByParty(row.Id)) {
345
381
  return;
346
382
  }
347
- const existing = (await this.store.list()).find((record) => record.Id === row.Id);
348
- if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
383
+ const existing = early === null ? await this.storedRecord(row.Id) : early;
384
+ if (existing && isSameJoin(existing, row)) {
349
385
  return;
350
386
  }
351
387
  await this.store.record({
@@ -354,6 +390,7 @@ export class JoinedStrandSession {
354
390
  MemberPrivateKey: row.MemberPrivateKey,
355
391
  joinedAt: existing?.joinedAt ?? Date.now()
356
392
  });
393
+ this.onLocalChange();
357
394
  }
358
395
 
359
396
  /**
@@ -367,6 +404,7 @@ export class JoinedStrandSession {
367
404
  this.keptForSession.delete(strandId);
368
405
  this.lastPartyWide = this.lastPartyWide.filter((row) => row.Id !== strandId);
369
406
  await this.store.forget(strandId);
407
+ this.onLocalChange();
370
408
  });
371
409
  }
372
410
 
@@ -385,13 +423,14 @@ export class JoinedStrandSession {
385
423
  if (!row) {
386
424
  return;
387
425
  }
388
- // Kept BEFORE the store forgets, so no poll can union without it. Queued even for a
426
+ // Kept BEFORE the store forgets, so no watcher run can union without it. Queued even for a
389
427
  // local-only join, which a sibling may have published already.
390
428
  this.keptForSession.set(strandId, row);
391
429
  this.pendingRemovals.add(strandId);
392
430
  if (local) {
393
431
  await this.store.forget(strandId);
394
432
  }
433
+ this.onLocalChange();
395
434
  });
396
435
  }
397
436
 
@@ -423,15 +462,16 @@ export class JoinedStrandSession {
423
462
  }
424
463
 
425
464
  // NOTE: a sibling that joined the same strand and has not yet published publishes it after
426
- // a leave, bringing the strand back party-wide. Needs two devices joining one strand within
427
- // a reconcile interval of the leave; if it is ever seen, check the strand's `Revocation`
465
+ // a leave, bringing the strand back party-wide. Needs a second device to have joined the
466
+ // strand and not yet run a connected reconcile pass when the leave lands (a join starts no
467
+ // pass, so up to the 2-minute backstop); if it is ever seen, check the strand's `Revocation`
428
468
  // tombstone before publishing.
429
469
  private async publish(record: JoinedStrandRecord): Promise<void> {
430
470
  try {
431
471
  if (!(await this.ledger.names(record.Id))) {
432
472
  await this.ledger.publish(record);
433
473
  // Into the last-good list too: its local record goes next, so a party-wide read that
434
- // fails before one succeeds would otherwise drop the strand from the poll and hide it
474
+ // fails before one succeeds would otherwise drop the strand from the watcher's read and hide it
435
475
  // from a self-revocation.
436
476
  this.lastPartyWide = [...this.lastPartyWide.filter((row) => row.Id !== record.Id), joinedStrandRow(record)];
437
477
  log('joined strand %s published party-wide (party=%s)', record.Id, this.store.partyId);
@@ -457,19 +497,20 @@ export class JoinedStrandSession {
457
497
  private async partyWide(): Promise<{ rows: StrandRow[]; fresh: boolean }> {
458
498
  try {
459
499
  this.lastPartyWide = await this.ledger.list();
460
- return { rows: this.lastPartyWide, fresh: true };
500
+ this.lastPartyWideFresh = true;
461
501
  } catch (error) {
462
502
  log('party-wide joined-strand read failed; offering the last list it answered (%d join(s)): %o',
463
503
  this.lastPartyWide.length, error);
464
- return { rows: this.lastPartyWide, fresh: false };
504
+ this.lastPartyWideFresh = false;
465
505
  }
506
+ return { rows: this.lastPartyWide, fresh: this.lastPartyWideFresh };
466
507
  }
467
508
 
468
509
  /**
469
510
  * {@link PartyJoinedStrandLedger.names}, answering false when the control database cannot
470
511
  * be read (a machine cut off from its party before it received the `JoinedStrand` block).
471
512
  * Recording is the safe side: a local record the party turns out to name is forgotten by
472
- * the next poll that reads it, while a join with no record is not offered after a restart.
513
+ * the next watcher run that reads it, while a join with no record is not offered after a restart.
473
514
  */
474
515
  private async namedByParty(strandId: string): Promise<boolean> {
475
516
  try {
@@ -480,6 +521,10 @@ export class JoinedStrandSession {
480
521
  }
481
522
  }
482
523
 
524
+ private async storedRecord(strandId: string): Promise<JoinedStrandRecord | undefined> {
525
+ return (await this.store.list()).find((record) => record.Id === strandId);
526
+ }
527
+
483
528
  /** Run `work` after every earlier serialized move settles; see {@link exclusive}. */
484
529
  private serialized<T>(work: () => Promise<T>): Promise<T> {
485
530
  const result = this.exclusive.then(work);
@@ -502,7 +547,7 @@ export class JoinedStrandSession {
502
547
  await this.store.forget(strandId);
503
548
  log('joined strand %s is named by this party\'s control database — local record forgotten', strandId);
504
549
  } catch (error) {
505
- log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
550
+ log('forgetting superseded joined strand %s failed; the next watcher run retries: %o', strandId, error);
506
551
  }
507
552
  }
508
553
  }
@@ -392,13 +392,15 @@ export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
392
392
  * every members-only protocol from the materialized snapshot, and unplaced
393
393
  * relay reservations stay capped (`MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
394
394
  * - **The slow case is bring-up, not steady state.** A membership read consults
395
- * the cohort only for a block this node does not hold, or before the
396
- * `Revocation` ledger marker exists; once the marker exists every block the
397
- * decision reads is held (`control-founding-consult-budget.spec.ts` pins both
398
- * states). So the gate always fails open only on a node whose membership reads
399
- * still consult: one consult costs about two link round trips, and asking a
400
- * silent peer costs the per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s
401
- * at the default declaration).
395
+ * the cohort only for a block this node does not hold: before a joining
396
+ * machine has read it from the cohort, or for a table nobody has written on a
397
+ * party founded before genesis created every control collection. Otherwise
398
+ * every block the decision reads is held
399
+ * (`control-founding-consult-budget.spec.ts` pins that). So the gate always
400
+ * fails open only on a node whose membership reads still consult: one consult
401
+ * costs about two link round trips, and asking a silent peer costs the
402
+ * per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s at the default
403
+ * declaration).
402
404
  * - **The decision is spent on the dialing machine's clock.** libp2p's listener
403
405
  * runs this gate before it answers the multiplexer negotiation the dialer is
404
406
  * waiting on (`libp2p/dist/src/upgrader.js`), and the listener's own
@@ -170,12 +170,16 @@ export interface MemberRedemptionHandlerOptions {
170
170
  * Registered only by a started `CadreNode`, after its control database is up, like the
171
171
  * formation responder; nothing here is reachable on a node without one.
172
172
  *
173
- * A subclass supplies the five credential-specific pieces: the request's shape, the checks after
174
- * it, the liveness read behind the push, the writes, and the two fixed refusals the base sends
175
- * on its own (`busy`, and `internal` for a frame it could not read or a failure it did not
176
- * expect).
173
+ * A subclass supplies the six credential-specific pieces: the request's shape, the checks after
174
+ * it, the read of the credential row this member holds, the liveness judgement behind the push,
175
+ * the writes, and the two fixed refusals the base sends on its own (`busy`, and `internal` for a
176
+ * frame it could not read or a failure it did not expect).
177
+ *
178
+ * The held row is read once, after the checks, and carried to the liveness judgement and the
179
+ * writes: the schema refuses every update and delete of an issued credential row, so the row
180
+ * read before the write lock is the row a read inside it would return.
177
181
  */
178
- export abstract class MemberRedemptionHandler<Request, Reply extends { accepted: boolean }> {
182
+ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted: boolean }, Held> {
179
183
  protected readonly partyId: string;
180
184
  private readonly admitDevice: MemberRedemptionHandlerOptions['admitDevice'];
181
185
  private readonly readTimeoutMs: number;
@@ -204,10 +208,12 @@ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted:
204
208
  protected abstract isWellFormedRequest(value: unknown): value is Request;
205
209
  /** The checks after the shape that cost no database work; null to go on to the writes. */
206
210
  protected abstract checkRequest(request: Request, remotePeerId: string): Reply | null;
207
- /** Is the credential the (checked) request carries still live on this member? Decides whether the holder is pushed the store (see {@link admitIfLive}). */
208
- protected abstract isLive(request: Request): Promise<boolean>;
209
- /** The protocol's writes under the admission, each answered by name when it fails. */
210
- protected abstract redeemAdmitted(request: Request, remotePeerId: string): Promise<Reply>;
211
+ /** The credential row this member holds under the (checked) request's key, or null; a failed read is answered `internal`. */
212
+ protected abstract readHeld(request: Request): Promise<Held | null>;
213
+ /** Is the credential the request carries still live on this member, judged on `held` (else the bundle's copy)? Decides whether the holder is pushed the store (see {@link admitIfLive}). */
214
+ protected abstract isLive(request: Request, held: Held | null): Promise<boolean>;
215
+ /** The protocol's writes under the admission, each answered by name when it fails; `held` is {@link readHeld}'s row. */
216
+ protected abstract redeemAdmitted(request: Request, remotePeerId: string, held: Held | null): Promise<Reply>;
211
217
 
212
218
  /** In-flight inbound redemptions. */
213
219
  get activeCount(): number {
@@ -274,9 +280,10 @@ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted:
274
280
  if (!this.isWellFormedRequest(request)) return this.malformedRefusal;
275
281
  const refused = this.checkRequest(request, remotePeerId);
276
282
  if (refused) return refused;
277
- const release = await this.admitIfLive(request, remotePeerId);
283
+ const held = await this.readHeld(request);
284
+ const release = await this.admitIfLive(request, remotePeerId, held);
278
285
  try {
279
- return await this.redeemAdmitted(request, remotePeerId);
286
+ return await this.redeemAdmitted(request, remotePeerId, held);
280
287
  } finally {
281
288
  release();
282
289
  }
@@ -299,11 +306,11 @@ export abstract class MemberRedemptionHandler<Request, Reply extends { accepted:
299
306
  * the ordinary catch-up reaches a member. Best-effort: a failed check or push is logged and
300
307
  * the writes go ahead without the holder admitted, then commit or are answered retryably.
301
308
  */
302
- private async admitIfLive(request: Request, remotePeerId: string): Promise<() => void> {
309
+ private async admitIfLive(request: Request, remotePeerId: string, held: Held | null): Promise<() => void> {
303
310
  const notAdmitted = (): void => undefined;
304
311
  if (!this.admitDevice) return notAdmitted;
305
312
  try {
306
- if (!await this.isLive(request)) {
313
+ if (!await this.isLive(request, held)) {
307
314
  log('%s: the credential is not live here; %s is not admitted before the write', this.label, remotePeerId);
308
315
  return notAdmitted;
309
316
  }