@serfab/cadre-core 1.7.0 → 1.9.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 (188) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +527 -206
  3. package/dist/cadre-node.js +1084 -528
  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 +10 -10
  45. package/dist/index.js +25 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/node-local-snapshot.d.ts +6 -4
  59. package/dist/node-local-snapshot.js +7 -3
  60. package/dist/node-local-snapshot.js.map +1 -1
  61. package/dist/peer-dial.d.ts +24 -4
  62. package/dist/peer-dial.js +74 -5
  63. package/dist/peer-dial.js.map +1 -1
  64. package/dist/peer-join-backfill.d.ts +2 -10
  65. package/dist/peer-join-backfill.js +8 -8
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/push-fanout.d.ts +6 -0
  68. package/dist/push-fanout.js +6 -0
  69. package/dist/push-fanout.js.map +1 -1
  70. package/dist/relay-reservation.d.ts +3 -1
  71. package/dist/relay-reservation.js +3 -1
  72. package/dist/relay-reservation.js.map +1 -1
  73. package/dist/relay-server.d.ts +79 -0
  74. package/dist/relay-server.js +74 -0
  75. package/dist/relay-server.js.map +1 -0
  76. package/dist/seed-bootstrap.d.ts +57 -4
  77. package/dist/seed-bootstrap.js +107 -83
  78. package/dist/seed-bootstrap.js.map +1 -1
  79. package/dist/storage-scope.d.ts +33 -17
  80. package/dist/storage-scope.js +44 -23
  81. package/dist/storage-scope.js.map +1 -1
  82. package/dist/strand-addr-protocol.d.ts +58 -21
  83. package/dist/strand-addr-protocol.js +133 -85
  84. package/dist/strand-addr-protocol.js.map +1 -1
  85. package/dist/strand-database.d.ts +20 -2
  86. package/dist/strand-database.js +42 -5
  87. package/dist/strand-database.js.map +1 -1
  88. package/dist/strand-first-sync-gate.d.ts +59 -21
  89. package/dist/strand-first-sync-gate.js +58 -19
  90. package/dist/strand-first-sync-gate.js.map +1 -1
  91. package/dist/strand-formation-deadlines.d.ts +151 -0
  92. package/dist/strand-formation-deadlines.js +171 -0
  93. package/dist/strand-formation-deadlines.js.map +1 -0
  94. package/dist/strand-formation-manager.d.ts +63 -30
  95. package/dist/strand-formation-manager.js +67 -41
  96. package/dist/strand-formation-manager.js.map +1 -1
  97. package/dist/strand-formation-protocol.d.ts +45 -46
  98. package/dist/strand-formation-protocol.js +62 -126
  99. package/dist/strand-formation-protocol.js.map +1 -1
  100. package/dist/strand-fret-addrs.d.ts +54 -0
  101. package/dist/strand-fret-addrs.js +108 -0
  102. package/dist/strand-fret-addrs.js.map +1 -0
  103. package/dist/strand-instance-manager.d.ts +122 -44
  104. package/dist/strand-instance-manager.js +270 -114
  105. package/dist/strand-instance-manager.js.map +1 -1
  106. package/dist/strand-membership-writer.d.ts +6 -0
  107. package/dist/strand-membership-writer.js +11 -0
  108. package/dist/strand-membership-writer.js.map +1 -1
  109. package/dist/strand-network-state-file.d.ts +12 -0
  110. package/dist/strand-network-state-file.js +38 -0
  111. package/dist/strand-network-state-file.js.map +1 -0
  112. package/dist/strand-network-state.d.ts +111 -0
  113. package/dist/strand-network-state.js +191 -0
  114. package/dist/strand-network-state.js.map +1 -0
  115. package/dist/strand-revocation-enforcer.js +2 -1
  116. package/dist/strand-revocation-enforcer.js.map +1 -1
  117. package/dist/strand-solicitation.d.ts +36 -20
  118. package/dist/strand-solicitation.js +4 -4
  119. package/dist/strand-solicitation.js.map +1 -1
  120. package/dist/strand-wake-protocol.d.ts +80 -23
  121. package/dist/strand-wake-protocol.js +89 -64
  122. package/dist/strand-wake-protocol.js.map +1 -1
  123. package/dist/types.d.ts +148 -61
  124. package/dist/types.js +4 -3
  125. package/dist/types.js.map +1 -1
  126. package/package.json +11 -10
  127. package/src/cadre-node.ts +8400 -7776
  128. package/src/control-authorization.ts +2 -1
  129. package/src/control-cohort.ts +6 -0
  130. package/src/control-database.ts +122 -18
  131. package/src/control-formation-recorder.ts +354 -305
  132. package/src/control-read-retry.ts +14 -12
  133. package/src/control-schema.ts +67 -5
  134. package/src/control-stream.ts +25 -1
  135. package/src/control-write-retry.ts +13 -2
  136. package/src/delegate-admission.ts +23 -13
  137. package/src/diagnostics/connection-path.ts +0 -7
  138. package/src/formation-approval.ts +610 -604
  139. package/src/fs-atomic.ts +14 -4
  140. package/src/hibernation-manager.ts +638 -437
  141. package/src/index.ts +43 -65
  142. package/src/joined-strand-store.ts +247 -57
  143. package/src/key-store-file.ts +14 -5
  144. package/src/link-budget.ts +393 -74
  145. package/src/membership-connection-gater.ts +31 -45
  146. package/src/node-local-snapshot.ts +12 -7
  147. package/src/peer-dial.ts +81 -7
  148. package/src/peer-join-backfill.ts +10 -9
  149. package/src/push-fanout.ts +6 -0
  150. package/src/relay-reservation.ts +3 -1
  151. package/src/relay-server.ts +98 -0
  152. package/src/seed-bootstrap.ts +133 -91
  153. package/src/storage-scope.ts +44 -23
  154. package/src/strand-addr-protocol.ts +547 -458
  155. package/src/strand-database.ts +287 -242
  156. package/src/strand-first-sync-gate.ts +58 -19
  157. package/src/strand-formation-deadlines.ts +227 -0
  158. package/src/strand-formation-manager.ts +700 -647
  159. package/src/strand-formation-protocol.ts +106 -154
  160. package/src/strand-fret-addrs.ts +145 -0
  161. package/src/strand-instance-manager.ts +307 -140
  162. package/src/strand-membership-writer.ts +12 -0
  163. package/src/strand-network-state-file.ts +40 -0
  164. package/src/strand-network-state.ts +243 -0
  165. package/src/strand-revocation-enforcer.ts +2 -1
  166. package/src/strand-solicitation.ts +519 -500
  167. package/src/strand-wake-protocol.ts +401 -355
  168. package/src/types.ts +150 -61
  169. package/dist/strand-peer-book-file.d.ts +0 -12
  170. package/dist/strand-peer-book-file.js +0 -37
  171. package/dist/strand-peer-book-file.js.map +0 -1
  172. package/dist/strand-peer-book-protocol.d.ts +0 -159
  173. package/dist/strand-peer-book-protocol.js +0 -360
  174. package/dist/strand-peer-book-protocol.js.map +0 -1
  175. package/dist/strand-peer-book-swap.d.ts +0 -104
  176. package/dist/strand-peer-book-swap.js +0 -284
  177. package/dist/strand-peer-book-swap.js.map +0 -1
  178. package/dist/strand-peer-book.d.ts +0 -159
  179. package/dist/strand-peer-book.js +0 -354
  180. package/dist/strand-peer-book.js.map +0 -1
  181. package/dist/strand-peer-observer.d.ts +0 -88
  182. package/dist/strand-peer-observer.js +0 -180
  183. package/dist/strand-peer-observer.js.map +0 -1
  184. package/src/strand-peer-book-file.ts +0 -39
  185. package/src/strand-peer-book-protocol.ts +0 -463
  186. package/src/strand-peer-book-swap.ts +0 -338
  187. package/src/strand-peer-book.ts +0 -468
  188. package/src/strand-peer-observer.ts +0 -235
@@ -39,6 +39,7 @@ export const CONTROL_TABLES = [
39
39
  'ValidationKey',
40
40
  'Strand',
41
41
  'StrandPartyKey',
42
+ 'JoinedStrand',
42
43
  'CadrePeer',
43
44
  'DeviceToken',
44
45
  'FormationInvite',
@@ -64,7 +65,7 @@ export type ControlTable = typeof CONTROL_TABLES[number];
64
65
  * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
65
66
  * `revocationDigest` — can type against it without pulling in the runtime.
66
67
  */
67
- export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'DeviceToken'>;
68
+ export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'DeviceToken'>;
68
69
 
69
70
  /**
70
71
  * What a signature authorizes, scoped to one table rule — or, for
@@ -28,6 +28,12 @@ import type { CohortPeerRow } from './strand-cohort.js';
28
28
  * Default recurring cadence for {@link CadreNode.reconcileControlCohort} (ms).
29
29
  * In the same spirit as the 5s strand-watch poll but lighter, since a control
30
30
  * cohort changes far less often than strand activity.
31
+ *
32
+ * An interval between pass starts, not a deadline on a pass. Passes are
33
+ * single-flight, so a pass that runs longer than this is joined rather than
34
+ * stacked, and the effective cadence becomes the pass length: one unreachable
35
+ * sibling's dial alone can take the whole per-peer dial budget (64 s at the
36
+ * default declared link).
31
37
  */
32
38
  export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15_000;
33
39
 
@@ -100,7 +100,9 @@ export class FormationAbortedError extends Error {
100
100
  *
101
101
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
102
102
  * the write, inside {@link ControlDatabase.redeemInvitation} /
103
- * {@link ControlDatabase.recordFormationUsage} — and off nothing else. It exists because
103
+ * {@link ControlDatabase.recordFormationUsage}, and off the unlocked pre-check
104
+ * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
105
+ * pass — and off nothing else. It exists because
104
106
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
105
107
  * schema's own count-based cap clause, which the manager reports as a retryable
106
108
  * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
@@ -293,26 +295,33 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
293
295
  ValidationKey: 'Key',
294
296
  Strand: 'Id',
295
297
  StrandPartyKey: 'Id',
298
+ JoinedStrand: 'Id',
296
299
  DeviceToken: 'PeerId',
297
300
  };
298
301
 
302
+ /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
303
+ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
304
+
299
305
  /**
300
- * The rejection a second insert of an already-seated strand id produces, as the optimystic
301
- * vtab words it (`uniqueConstraintMessage`, qualified by table name only — no schema
302
- * prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
306
+ * The rejection a second insert of an already-seated strand id into `table` produces, as
307
+ * the optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only —
308
+ * no schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
303
309
  *
304
- * `Strand.StampId` is unique too, but a fresh stamp is minted per insert attempt, so only
305
- * the primary key can collide on a repeat publish. Matching the column explicitly keeps
306
- * any OTHER uniqueness failure out of the idempotency branch.
310
+ * Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
311
+ * only the primary key can collide on a repeat publish. Matching the column explicitly
312
+ * keeps any OTHER uniqueness failure out of the idempotency branch.
307
313
  */
308
- const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
314
+ function strandIdConflictPattern(table: StrandIdTable): RegExp {
315
+ return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
316
+ }
309
317
 
310
318
  /**
311
- * Did this write fail because the strand id is already seated?
319
+ * Did this write to `table` fail because the strand id is already seated there?
312
320
  *
313
321
  * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
314
322
  * landed" (re-read, and no-op when the row matches) apart from every other rejection —
315
- * an unauthorized signer, a retired stamp — which must keep surfacing.
323
+ * an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
324
+ * the same test tells "another machine of this party already published this join" apart.
316
325
  *
317
326
  * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
318
327
  * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
@@ -321,8 +330,8 @@ const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
321
330
  * `publish-strand.spec.ts` repeat-publish cases assert against the live engine error, so a
322
331
  * reword reddens there.
323
332
  */
324
- export function isStrandIdConflict(error: unknown): boolean {
325
- return errorChainMatches(error, STRAND_ID_CONFLICT);
333
+ export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean {
334
+ return errorChainMatches(error, strandIdConflictPattern(table));
326
335
  }
327
336
 
328
337
  /**
@@ -339,7 +348,7 @@ export const REVOCATION_LEDGER_MARKER = {
339
348
 
340
349
  /**
341
350
  * The rejection a second insert of the ledger marker produces: the optimystic vtab's
342
- * primary-key wording (see {@link STRAND_ID_CONFLICT}), naming both columns of
351
+ * primary-key wording (see {@link strandIdConflictPattern}), naming both columns of
343
352
  * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
344
353
  * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
345
354
  * has no other unique constraint.
@@ -362,10 +371,12 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
362
371
  * `CadrePeer.AuthorizedDelete` in the schema). `Strand` and `StrandPartyKey` are
363
372
  * deliberately absent: their rows carry `MemberPrivateKey` / `PrivateKey` — party
364
373
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
365
- * future change). `OwnerKey` has no production removal path and `MinOneOwner` makes an
366
- * automated owner-key reap a party-bricking hazard.
374
+ * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
375
+ * secret is the joined strand's shared read key, which its founding party and every member
376
+ * hold, so a reaped row is recoverable by re-forming. `OwnerKey` has no production removal
377
+ * path and `MinOneOwner` makes an automated owner-key reap a party-bricking hazard.
367
378
  */
368
- export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey'] as const satisfies readonly RevocableTable[];
379
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand'] as const satisfies readonly RevocableTable[];
369
380
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
370
381
 
371
382
  /**
@@ -378,6 +389,16 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
378
389
  const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(REAPABLE_TABLES);
379
390
  const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
380
391
 
392
+ /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
393
+ function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
394
+ return {
395
+ Id: row.Id as string,
396
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
397
+ Type: row.Type as 'o' | 'c',
398
+ FounderOwnerKey: null,
399
+ };
400
+ }
401
+
381
402
  /**
382
403
  * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
383
404
  *
@@ -559,7 +580,7 @@ export class ControlDatabase {
559
580
  // NOTE: this is where a slow launch is felt. Duration is (raw-storage operations
560
581
  // issued) × per-operation storage latency: ~1ms/op on an idle machine, but
561
582
  // 50-90ms/op on a loaded disk or a phone's flash under launch contention. A cold
562
- // start now reaches the backend 45 times (9 tables + 1 index, 20 distinct blocks
583
+ // start now reaches the backend 48 times (10 tables + 1 index, 23 distinct blocks
563
584
  // — dominated by its genuine writes), a warm restart 13, because cadre-core
564
585
  // wraps every embedder storage in `@optimystic/db-p2p`'s write-through cache
565
586
  // (@serfab/quereus-plugin-sereus's cached-storage.ts). Uncached the same start issued ~2000 operations — the
@@ -629,6 +650,11 @@ export class ControlDatabase {
629
650
  // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
630
651
  // A table the live catalog already lists generates no statements at all, which is what
631
652
  // makes an apply over an already-complete schema (a warm start) a no-op.
653
+ // NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
654
+ // `alter table ... drop constraint`, which the optimystic module refuses, so a store
655
+ // written by a build with older constraint text fails here on every start (policy:
656
+ // recreate, docs/architecture.md after the Control Network table). If backwards
657
+ // compatibility becomes a requirement, this apply is the migration site.
632
658
  // - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
633
659
  // undo journal; when a step fails it runs that journal in reverse, re-renders the
634
660
  // catalog, compares it against a fingerprint taken BEFORE the apply, and only then
@@ -879,6 +905,29 @@ export class ControlDatabase {
879
905
  return null;
880
906
  }
881
907
 
908
+ /**
909
+ * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
910
+ * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
911
+ * never founds, so `FounderOwnerKey` is always null. Read raw, like {@link queryStrands}:
912
+ * no retired-stamp filter.
913
+ */
914
+ async queryJoinedStrands(): Promise<StrandRow[]> {
915
+ this.ensureInitialized();
916
+ const rows = await this.readRows('select Id, Type, MemberPrivateKey from CadreControl.JoinedStrand', undefined, 'joined-strands');
917
+ return rows.map(joinedStrandRow);
918
+ }
919
+
920
+ /** Single-row sibling of {@link queryJoinedStrands}: the joined strand with this id, or null. */
921
+ async queryJoinedStrand(strandId: string): Promise<StrandRow | null> {
922
+ this.ensureInitialized();
923
+ const rows = await this.readRows(
924
+ 'select Id, Type, MemberPrivateKey from CadreControl.JoinedStrand where Id = ?',
925
+ [strandId],
926
+ 'joined-strand'
927
+ );
928
+ return rows.length === 0 ? null : joinedStrandRow(rows[0]!);
929
+ }
930
+
882
931
  /**
883
932
  * Count rows in a CadreControl table as seen by THIS database instance.
884
933
  *
@@ -1095,6 +1144,11 @@ export class ControlDatabase {
1095
1144
  return this.queryStampId('StrandPartyKey', strandId);
1096
1145
  }
1097
1146
 
1147
+ /** `JoinedStrand` stamp nonce — bound into {@link deleteJoinedStrand}'s remove digest. */
1148
+ queryJoinedStrandStampId(strandId: string): Promise<string | null> {
1149
+ return this.queryStampId('JoinedStrand', strandId);
1150
+ }
1151
+
1098
1152
  /**
1099
1153
  * Read THIS party's own strand membership private key (base64 protobuf; decode with
1100
1154
  * `strandMemberKeyPair`) for one strand, or null when no `StrandPartyKey` row exists.
@@ -1606,6 +1660,56 @@ export class ControlDatabase {
1606
1660
  return this.lockedWithRetry(() => this.deleteGuardedRow('StrandPartyKey', strandId, ownerKey, signMessage), {}, 'strand-party-key-delete');
1607
1661
  }
1608
1662
 
1663
+ /**
1664
+ * Record, party-wide, a strand this party joined from another party (`JoinedStrand` row),
1665
+ * using an owner signature.
1666
+ *
1667
+ * Mirrors {@link insertStrand}: the owner signs the canonical row-bound authorization
1668
+ * message over (Id, Type, MemberPrivateKey, StampId) — binding the read secret, so a
1669
+ * captured approval can only reproduce the row it approved — and the StampId is persisted
1670
+ * as a unique column for single-use anti-replay.
1671
+ *
1672
+ * Fails with a `JoinedStrand.Id` uniqueness violation when the id is already recorded;
1673
+ * `isStrandIdConflict(error, 'JoinedStrand')` identifies that case.
1674
+ */
1675
+ async insertJoinedStrand(
1676
+ row: Pick<StrandRow, 'Id' | 'Type' | 'MemberPrivateKey'>,
1677
+ ownerKey: string,
1678
+ signMessage: (message: Uint8Array) => string
1679
+ ): Promise<void> {
1680
+ this.ensureInitialized();
1681
+ log('Inserting joined strand: %s (type: %s)', row.Id, row.Type);
1682
+
1683
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1684
+
1685
+ // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
1686
+ // Id, Type, MemberPrivateKey ('' when null), StampId.
1687
+ const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
1688
+ const signature = signMessage(message);
1689
+
1690
+ await this.execWrite(`
1691
+ insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
1692
+ with context OwnerKey = ?, Signature = ?
1693
+ values (?, ?, ?, ?)
1694
+ `, [ownerKey, signature, row.Id, row.Type, row.MemberPrivateKey, stampId], 'joined-strand-insert');
1695
+
1696
+ log('Joined strand inserted: %s', row.Id);
1697
+ }
1698
+
1699
+ /**
1700
+ * Owner-signed removal of one `JoinedStrand` row. Mirrors {@link deleteStrandPartyKey}:
1701
+ * `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
1702
+ * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false`
1703
+ * then, `true` when a row was actually removed.
1704
+ */
1705
+ deleteJoinedStrand(
1706
+ strandId: string,
1707
+ ownerKey: string,
1708
+ signMessage: (message: Uint8Array) => string
1709
+ ): Promise<boolean> {
1710
+ return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
1711
+ }
1712
+
1609
1713
  /**
1610
1714
  * Insert a validation key into the control database using an owner signature.
1611
1715
  *
@@ -2082,7 +2186,7 @@ export class ControlDatabase {
2082
2186
  * reads from this node (every other node already filters it by tombstone). A revoked
2083
2187
  * node therefore keeps its own copy of its own row; "a node that learns it has been
2084
2188
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
2085
- * `ValidationKey` has no self notion and is not special-cased.
2189
+ * `ValidationKey` and `JoinedStrand` have no self notion and are not special-cased.
2086
2190
  *
2087
2191
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
2088
2192
  * starve every tombstone after the failing one on every subsequent pass, and each row is