@serfab/cadre-core 1.9.0 → 1.11.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 (119) hide show
  1. package/dist/cadre-node.d.ts +148 -44
  2. package/dist/cadre-node.js +363 -139
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +1066 -979
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/diagnostics/connection-path.d.ts +1 -18
  22. package/dist/diagnostics/connection-path.js +1 -20
  23. package/dist/diagnostics/connection-path.js.map +1 -1
  24. package/dist/identity-key.d.ts +0 -30
  25. package/dist/identity-key.js +7 -40
  26. package/dist/identity-key.js.map +1 -1
  27. package/dist/index.d.ts +8 -5
  28. package/dist/index.js +12 -6
  29. package/dist/index.js.map +1 -1
  30. package/dist/invitation-bootstrap.d.ts +32 -0
  31. package/dist/invitation-bootstrap.js +30 -0
  32. package/dist/invitation-bootstrap.js.map +1 -0
  33. package/dist/link-budget.d.ts +204 -119
  34. package/dist/link-budget.js +208 -120
  35. package/dist/link-budget.js.map +1 -1
  36. package/dist/membership-connection-gater.d.ts +3 -3
  37. package/dist/membership-connection-gater.js +3 -3
  38. package/dist/peer-dial.d.ts +18 -14
  39. package/dist/peer-dial.js +18 -14
  40. package/dist/peer-dial.js.map +1 -1
  41. package/dist/peer-join-backfill.d.ts +3 -3
  42. package/dist/pending-join-runner.d.ts +186 -0
  43. package/dist/pending-join-runner.js +649 -0
  44. package/dist/pending-join-runner.js.map +1 -0
  45. package/dist/push-fanout.d.ts +1 -1
  46. package/dist/push-fanout.js +1 -1
  47. package/dist/relay-stun.d.ts +24 -0
  48. package/dist/relay-stun.js +51 -0
  49. package/dist/relay-stun.js.map +1 -0
  50. package/dist/seed-bootstrap.d.ts +2 -2
  51. package/dist/seed-bootstrap.js +5 -5
  52. package/dist/strand-addr-protocol.js +2 -2
  53. package/dist/strand-formation-deadlines.d.ts +5 -5
  54. package/dist/strand-formation-deadlines.js +5 -5
  55. package/dist/strand-formation-manager.d.ts +18 -4
  56. package/dist/strand-formation-manager.js +48 -33
  57. package/dist/strand-formation-manager.js.map +1 -1
  58. package/dist/strand-formation-protocol.d.ts +71 -14
  59. package/dist/strand-formation-protocol.js +222 -47
  60. package/dist/strand-formation-protocol.js.map +1 -1
  61. package/dist/strand-formation-rejection.d.ts +94 -0
  62. package/dist/strand-formation-rejection.js +77 -0
  63. package/dist/strand-formation-rejection.js.map +1 -0
  64. package/dist/strand-instance-manager.js +4 -1
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-membership-writer.js +32 -32
  67. package/dist/strand-solicitation.d.ts +11 -3
  68. package/dist/strand-solicitation.js +9 -0
  69. package/dist/strand-solicitation.js.map +1 -1
  70. package/dist/types.d.ts +103 -17
  71. package/dist/types.js +5 -9
  72. package/dist/types.js.map +1 -1
  73. package/package.json +22 -22
  74. package/src/cadre-node.ts +8654 -8400
  75. package/src/control-authorization.ts +2 -1
  76. package/src/control-cohort.ts +128 -128
  77. package/src/control-database.ts +303 -82
  78. package/src/control-formation-recorder.ts +353 -354
  79. package/src/control-read-retry.ts +216 -216
  80. package/src/control-schema.ts +1078 -991
  81. package/src/control-write-retry.ts +567 -561
  82. package/src/diagnostics/connection-path.ts +1 -29
  83. package/src/formation-approval.ts +610 -610
  84. package/src/hibernation-manager.ts +638 -638
  85. package/src/identity-key.ts +7 -62
  86. package/src/index.ts +644 -627
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/joined-strand-store.ts +498 -498
  89. package/src/link-budget.ts +684 -583
  90. package/src/membership-connection-gater.ts +570 -570
  91. package/src/node-local-snapshot.ts +298 -298
  92. package/src/peer-dial.ts +315 -311
  93. package/src/peer-join-backfill.ts +710 -710
  94. package/src/pending-join-runner.ts +735 -0
  95. package/src/push-fanout.ts +1 -1
  96. package/src/relay-reservation.ts +1121 -1121
  97. package/src/relay-server.ts +98 -98
  98. package/src/relay-stun.ts +58 -0
  99. package/src/seed-bootstrap.ts +1448 -1448
  100. package/src/strand-addr-protocol.ts +547 -547
  101. package/src/strand-database.ts +287 -287
  102. package/src/strand-first-sync-gate.ts +391 -391
  103. package/src/strand-formation-deadlines.ts +227 -227
  104. package/src/strand-formation-manager.ts +725 -700
  105. package/src/strand-formation-protocol.ts +1036 -802
  106. package/src/strand-formation-rejection.ts +129 -0
  107. package/src/strand-fret-addrs.ts +145 -145
  108. package/src/strand-instance-manager.ts +1789 -1786
  109. package/src/strand-membership-writer.ts +1974 -1974
  110. package/src/strand-network-state-file.ts +40 -40
  111. package/src/strand-network-state.ts +243 -243
  112. package/src/strand-revocation-enforcer.ts +654 -654
  113. package/src/strand-solicitation.ts +532 -519
  114. package/src/strand-wake-protocol.ts +401 -401
  115. package/src/types.ts +2222 -2140
  116. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  117. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  118. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  119. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
@@ -33,7 +33,7 @@
33
33
  * CHECK the `'reissue'`-tagged digest `ControlDatabase.reissueRevocations`
34
34
  * signs — and `countRows` counts it.
35
35
  */
36
- export declare const CONTROL_TABLES: readonly ["OwnerKey", "ValidationKey", "Strand", "StrandPartyKey", "JoinedStrand", "CadrePeer", "DeviceToken", "FormationInvite", "FormationUsage", "Revocation"];
36
+ export declare const CONTROL_TABLES: readonly ["OwnerKey", "ValidationKey", "Strand", "StrandPartyKey", "JoinedStrand", "PendingJoin", "CadrePeer", "DeviceToken", "FormationInvite", "FormationUsage", "Revocation"];
37
37
  /**
38
38
  * Name of a CadreControl table. Doubles as the table half of a
39
39
  * {@link ControlDomain} tag.
@@ -51,7 +51,7 @@ export type ControlTable = typeof CONTROL_TABLES[number];
51
51
  * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
52
52
  * `revocationDigest` — can type against it without pulling in the runtime.
53
53
  */
54
- export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'DeviceToken'>;
54
+ export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'PendingJoin' | 'DeviceToken'>;
55
55
  /**
56
56
  * What a signature authorizes, scoped to one table rule — or, for
57
57
  * `'Cadre.Enrollment'`, the offline peer vouch that `cadre enroll register`
@@ -39,6 +39,7 @@ export const CONTROL_TABLES = [
39
39
  'Strand',
40
40
  'StrandPartyKey',
41
41
  'JoinedStrand',
42
+ 'PendingJoin',
42
43
  'CadrePeer',
43
44
  'DeviceToken',
44
45
  'FormationInvite',
@@ -1 +1 @@
1
- {"version":3,"file":"control-authorization.js","sourceRoot":"","sources":["../src/control-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,UAAU;IACV,eAAe;IACf,QAAQ;IACR,gBAAgB;IAChB,cAAc;IACd,WAAW;IACX,aAAa;IACb,iBAAiB;IACjB,gBAAgB;IAChB,YAAY;CACJ,CAAC;AAsDX;;;;;;;GAOG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAqB,EACrB,MAAqB,EACrB,SAAmB;IAEnB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AACxC,CAAC"}
1
+ {"version":3,"file":"control-authorization.js","sourceRoot":"","sources":["../src/control-authorization.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,UAAU;IACV,eAAe;IACf,QAAQ;IACR,gBAAgB;IAChB,cAAc;IACd,aAAa;IACb,WAAW;IACX,aAAa;IACb,iBAAiB;IACjB,gBAAgB;IAChB,YAAY;CACJ,CAAC;AAsDX;;;;;;;GAOG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAqB,EACrB,MAAqB,EACrB,SAAmB;IAEnB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AACxC,CAAC"}
@@ -29,7 +29,7 @@ import type { CohortPeerRow } from './strand-cohort.js';
29
29
  * An interval between pass starts, not a deadline on a pass. Passes are
30
30
  * single-flight, so a pass that runs longer than this is joined rather than
31
31
  * stacked, and the effective cadence becomes the pass length: one unreachable
32
- * sibling's dial alone can take the whole per-peer dial budget (64 s at the
32
+ * sibling's dial alone can take the whole per-peer dial budget (86 s at the
33
33
  * default declared link).
34
34
  */
35
35
  export declare const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15000;
@@ -29,7 +29,7 @@ import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
29
29
  * An interval between pass starts, not a deadline on a pass. Passes are
30
30
  * single-flight, so a pass that runs longer than this is joined rather than
31
31
  * stacked, and the effective cadence becomes the pass length: one unreachable
32
- * sibling's dial alone can take the whole per-peer dial budget (64 s at the
32
+ * sibling's dial alone can take the whole per-peer dial budget (86 s at the
33
33
  * default declared link).
34
34
  */
35
35
  export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15000;
@@ -2,7 +2,7 @@ import { Database } from '@quereus/quereus';
2
2
  import type { SqlParameters } from '@quereus/quereus';
3
3
  import type { Libp2p } from '@libp2p/interface';
4
4
  import type { IRepo } from '@optimystic/db-core';
5
- import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow } from './types.js';
5
+ import type { StrandRow, PendingJoinRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow } from './types.js';
6
6
  import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
7
7
  import type { ControlRetryAbandonment } from './control-retry.js';
8
8
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
@@ -175,6 +175,34 @@ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
175
175
  * reword reddens there.
176
176
  */
177
177
  export declare function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean;
178
+ /**
179
+ * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
180
+ * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
181
+ * Text-matched, failing closed, like {@link isStrandIdConflict}.
182
+ *
183
+ * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
184
+ * is retired and no reap pass has removed it yet), so an adopting re-read can come back empty
185
+ * until the next reap pass.
186
+ */
187
+ export declare function isPendingJoinConflict(error: unknown): boolean;
188
+ /**
189
+ * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
190
+ * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
191
+ * the bearer token itself.
192
+ */
193
+ export declare function pendingJoinId(token: string): string;
194
+ /**
195
+ * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
196
+ * incarnation the caller read: another write replaced or removed it first (a sibling machine
197
+ * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
198
+ * re-reads and decides again. `liveStampId` is null when the row is gone.
199
+ */
200
+ export declare class PendingJoinChangedError extends Error {
201
+ readonly id: string;
202
+ readonly expectedStampId: string;
203
+ readonly liveStampId: string | null;
204
+ constructor(id: string, expectedStampId: string, liveStampId: string | null);
205
+ }
178
206
  /**
179
207
  * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
180
208
  * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
@@ -194,10 +222,12 @@ export declare const REVOCATION_LEDGER_MARKER: {
194
222
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
195
223
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
196
224
  * secret is the joined strand's shared read key, which its founding party and every member
197
- * hold, so a reaped row is recoverable by re-forming. `OwnerKey` has no production removal
198
- * path and `MinOneOwner` makes an automated owner-key reap a party-bricking hazard.
225
+ * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
226
+ * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
227
+ * production removal path and `MinOneOwner` makes an automated owner-key reap a
228
+ * party-bricking hazard.
199
229
  */
200
- export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand"];
230
+ export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand", "PendingJoin"];
201
231
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
202
232
  /**
203
233
  * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
@@ -445,6 +475,18 @@ export declare class ControlDatabase {
445
475
  queryJoinedStrands(): Promise<StrandRow[]>;
446
476
  /** Single-row sibling of {@link queryJoinedStrands}: the joined strand with this id, or null. */
447
477
  queryJoinedStrand(strandId: string): Promise<StrandRow | null>;
478
+ /**
479
+ * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
480
+ * pending or finished.
481
+ *
482
+ * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
483
+ * drops them: such a row is either a replaced or dismissed incarnation this node still
484
+ * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
485
+ * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
486
+ */
487
+ queryPendingJoins(): Promise<PendingJoinRow[]>;
488
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
489
+ queryPendingJoin(id: string): Promise<PendingJoinRow | null>;
448
490
  /**
449
491
  * Count rows in a CadreControl table as seen by THIS database instance.
450
492
  *
@@ -786,13 +828,10 @@ export declare class ControlDatabase {
786
828
  * one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
787
829
  * transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
788
830
  * per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
789
- * digests, mandatory same-transaction tombstones); kept separate rather than
790
- * generalizing the shared body because no other guarded table has a companion row.
791
- *
792
- * NOTE: that duplication is a drift risk, not a correctness one — a future tightening of
793
- * {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
794
- * will NOT reach this body. Grep for both when you change either; if a second guarded
795
- * table ever gains a companion row, generalize instead of copying this a third time.
831
+ * digests, mandatory same-transaction tombstones). Both sign through
832
+ * {@link signGuardedRemoval} and run {@link execGuardedRemoval}, so a change to the digest
833
+ * shape or the statement pair reaches this body; only the stamp reads and the companion-row
834
+ * branch are its own, because no other guarded table has a companion row.
796
835
  *
797
836
  * Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
798
837
  */
@@ -847,6 +886,46 @@ export declare class ControlDatabase {
847
886
  * then, `true` when a row was actually removed.
848
887
  */
849
888
  deleteJoinedStrand(strandId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
889
+ /**
890
+ * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
891
+ * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
892
+ * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
893
+ *
894
+ * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
895
+ * this invitation; {@link isPendingJoinConflict} identifies that case.
896
+ */
897
+ insertPendingJoin(row: Omit<PendingJoinRow, 'StampId'>, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoinRow>;
898
+ /**
899
+ * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
900
+ * an outcome is written, since the table refuses updates. Returns the row as written.
901
+ *
902
+ * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
903
+ * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
904
+ * between. The live row must still be the incarnation the caller read: when its `StampId` is
905
+ * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
906
+ * writes nothing. That check is local; a sibling machine's replacement or removal that lands
907
+ * between it and the commit fails the transaction instead (its tombstone for the same stamp,
908
+ * or its successor row, collides on a primary key), and the caller re-reads either way.
909
+ *
910
+ * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
911
+ * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
912
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose
913
+ * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
914
+ */
915
+ replacePendingJoin(expectedStampId: string, next: Omit<PendingJoinRow, 'StampId'>, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoinRow>;
916
+ /**
917
+ * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
918
+ * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
919
+ * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
920
+ * `true` when a row was actually removed.
921
+ */
922
+ deletePendingJoin(id: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
923
+ /**
924
+ * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
925
+ * verifies, returning the row as it will be written and the parameters of
926
+ * {@link PENDING_JOIN_INSERT_SQL}.
927
+ */
928
+ private signPendingJoinInsert;
850
929
  /**
851
930
  * Insert a validation key into the control database using an owner signature.
852
931
  *
@@ -1035,11 +1114,22 @@ export declare class ControlDatabase {
1035
1114
  *
1036
1115
  * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
1037
1116
  * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
1117
+ */
1118
+ private deleteGuardedRow;
1119
+ /**
1120
+ * Delete the row incarnation `removal` names and file its tombstone — the statement pair
1121
+ * every owner removal of a guarded row runs ({@link deleteGuardedRow},
1122
+ * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
1123
+ * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
1124
+ * tombstone commits with it.
1038
1125
  *
1039
- * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
1126
+ * The delete matches the stamp as well as the key, so it can only ever remove the
1127
+ * incarnation the signature names.
1128
+ *
1129
+ * `tableName` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
1040
1130
  * come from closed literal unions — no caller-supplied string reaches the statement.
1041
1131
  */
1042
- private deleteGuardedRow;
1132
+ private execGuardedRemoval;
1043
1133
  /**
1044
1134
  * Fire the guarded-delete listener for one committed tombstone, swallowing (and
1045
1135
  * logging) a listener throw — a committed delete never fails because bookkeeping did.
@@ -1130,7 +1220,8 @@ export declare class ControlDatabase {
1130
1220
  * reads from this node (every other node already filters it by tombstone). A revoked
1131
1221
  * node therefore keeps its own copy of its own row; "a node that learns it has been
1132
1222
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
1133
- * `ValidationKey` and `JoinedStrand` have no self notion and are not special-cased.
1223
+ * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
1224
+ * special-cased.
1134
1225
  *
1135
1226
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
1136
1227
  * starve every tombstone after the failing one on every subsequent pass, and each row is
@@ -213,18 +213,26 @@ const GUARDED_KEY_COLUMN = {
213
213
  Strand: 'Id',
214
214
  StrandPartyKey: 'Id',
215
215
  JoinedStrand: 'Id',
216
+ PendingJoin: 'Id',
216
217
  DeviceToken: 'PeerId',
217
218
  };
219
+ function signGuardedRemoval(tableName, rowKey, stampId, signMessage) {
220
+ return {
221
+ ref: { tableName, rowKey, stampId },
222
+ signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
223
+ revocationSignature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId])),
224
+ };
225
+ }
218
226
  /**
219
- * The rejection a second insert of an already-seated strand id into `table` produces, as
220
- * the optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only —
221
- * no schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
227
+ * The rejection a second insert of an already-seated id into `table` produces, as the
228
+ * optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
229
+ * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
222
230
  *
223
231
  * Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
224
- * only the primary key can collide on a repeat publish. Matching the column explicitly
225
- * keeps any OTHER uniqueness failure out of the idempotency branch.
232
+ * only the primary key can collide on a repeat insert. Matching the column explicitly
233
+ * keeps any OTHER uniqueness failure out of the caller's idempotency branch.
226
234
  */
227
- function strandIdConflictPattern(table) {
235
+ function idConflictPattern(table) {
228
236
  return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
229
237
  }
230
238
  /**
@@ -243,7 +251,42 @@ function strandIdConflictPattern(table) {
243
251
  * reword reddens there.
244
252
  */
245
253
  export function isStrandIdConflict(error, table) {
246
- return errorChainMatches(error, strandIdConflictPattern(table));
254
+ return errorChainMatches(error, idConflictPattern(table));
255
+ }
256
+ /**
257
+ * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
258
+ * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
259
+ * Text-matched, failing closed, like {@link isStrandIdConflict}.
260
+ *
261
+ * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
262
+ * is retired and no reap pass has removed it yet), so an adopting re-read can come back empty
263
+ * until the next reap pass.
264
+ */
265
+ export function isPendingJoinConflict(error) {
266
+ return errorChainMatches(error, idConflictPattern('PendingJoin'));
267
+ }
268
+ /**
269
+ * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
270
+ * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
271
+ * the bearer token itself.
272
+ */
273
+ export function pendingJoinId(token) {
274
+ return digest([token], 'sha256', 'base64url');
275
+ }
276
+ /**
277
+ * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
278
+ * incarnation the caller read: another write replaced or removed it first (a sibling machine
279
+ * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
280
+ * re-reads and decides again. `liveStampId` is null when the row is gone.
281
+ */
282
+ export class PendingJoinChangedError extends Error {
283
+ constructor(id, expectedStampId, liveStampId) {
284
+ super(`PendingJoin ${id} changed: expected stamp ${expectedStampId}, found ${liveStampId ?? 'no row'}`);
285
+ this.id = id;
286
+ this.expectedStampId = expectedStampId;
287
+ this.liveStampId = liveStampId;
288
+ this.name = 'PendingJoinChangedError';
289
+ }
247
290
  }
248
291
  /**
249
292
  * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
@@ -258,7 +301,7 @@ export const REVOCATION_LEDGER_MARKER = {
258
301
  };
259
302
  /**
260
303
  * The rejection a second insert of the ledger marker produces: the optimystic vtab's
261
- * primary-key wording (see {@link strandIdConflictPattern}), naming both columns of
304
+ * primary-key wording (see {@link idConflictPattern}), naming both columns of
262
305
  * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
263
306
  * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
264
307
  * has no other unique constraint.
@@ -280,10 +323,12 @@ function errorChainMatches(error, pattern) {
280
323
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
281
324
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
282
325
  * secret is the joined strand's shared read key, which its founding party and every member
283
- * hold, so a reaped row is recoverable by re-forming. `OwnerKey` has no production removal
284
- * path and `MinOneOwner` makes an automated owner-key reap a party-bricking hazard.
326
+ * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
327
+ * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
328
+ * production removal path and `MinOneOwner` makes an automated owner-key reap a
329
+ * party-bricking hazard.
285
330
  */
286
- export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand'];
331
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'PendingJoin'];
287
332
  /**
288
333
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
289
334
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
@@ -302,6 +347,48 @@ function joinedStrandRow(row) {
302
347
  FounderOwnerKey: null,
303
348
  };
304
349
  }
350
+ /** Every `PendingJoin` column, in the schema's order. */
351
+ const PENDING_JOIN_COLUMNS = 'Id, Invitation, Disclosure, RequestedAt, ExpiresAt, Outcome, OutcomeAt, StrandId, MembershipInvite, FailureCode, FailureReason, StampId';
352
+ const PENDING_JOIN_INSERT_SQL = `
353
+ insert into CadreControl.PendingJoin (${PENDING_JOIN_COLUMNS})
354
+ with context OwnerKey = ?, Signature = ?
355
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
356
+ `;
357
+ /** A `PendingJoin` row as read; the schema's `KnownOutcome` check backs the `Outcome` cast. */
358
+ function pendingJoinRow(row) {
359
+ return {
360
+ Id: row.Id,
361
+ Invitation: row.Invitation,
362
+ Disclosure: row.Disclosure,
363
+ RequestedAt: row.RequestedAt,
364
+ ExpiresAt: row.ExpiresAt,
365
+ Outcome: row.Outcome,
366
+ OutcomeAt: row.OutcomeAt,
367
+ StrandId: row.StrandId,
368
+ MembershipInvite: row.MembershipInvite,
369
+ FailureCode: row.FailureCode,
370
+ FailureReason: row.FailureReason,
371
+ StampId: row.StampId,
372
+ };
373
+ }
374
+ /**
375
+ * The row fields `PendingJoin.AuthorizedInsert` signs, in the schema's order: a null column
376
+ * signs as `''` and an integer as its text (`cast(... as text)` on the SQL side).
377
+ */
378
+ function pendingJoinAddFields(row) {
379
+ return [
380
+ row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt),
381
+ row.Outcome ?? '', row.OutcomeAt === null ? '' : String(row.OutcomeAt), row.StrandId ?? '',
382
+ row.MembershipInvite ?? '', row.FailureCode ?? '', row.FailureReason ?? '', row.StampId,
383
+ ];
384
+ }
385
+ /** The values of {@link PENDING_JOIN_INSERT_SQL}'s columns, in {@link PENDING_JOIN_COLUMNS} order. */
386
+ function pendingJoinValues(row) {
387
+ return [
388
+ row.Id, row.Invitation, row.Disclosure, row.RequestedAt, row.ExpiresAt, row.Outcome, row.OutcomeAt,
389
+ row.StrandId, row.MembershipInvite, row.FailureCode, row.FailureReason, row.StampId,
390
+ ];
391
+ }
305
392
  /**
306
393
  * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
307
394
  * It provides typed query methods for accessing control network data.
@@ -722,6 +809,29 @@ export class ControlDatabase {
722
809
  const rows = await this.readRows('select Id, Type, MemberPrivateKey from CadreControl.JoinedStrand where Id = ?', [strandId], 'joined-strand');
723
810
  return rows.length === 0 ? null : joinedStrandRow(rows[0]);
724
811
  }
812
+ /**
813
+ * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
814
+ * pending or finished.
815
+ *
816
+ * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
817
+ * drops them: such a row is either a replaced or dismissed incarnation this node still
818
+ * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
819
+ * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
820
+ */
821
+ async queryPendingJoins() {
822
+ this.ensureInitialized();
823
+ const revoked = await this.queryRevokedStamps('PendingJoin');
824
+ const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin`, undefined, 'pending-joins');
825
+ return rows.map(pendingJoinRow).filter(row => !revoked.has(row.StampId));
826
+ }
827
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
828
+ async queryPendingJoin(id) {
829
+ this.ensureInitialized();
830
+ const revoked = await this.queryRevokedStamps('PendingJoin');
831
+ const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin where Id = ?`, [id], 'pending-join');
832
+ const row = rows.length === 0 ? null : pendingJoinRow(rows[0]);
833
+ return row === null || revoked.has(row.StampId) ? null : row;
834
+ }
725
835
  /**
726
836
  * Count rows in a CadreControl table as seen by THIS database instance.
727
837
  *
@@ -1270,13 +1380,10 @@ export class ControlDatabase {
1270
1380
  * one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
1271
1381
  * transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
1272
1382
  * per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
1273
- * digests, mandatory same-transaction tombstones); kept separate rather than
1274
- * generalizing the shared body because no other guarded table has a companion row.
1275
- *
1276
- * NOTE: that duplication is a drift risk, not a correctness one — a future tightening of
1277
- * {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
1278
- * will NOT reach this body. Grep for both when you change either; if a second guarded
1279
- * table ever gains a companion row, generalize instead of copying this a third time.
1383
+ * digests, mandatory same-transaction tombstones). Both sign through
1384
+ * {@link signGuardedRemoval} and run {@link execGuardedRemoval}, so a change to the digest
1385
+ * shape or the statement pair reaches this body; only the stamp reads and the companion-row
1386
+ * branch are its own, because no other guarded table has a companion row.
1280
1387
  *
1281
1388
  * Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
1282
1389
  */
@@ -1289,38 +1396,20 @@ export class ControlDatabase {
1289
1396
  return false;
1290
1397
  }
1291
1398
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1292
- const strandSignature = signMessage(buildAuthorizationMessage('CadreControl.Strand', 'remove', [strandId, strandStamp]));
1293
- const strandRevocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['Strand', strandId, strandStamp]));
1294
- const partyKeySignature = partyKeyStamp === null ? null : signMessage(buildAuthorizationMessage('CadreControl.StrandPartyKey', 'remove', [strandId, partyKeyStamp]));
1295
- const partyKeyRevocationSignature = partyKeyStamp === null ? null : signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['StrandPartyKey', strandId, partyKeyStamp]));
1399
+ const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
1400
+ const partyKeyRemoval = partyKeyStamp === null
1401
+ ? null
1402
+ : signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
1296
1403
  await this.inTransaction('delete Strand', async () => {
1297
- await this.db.exec(`
1298
- delete from CadreControl.Strand
1299
- with context OwnerKey = ?, Signature = ?
1300
- where Id = ?
1301
- `, [ownerKey, strandSignature, strandId]);
1302
- await this.db.exec(`
1303
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1304
- with context OwnerKey = ?, Signature = ?
1305
- values ('Strand', ?, ?, 0)
1306
- `, [ownerKey, strandRevocationSignature, strandId, strandStamp]);
1307
- if (partyKeyStamp !== null) {
1308
- await this.db.exec(`
1309
- delete from CadreControl.StrandPartyKey
1310
- with context OwnerKey = ?, Signature = ?
1311
- where Id = ?
1312
- `, [ownerKey, partyKeySignature, strandId]);
1313
- await this.db.exec(`
1314
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1315
- with context OwnerKey = ?, Signature = ?
1316
- values ('StrandPartyKey', ?, ?, 0)
1317
- `, [ownerKey, partyKeyRevocationSignature, strandId, partyKeyStamp]);
1404
+ await this.execGuardedRemoval(strandRemoval, ownerKey);
1405
+ if (partyKeyRemoval !== null) {
1406
+ await this.execGuardedRemoval(partyKeyRemoval, ownerKey);
1318
1407
  }
1319
1408
  });
1320
- log('Strand deleted: %s (stamp retired%s)', strandId, partyKeyStamp === null ? '' : '; party key deleted, stamp retired');
1321
- this.notifyGuardedDelete({ tableName: 'Strand', rowKey: strandId, stampId: strandStamp });
1322
- if (partyKeyStamp !== null) {
1323
- this.notifyGuardedDelete({ tableName: 'StrandPartyKey', rowKey: strandId, stampId: partyKeyStamp });
1409
+ log('Strand deleted: %s (stamp retired%s)', strandId, partyKeyRemoval === null ? '' : '; party key deleted, stamp retired');
1410
+ this.notifyGuardedDelete(strandRemoval.ref);
1411
+ if (partyKeyRemoval !== null) {
1412
+ this.notifyGuardedDelete(partyKeyRemoval.ref);
1324
1413
  }
1325
1414
  return true;
1326
1415
  }
@@ -1407,6 +1496,78 @@ export class ControlDatabase {
1407
1496
  deleteJoinedStrand(strandId, ownerKey, signMessage) {
1408
1497
  return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
1409
1498
  }
1499
+ /**
1500
+ * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
1501
+ * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
1502
+ * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
1503
+ *
1504
+ * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
1505
+ * this invitation; {@link isPendingJoinConflict} identifies that case.
1506
+ */
1507
+ async insertPendingJoin(row, ownerKey, signMessage) {
1508
+ this.ensureInitialized();
1509
+ log('Inserting pending join: %s (outcome: %s)', row.Id, row.Outcome);
1510
+ const { written, params } = this.signPendingJoinInsert(row, ownerKey, signMessage);
1511
+ await this.execWrite(PENDING_JOIN_INSERT_SQL, params, 'pending-join-insert');
1512
+ log('Pending join inserted: %s', row.Id);
1513
+ return written;
1514
+ }
1515
+ /**
1516
+ * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
1517
+ * an outcome is written, since the table refuses updates. Returns the row as written.
1518
+ *
1519
+ * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
1520
+ * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
1521
+ * between. The live row must still be the incarnation the caller read: when its `StampId` is
1522
+ * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
1523
+ * writes nothing. That check is local; a sibling machine's replacement or removal that lands
1524
+ * between it and the commit fails the transaction instead (its tombstone for the same stamp,
1525
+ * or its successor row, collides on a primary key), and the caller re-reads either way.
1526
+ *
1527
+ * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
1528
+ * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
1529
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose
1530
+ * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
1531
+ */
1532
+ async replacePendingJoin(expectedStampId, next, ownerKey, signMessage) {
1533
+ this.ensureInitialized();
1534
+ const id = next.Id;
1535
+ const removal = signGuardedRemoval('PendingJoin', id, expectedStampId, signMessage);
1536
+ const { written, params } = this.signPendingJoinInsert(next, ownerKey, signMessage);
1537
+ return this.lockedWithRetry(async () => {
1538
+ // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1539
+ const liveStampId = await this.queryStampId('PendingJoin', id, false);
1540
+ if (liveStampId !== expectedStampId) {
1541
+ throw new PendingJoinChangedError(id, expectedStampId, liveStampId);
1542
+ }
1543
+ await this.inTransaction('replace PendingJoin', async () => {
1544
+ await this.execGuardedRemoval(removal, ownerKey);
1545
+ await this.db.exec(PENDING_JOIN_INSERT_SQL, params);
1546
+ });
1547
+ log('Pending join replaced: %s (outcome: %s; stamp retired)', id, next.Outcome);
1548
+ this.notifyGuardedDelete(removal.ref);
1549
+ return written;
1550
+ }, {}, 'pending-join-replace');
1551
+ }
1552
+ /**
1553
+ * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
1554
+ * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
1555
+ * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
1556
+ * `true` when a row was actually removed.
1557
+ */
1558
+ deletePendingJoin(id, ownerKey, signMessage) {
1559
+ return this.lockedWithRetry(() => this.deleteGuardedRow('PendingJoin', id, ownerKey, signMessage), {}, 'pending-join-delete');
1560
+ }
1561
+ /**
1562
+ * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
1563
+ * verifies, returning the row as it will be written and the parameters of
1564
+ * {@link PENDING_JOIN_INSERT_SQL}.
1565
+ */
1566
+ signPendingJoinInsert(row, ownerKey, signMessage) {
1567
+ const written = { ...row, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
1568
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.PendingJoin', 'add', pendingJoinAddFields(written)));
1569
+ return { written, params: [ownerKey, signature, ...pendingJoinValues(written)] };
1570
+ }
1410
1571
  /**
1411
1572
  * Insert a validation key into the control database using an owner signature.
1412
1573
  *
@@ -1652,9 +1813,6 @@ export class ControlDatabase {
1652
1813
  *
1653
1814
  * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
1654
1815
  * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
1655
- *
1656
- * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
1657
- * come from closed literal unions — no caller-supplied string reaches the statement.
1658
1816
  */
1659
1817
  async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
1660
1818
  this.ensureInitialized();
@@ -1664,31 +1822,41 @@ export class ControlDatabase {
1664
1822
  log('delete %s: no row for %s (already absent)', table, keyValue);
1665
1823
  return false;
1666
1824
  }
1667
- const message = buildAuthorizationMessage(`CadreControl.${table}`, 'remove', [keyValue, stampId]);
1668
- const signature = signMessage(message);
1669
- // The tombstone carries its OWN owner signature (`Revocation.Authorized`): retiring a
1670
- // stamp is permanent and party-wide, so the delete's signature deliberately does not
1671
- // cover it — the digests are domain-separated and neither replays as the other.
1672
- const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
1673
- await this.inTransaction(`delete ${table}`, async () => {
1674
- await this.db.exec(`
1675
- delete from CadreControl.${table}
1676
- with context OwnerKey = ?, Signature = ?
1677
- where ${GUARDED_KEY_COLUMN[table]} = ?
1678
- `, [ownerKey, signature, keyValue]);
1679
- // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1680
- // than leaning on the column default — the seat-at-zero rule is load-bearing for
1681
- // reissueRevocations' monotonic bump, so state it at the write site.
1682
- await this.db.exec(`
1683
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1684
- with context OwnerKey = ?, Signature = ?
1685
- values (?, ?, ?, 0)
1686
- `, [ownerKey, revocationSignature, table, keyValue, stampId]);
1687
- });
1825
+ const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
1826
+ await this.inTransaction(`delete ${table}`, () => this.execGuardedRemoval(removal, ownerKey));
1688
1827
  log('%s deleted: %s (stamp retired)', table, keyValue);
1689
- this.notifyGuardedDelete({ tableName: table, rowKey: keyValue, stampId });
1828
+ this.notifyGuardedDelete(removal.ref);
1690
1829
  return true;
1691
1830
  }
1831
+ /**
1832
+ * Delete the row incarnation `removal` names and file its tombstone — the statement pair
1833
+ * every owner removal of a guarded row runs ({@link deleteGuardedRow},
1834
+ * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
1835
+ * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
1836
+ * tombstone commits with it.
1837
+ *
1838
+ * The delete matches the stamp as well as the key, so it can only ever remove the
1839
+ * incarnation the signature names.
1840
+ *
1841
+ * `tableName` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
1842
+ * come from closed literal unions — no caller-supplied string reaches the statement.
1843
+ */
1844
+ async execGuardedRemoval(removal, ownerKey) {
1845
+ const { tableName, rowKey, stampId } = removal.ref;
1846
+ await this.db.exec(`
1847
+ delete from CadreControl.${tableName}
1848
+ with context OwnerKey = ?, Signature = ?
1849
+ where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
1850
+ `, [ownerKey, removal.signature, rowKey, stampId]);
1851
+ // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1852
+ // than leaning on the column default — the seat-at-zero rule is load-bearing for
1853
+ // reissueRevocations' monotonic bump, so state it at the write site.
1854
+ await this.db.exec(`
1855
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1856
+ with context OwnerKey = ?, Signature = ?
1857
+ values (?, ?, ?, 0)
1858
+ `, [ownerKey, removal.revocationSignature, tableName, rowKey, stampId]);
1859
+ }
1692
1860
  /**
1693
1861
  * Fire the guarded-delete listener for one committed tombstone, swallowing (and
1694
1862
  * logging) a listener throw — a committed delete never fails because bookkeeping did.
@@ -1826,7 +1994,8 @@ export class ControlDatabase {
1826
1994
  * reads from this node (every other node already filters it by tombstone). A revoked
1827
1995
  * node therefore keeps its own copy of its own row; "a node that learns it has been
1828
1996
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
1829
- * `ValidationKey` and `JoinedStrand` have no self notion and are not special-cased.
1997
+ * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
1998
+ * special-cased.
1830
1999
  *
1831
2000
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
1832
2001
  * starve every tombstone after the failing one on every subsequent pass, and each row is