@serfab/cadre-core 0.9.0 → 0.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 (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,5 +1,6 @@
1
1
  import debug from 'debug';
2
- import { digest, sign, verify, generatePrivateKey, getPublicKey } from '@optimystic/quereus-plugin-crypto';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
3
4
  import { canonicalDatetime } from './canonical-datetime.js';
4
5
  const log = debug('sereus:cadre:strand-membership');
5
6
  /**
@@ -19,18 +20,17 @@ export const STRAND_ENGINE = 'quereus';
19
20
  */
20
21
  export const STRAND_ENGINE_VERSION = '0.1.0';
21
22
  /**
22
- * The single-digest ed25519 signer the `Strand.*` membership/RBAC constraints
23
- * verify against.
23
+ * The single-payload ed25519 signer the `Strand.Invite`/`Strand.ConsumedInvite`
24
+ * handshake constraints verify against.
24
25
  *
25
- * `schemas/strand.qsql` signs a single SHA-256 digest over a `'|'`-joined payload
26
- * (e.g. `Member.Authorized` verifies
27
- * `verify(digest(new.Key), context.ManagerSignature, A.MemberKey, 'ed25519')`).
26
+ * Those constraints sign a single SHA-256 digest over a `'|'`-joined payload
27
+ * (e.g. `InviteValid` verifies
28
+ * `verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), ...)`).
28
29
  * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
29
30
  * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
30
31
  * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
31
- * This differs from the control-layer's multi-field `buildAuthorizationMessage`
32
- * (a single digest over MANY fields) — the strand layer is a single digest over ONE
33
- * joined payload field.
32
+ * The membership/RBAC tables (Member/Manager/MemberPeer/Revocation) instead use
33
+ * domain/action-tagged VARIADIC digests — see {@link signStrandApproval}.
34
34
  *
35
35
  * All strand keys are ed25519, so the explicit `'ed25519'` curve arg is mandatory
36
36
  * (the crypto plugin otherwise defaults to secp256k1). Mirrors the proven
@@ -47,7 +47,7 @@ export function signStrandPayload(payload, privateKeyB64) {
47
47
  /**
48
48
  * The verifier counterpart to {@link signStrandPayload}.
49
49
  *
50
- * Mirrors what every `Strand.*` constraint computes:
50
+ * Mirrors what the single-payload `Strand.*` constraints compute:
51
51
  * `verify(digest(payload), signature, publicKey, 'ed25519')`.
52
52
  * SQL `digest`'s default output and `verify`'s default `inputEncoding`/`sigEncoding`/
53
53
  * `keyEncoding` are all base64url, so the off-engine check operates on the exact
@@ -64,6 +64,121 @@ export function verifyStrandPayload(payload, signatureB64, publicKeyB64) {
64
64
  const payloadDigest = digest([payload], 'sha256', 'base64url');
65
65
  return verify(payloadDigest, signatureB64, publicKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
66
66
  }
67
+ /**
68
+ * Sign a domain/action-tagged `Strand.*` membership/RBAC approval.
69
+ *
70
+ * Every `Authorized` branch on `Member`/`Manager`/`MemberPeer`/`Revocation`
71
+ * verifies a VARIADIC digest — `digest('<domain>', '<action>', <field>, ...,
72
+ * <StampId>)` — whose leading two elements are fixed literals (the domain tag,
73
+ * e.g. `'Strand.Member'`, and an action tag, e.g. `'add'`) and whose trailing
74
+ * element is the row's per-incarnation StampId. The tags keep an approval from
75
+ * verifying against any rule but the one it was minted for (the idiom of the
76
+ * control layer's `buildAuthorizationMessage`); the StampId binding makes it
77
+ * single-use — it authorizes exactly one incarnation of one row, and dies when
78
+ * that row's stamp is retired into `Strand.Revocation`.
79
+ *
80
+ * The caller passes the digest elements exactly as the matching SQL constraint
81
+ * lists them (each SQL argument = one array element), PRESERVING TYPE: the
82
+ * crypto plugin's digest framing is type-tagged (INTEGER 1 and TEXT '1' encode
83
+ * differently, deliberately), so `Manager.Authorized`'s promotion branch —
84
+ * whose digest includes the INTEGER `new.Generation` — must be signed over the
85
+ * NUMBER, not its string form. Integer `number` and `bigint` encode
86
+ * identically in that framing, so JS-side numbers match however the engine
87
+ * represents the SQL integer.
88
+ *
89
+ * Like {@link signStrandPayload}, the raw digest BYTES are ed25519-signed
90
+ * directly, matching SQL `verify(...)`'s default base64url input decoding.
91
+ *
92
+ * @param fields - The digest elements, in constraint order (tags first, StampId last).
93
+ * @param privateKeyB64 - The signer's base64url ed25519 private seed.
94
+ * @returns The base64url ed25519 signature over the tagged digest.
95
+ */
96
+ export function signStrandApproval(fields, privateKeyB64) {
97
+ const hashBytes = digest(fields, 'sha256', 'bytes');
98
+ return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
99
+ }
100
+ /**
101
+ * Mint a fresh per-row authorization nonce (StampId) for a `Strand.Member`,
102
+ * `Strand.Manager`, or `Strand.MemberPeer` row.
103
+ *
104
+ * 256 bits of CSPRNG output, base64url-encoded — enough entropy that a future
105
+ * legitimate stamp cannot be guessed and pre-planted into `Strand.Revocation`
106
+ * (the `RowIsGone` comment in `schemas/strand.qsql` leans on exactly this).
107
+ * Plain randomness, deliberately NOT the control layer's peer-derived
108
+ * `generateStampId`: strand rows are not per-peer, so entropy alone carries
109
+ * the uniqueness.
110
+ *
111
+ * @returns A fresh base64url stamp (43 chars for 32 bytes).
112
+ */
113
+ export function generateStrandStampId() {
114
+ // randomBytes' first arg is BITS, not bytes — 256 bits = 32 bytes.
115
+ const bytes = randomBytes(256, 'bytes');
116
+ return uint8ArrayToString(bytes, 'base64url');
117
+ }
118
+ /**
119
+ * Run `fn` inside one explicit strand transaction: begin, fn, commit — with the
120
+ * standard rollback-on-failure shape.
121
+ *
122
+ * Used wherever two `Strand.*` writes must land atomically: the invite-consume
123
+ * pair (Member + ConsumedInvite) and every delete-plus-tombstone pair (the
124
+ * `RevocationRecorded` constraints require the `Revocation` row in the SAME
125
+ * transaction as the delete it retires).
126
+ *
127
+ * JOINS a caller-owned transaction instead of opening its own: Quereus's
128
+ * `Database.beginTransaction()` hard-throws "Transaction already active" on any
129
+ * nesting, and a caller composing several writers in one transaction (e.g. a
130
+ * resign + revoke pair that must land atomically) is exactly the case where the
131
+ * deferred constraints should fire once, at the CALLER's commit. In joined mode
132
+ * commit/rollback belong to the caller, so failures simply propagate.
133
+ */
134
+ async function inStrandTransaction(db, fn) {
135
+ if (!db.getAutocommit()) {
136
+ await fn();
137
+ return;
138
+ }
139
+ await db.beginTransaction();
140
+ try {
141
+ await fn();
142
+ await db.commit();
143
+ }
144
+ catch (error) {
145
+ // A failed commit() already tears down the transaction, so rollback() would
146
+ // throw "No transaction active" and mask the real cause — swallow only that.
147
+ try {
148
+ await db.rollback();
149
+ }
150
+ catch (rollbackError) {
151
+ log('Rollback after strand transaction failure was a no-op: %s', rollbackError);
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+ /**
157
+ * File the `Strand.Revocation` tombstone that retires a deleted row's StampId.
158
+ *
159
+ * Runs inside the SAME transaction as the delete it accompanies — the guarded
160
+ * tables' `RevocationRecorded` (deferred) requires the tombstone at commit, and
161
+ * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
162
+ * the pair only ever lands together.
163
+ *
164
+ * `Revocation.Authorized` verifies the retire-tagged digest against a
165
+ * PRE-transaction (`committed.Member`) row, so the retiree must have been a
166
+ * member BEFORE this transaction. Every writer flow satisfies this: managers are
167
+ * members, and a self-departing member is committed until this very transaction
168
+ * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
169
+ * context is non-null — both fields are always supplied.
170
+ *
171
+ * @param db - The strand database, INSIDE an open transaction.
172
+ * @param tableName - Which guarded table the stamp belonged to.
173
+ * @param stampId - The retired stamp.
174
+ * @param retiree - The committed member filing the tombstone (the delete's own signer).
175
+ */
176
+ async function insertRevocation(db, tableName, stampId, retiree) {
177
+ const signature = signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64);
178
+ await db.exec(`insert into Strand.Revocation (TableName, StampId)
179
+ with context MemberKey = ?, Signature = ?
180
+ values (?, ?)`, [retiree.publicKeyB64, signature, tableName, stampId]);
181
+ }
67
182
  /**
68
183
  * Count rows in a `Strand.*` table as seen by this database instance.
69
184
  *
@@ -108,38 +223,52 @@ async function insertHeaderIfAbsent(db, params) {
108
223
  /**
109
224
  * Insert the founding `Strand.Member` if no member exists yet.
110
225
  *
111
- * The empty-table state satisfies the `count(1) from Member <= 1` bootstrap branch
112
- * of `Member.Authorized`, so no manager signature is needed — the context fields
113
- * are explicit nulls. Guarding on the count makes this idempotent: a re-run (the
114
- * founder re-`addStrand`/`resumeStrand`) finds the member present and skips.
226
+ * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
227
+ * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
228
+ * needed — the context fields are explicit nulls. The branch also caps the
229
+ * POST-image at one member, so it waives authorization for exactly this single
230
+ * seat, never for a batch. The row still mints a fresh StampId — the column is
231
+ * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
232
+ * count makes this idempotent: a re-run (the founder re-`addStrand`/
233
+ * `resumeStrand`) finds the member present and skips.
115
234
  */
116
235
  async function insertFounderMemberIfAbsent(db, memberKey, strandId) {
117
236
  if (await strandTableCount(db, 'Member') > 0) {
118
237
  log('Member already present for strand %s; skipping founder Member', strandId);
119
238
  return;
120
239
  }
121
- await db.exec(`insert into Strand.Member (Key)
122
- with context ManagerKey = null, ManagerSignature = null
123
- values (?)`, [memberKey]);
240
+ await db.exec(`insert into Strand.Member (Key, StampId)
241
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
242
+ values (?, ?)`, [memberKey, generateStrandStampId()]);
124
243
  log('Inserted founding Member for strand %s', strandId);
125
244
  }
126
245
  /**
127
246
  * Insert the founding `Strand.Manager` if no manager exists yet.
128
247
  *
129
- * The empty-table state satisfies the `count(1) from Manager <= 1` bootstrap
130
- * branch of `Manager.Authorized`, so no signature is needed. Idempotent via the
131
- * same count guard. Must run AFTER the Header insert: `Manager.OnlyClosed` is a
132
- * deferred (subquery) check evaluated at commit, and the sequential auto-commit
133
- * Header→Member→Manager order ensures the closed `Header` is committed first.
248
+ * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
249
+ * no signature is needed (a fresh StampId is still minted — the column is NOT
250
+ * NULL). The founder is seated at `Generation = 0` — the root of
251
+ * the manager lineage; the bootstrap branch requires exactly that value, and every
252
+ * later manager is seated at a strictly greater generation (see {@link addManager}).
253
+ * Idempotent via the same count guard.
254
+ *
255
+ * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
256
+ * the founding state and now reads `exists (select 1 from Member M where M.Key =
257
+ * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
258
+ * founding `Member` row MUST already exist when this commits — a Manager-first
259
+ * seeding path is rejected. It must also run AFTER the Header insert:
260
+ * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
261
+ * sequential auto-commit Header→Member→Manager order in
262
+ * {@link bootstrapFounderMembership} satisfies both.
134
263
  */
135
264
  async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
136
265
  if (await strandTableCount(db, 'Manager') > 0) {
137
266
  log('Manager already present for strand %s; skipping founder Manager', strandId);
138
267
  return;
139
268
  }
140
- await db.exec(`insert into Strand.Manager (MemberKey)
269
+ await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
141
270
  with context ManagerKey = null, Signature = null
142
- values (?)`, [memberKey]);
271
+ values (?, 0, ?)`, [memberKey, generateStrandStampId()]);
143
272
  log('Inserted founding Manager for strand %s', strandId);
144
273
  }
145
274
  /**
@@ -229,8 +358,8 @@ export async function issueInvite(db, params) {
229
358
  * Redeem an invite to admit a new `Member`, atomically.
230
359
  *
231
360
  * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
232
- * `ConsumedInvite`'s `MemberExists`/`MemberValid` need the `Member` row — a
233
- * circular dependency. Both are deferred (subquery-bearing) checks that evaluate
361
+ * `ConsumedInvite`'s `MemberExists` needs the `Member` row — a circular
362
+ * dependency. Both are deferred (subquery-bearing) checks that evaluate
234
363
  * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
235
364
  * transaction lets both rows exist when the deferred checks fire. This mirrors
236
365
  * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
@@ -238,7 +367,9 @@ export async function issueInvite(db, params) {
238
367
  * The `ConsumedInvite` insert carries an `InviteSignature` over
239
368
  * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
240
369
  * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
241
- * signature — its admission is the existence of the matching `ConsumedInvite`.
370
+ * signature — its admission is the existence of the matching `ConsumedInvite` —
371
+ * but still mints a fresh StampId (NOT NULL on every path; a revoked-then-
372
+ * re-invited member is a fresh incarnation under a fresh stamp).
242
373
  *
243
374
  * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
244
375
  * of the same invite is rejected by the PK (a distinct layer from the control
@@ -275,42 +406,171 @@ export async function consumeInvite(db, params) {
275
406
  // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
276
407
  // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
277
408
  const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
278
- await db.beginTransaction();
279
- try {
409
+ await inStrandTransaction(db, async () => {
280
410
  // 1. Member — admitted by the deferred invite branch (the matching
281
411
  // ConsumedInvite below), so no manager signature is supplied.
282
- await db.exec(`insert into Strand.Member (Key)
283
- with context ManagerKey = null, ManagerSignature = null
284
- values (?)`, [memberKey]);
412
+ await db.exec(`insert into Strand.Member (Key, StampId)
413
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
414
+ values (?, ?)`, [memberKey, generateStrandStampId()]);
285
415
  // 2. ConsumedInvite — proves possession of the invite private key and that the
286
416
  // invite has not expired (NotExpired gate against the canonical Now).
287
417
  await db.exec(`insert into Strand.ConsumedInvite (InviteKey, MemberKey)
288
418
  with context InviteSignature = ?, Now = ?
289
419
  values (?, ?)`, [inviteSignature, nowCanonical, inviteKey, memberKey]);
290
- await db.commit();
291
- log('Consumed invite %s -> member %s', inviteKey, memberKey);
420
+ });
421
+ log('Consumed invite %s -> member %s', inviteKey, memberKey);
422
+ }
423
+ /**
424
+ * Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
425
+ *
426
+ * Unfiltered scan + JavaScript comparison, deliberately: `ConsumedInvite` and
427
+ * `CancelledInvite` each have the single column `InviteKey` as their primary key, so
428
+ * ANY where-equality on it is a FULL-PK predicate — which the optimystic virtual-table
429
+ * module serves via a single-key point lookup that can MISS on a networked strand (the
430
+ * argument {@link scanMemberPeers} makes in full). Reading the whole column and
431
+ * comparing in JS depends only on the scan returning a SUPERSET of the live rows.
432
+ *
433
+ * The table name is a fixed literal supplied by this module (never user input), so the
434
+ * interpolation is not an injection surface — same as {@link strandTableCount}.
435
+ *
436
+ * NOTE: every caller pays a WHOLE-column read — {@link cancelInvite}'s idempotence guard
437
+ * reads all cancelled keys to answer one membership question, and
438
+ * {@link listOutstandingInvites} reads both marker tables per call. Fine at strand scale
439
+ * (a handful of invitations); if invite churn ever gets large this wants a real filtered
440
+ * read, which in turn needs the networked point-lookup gap closed
441
+ * (`debt-composite-pk-point-lookup-unreliable-untracked`).
442
+ */
443
+ async function scanInviteKeys(db, table) {
444
+ const keys = new Set();
445
+ for await (const row of db.eval(`select InviteKey from Strand.${table}`)) {
446
+ keys.add(row.InviteKey);
292
447
  }
293
- catch (error) {
294
- // A failed commit() already tears down the transaction, so rollback() would
295
- // throw "No transaction active" and mask the real cause — swallow only that.
296
- try {
297
- await db.rollback();
448
+ return keys;
449
+ }
450
+ /**
451
+ * Cancel an outstanding invitation, permanently: it may never be consumed.
452
+ *
453
+ * Files a `Strand.CancelledInvite` tombstone carrying a manager signature over
454
+ * `digest('Strand.CancelledInvite', 'cancel', InviteKey)`. The digest binds the exact
455
+ * invite key, so an approval minted for one invitation cannot cancel another.
456
+ *
457
+ * One row, one statement — no transaction needed. `CancelledInvite.Authorized` is a
458
+ * deferred (subquery-bearing) check, so it fires at this statement's own auto-commit.
459
+ *
460
+ * Insert-if-absent, matching {@link registerMemberPeer}'s and {@link revokeMember}'s
461
+ * restart-safe shape: a repeat cancellation logs and returns rather than throwing on the
462
+ * primary key. (Check-then-write is not atomic; the primary key is the real backstop —
463
+ * the guard is for the sequential repeat/restart path.)
464
+ *
465
+ * Cancellation is what makes removal a re-entry gate: it is the `ConsumedInvite` insert
466
+ * that `NotCancelled` blocks, and `Member.Authorized`'s invite branch needs a
467
+ * same-transaction FRESH `ConsumedInvite` row, so a cancelled invitation rolls the whole
468
+ * join back. Un-cancelling is impossible (`CancelledInvite.Immutable`) — to re-invite a
469
+ * party, {@link issueInvite} a fresh invitation.
470
+ *
471
+ * Enumerate what there is to cancel with {@link listOutstandingInvites}: an invitation
472
+ * names no invitee, so the strand cannot tell a manager WHICH invitations a departing
473
+ * member holds. Invitee binding is tracked as `feat-strand-invitee-bound-invites`.
474
+ *
475
+ * @param db - The closed strand's database.
476
+ * @param params - The cancelling manager's keypair and the invite key to kill.
477
+ * @throws If `CancelledInvite.Authorized` rejects (a non-manager, a manager seated in
478
+ * this transaction, or an approval minted for a different invite key) or `OnlyClosed`
479
+ * rejects (an open strand); the insert rolls back, leaving no tombstone.
480
+ */
481
+ export async function cancelInvite(db, params) {
482
+ const { managerKeyPair, inviteKey } = params;
483
+ if ((await scanInviteKeys(db, 'CancelledInvite')).has(inviteKey)) {
484
+ log('Invite %s already cancelled; skipping', inviteKey);
485
+ return;
486
+ }
487
+ const signature = signStrandApproval(['Strand.CancelledInvite', 'cancel', inviteKey], managerKeyPair.privateKeyB64);
488
+ await db.exec(`insert into Strand.CancelledInvite (InviteKey)
489
+ with context ManagerKey = ?, ManagerSignature = ?
490
+ values (?)`, [managerKeyPair.publicKeyB64, signature, inviteKey]);
491
+ log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
492
+ }
493
+ /**
494
+ * The invitations that are still redeemable: neither consumed, nor cancelled, nor
495
+ * expired at `nowMs`.
496
+ *
497
+ * `Strand.Invite` is insert-only and carries no state column, so "outstanding" is not a
498
+ * property of the row — it is the `Invite` set minus the `ConsumedInvite` keys, minus
499
+ * the `CancelledInvite` keys, minus anything whose `Expiration` has passed. All three
500
+ * exclusions mirror gates a consume would hit anyway (`ConsumedInvite`'s primary key,
501
+ * `NotCancelled`, `NotExpired`).
502
+ *
503
+ * Reads via unfiltered scans and compares in JavaScript — see {@link scanInviteKeys} for
504
+ * why a full-PK where-equality is not reliable on a networked strand.
505
+ *
506
+ * Expiry is compared as `expiration > canonicalDatetime(nowMs)`: the same transform
507
+ * {@link issueInvite} uses to STORE `Invite.Expiration`, so the string comparison orders
508
+ * chronologically, and the same strict `>` the on-engine `NotExpired` gate uses (expiry
509
+ * is exclusive — an invitation is dead at its expiry instant, not after it).
510
+ *
511
+ * This is the manager's enumeration side of {@link cancelInvite}, and the "door is open"
512
+ * pre-flight behind `StrandMemberVerifier.isAuthorizedToJoin`.
513
+ *
514
+ * @param db - The closed strand's database.
515
+ * @param nowMs - The instant (epoch ms) to compare expiries against; defaults to
516
+ * `Date.now()`. Tests pin it so the comparison is deterministic, mirroring
517
+ * {@link consumeInvite}'s `nowMs` convention.
518
+ * @returns The redeemable invitations, in NO guaranteed order (the scans promise only a
519
+ * superset of the live rows, not a stable sequence — sort by `expiration` if order
520
+ * matters); empty if none.
521
+ */
522
+ export async function listOutstandingInvites(db, nowMs) {
523
+ // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
524
+ const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
525
+ const consumed = await scanInviteKeys(db, 'ConsumedInvite');
526
+ const cancelled = await scanInviteKeys(db, 'CancelledInvite');
527
+ const outstanding = [];
528
+ for await (const row of db.eval('select Key, Expiration from Strand.Invite')) {
529
+ const inviteKey = row.Key;
530
+ const expiration = row.Expiration ?? null;
531
+ if (consumed.has(inviteKey) || cancelled.has(inviteKey)) {
532
+ continue;
298
533
  }
299
- catch (rollbackError) {
300
- log('Rollback after consumeInvite failure was a no-op: %s', rollbackError);
534
+ if (expiration != null && expiration <= nowCanonical) {
535
+ continue;
301
536
  }
302
- throw error;
537
+ outstanding.push({ inviteKey, expiration });
303
538
  }
539
+ return outstanding;
540
+ }
541
+ // ── Member admission + removal (manager-admit, revoke, leave) ─────────────────
542
+ /**
543
+ * The live StampId of the `Strand.Member` row keyed by `memberKey`, or
544
+ * `undefined` if no such row is visible.
545
+ *
546
+ * `Member`'s primary key is the single `Key` column, so ANY where-equality on it
547
+ * is a full-PK predicate — which the optimystic virtual-table module serves via a
548
+ * single-key point lookup that can MISS on a networked strand (the same failure
549
+ * mode {@link scanMemberPeers} documents). So this reads via an UNFILTERED scan
550
+ * and compares the key in JavaScript: correctness depends only on the scan
551
+ * returning a superset of the live rows — the weakest possible assumption about
552
+ * the storage layer.
553
+ */
554
+ async function memberStampId(db, memberKey) {
555
+ for await (const row of db.eval('select Key, StampId from Strand.Member')) {
556
+ if (row.Key === memberKey) {
557
+ return row.StampId;
558
+ }
559
+ }
560
+ return undefined;
304
561
  }
305
562
  /**
306
563
  * Admit a `Member` directly by manager signature — the sibling of the invite
307
564
  * path on `Member.Authorized`'s direct-manager branch.
308
565
  *
309
- * The constraint verifies `digest(new.Key)` against a `Manager` row matching
310
- * `context.ManagerKey`, so the payload is just the member key (no `'|'` join).
311
- * No `ConsumedInvite` is involved: this is the path a manager uses to seat a
312
- * member it already trusts (e.g. a manager-side enrollment that admits a
313
- * party already authorised out-of-band).
566
+ * The constraint verifies the add-tagged digest
567
+ * `digest('Strand.Member', 'add', new.Key, new.StampId)` against a pre-existing
568
+ * (`committed.Manager`) row matching `context.ManagerKey`. The stamp is minted
569
+ * fresh here and bound inside the signed digest, so the approval seats exactly
570
+ * this incarnation: once the member is removed (retiring the stamp), the captured
571
+ * approval can never re-seat it. No `ConsumedInvite` is involved: this is the
572
+ * path a manager uses to seat a member it already trusts (e.g. a manager-side
573
+ * enrollment that admits a party already authorised out-of-band).
314
574
  *
315
575
  * @param db - The closed strand's database.
316
576
  * @param params - The admitting manager keypair and the new member key.
@@ -319,32 +579,135 @@ export async function consumeInvite(db, params) {
319
579
  */
320
580
  export async function addMemberByManager(db, params) {
321
581
  const { managerKeyPair, memberKey } = params;
322
- const signature = signStrandPayload(memberKey, managerKeyPair.privateKeyB64);
323
- await db.exec(`insert into Strand.Member (Key)
324
- with context ManagerKey = ?, ManagerSignature = ?
325
- values (?)`, [managerKeyPair.publicKeyB64, signature, memberKey]);
582
+ const stampId = generateStrandStampId();
583
+ const signature = signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64);
584
+ await db.exec(`insert into Strand.Member (Key, StampId)
585
+ with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
586
+ values (?, ?)`, [managerKeyPair.publicKeyB64, signature, memberKey, stampId]);
326
587
  log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
327
588
  }
589
+ /**
590
+ * Revoke a `Member` on the signature of an existing manager.
591
+ *
592
+ * Reads the target row's live StampId first (a quiet no-op if the row is already
593
+ * absent — a repeated or restarted revocation should not throw), then runs ONE
594
+ * transaction pairing the delete with its `Strand.Revocation` tombstone: the
595
+ * schema's `Member.RevocationRecorded` requires the tombstone in the same
596
+ * transaction, and the tombstone is what permanently retires the stamp so the
597
+ * captured admission approval can never re-seat this incarnation.
598
+ *
599
+ * The manager-removal branch of `Member.Authorized` verifies the remove-tagged
600
+ * digest `digest('Strand.Member', 'remove', old.Key, old.StampId)` against a
601
+ * `committed.Manager` row matching `context.ManagerKey` — distinct from the
602
+ * add-tagged admission payload, and bound to the live stamp, so neither a
603
+ * captured admission nor a captured PRIOR removal (whose stamp differs) replays.
604
+ *
605
+ * A revoked member cannot re-admit itself off the invite it ALREADY SPENT: that
606
+ * `ConsumedInvite` row is stale, and the invite branch requires a
607
+ * same-transaction FRESH consumption. Re-admission normally takes a fresh
608
+ * manager action ({@link addMemberByManager} or a new invite).
609
+ *
610
+ * An UNSPENT invite the revoked party holds is killed EXPLICITLY, by the manager
611
+ * calling {@link cancelInvite} on it (enumerate the candidates with
612
+ * {@link listOutstandingInvites}); a cancelled invitation can never be consumed,
613
+ * and cancellation is permanent. This removal does NOT cascade into cancellation:
614
+ * an invitation names no invitee, so there is nothing to match the removed member
615
+ * against — the strand cannot tell which invitations were meant for it, or whether
616
+ * it holds any. Binding an invitation to its invitee is tracked as
617
+ * `feat-strand-invitee-bound-invites`.
618
+ *
619
+ * A manager must resign its `Manager` row before (or in the same transaction
620
+ * as) losing membership — `Member.NotAManager` rejects un-membering a key that
621
+ * still holds a Manager row. And the strand never drops to zero members
622
+ * (`Member.MinOneMember`, a local-count floor with the same cross-node caveat
623
+ * as `MinOneManager`).
624
+ *
625
+ * @param db - The closed strand's database.
626
+ * @param params - The revoking manager keypair and the target member key.
627
+ * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
628
+ * signer), `Member.NotAManager` rejects (the target still holds a Manager
629
+ * row), or `Member.MinOneMember` rejects (the removal would empty the member
630
+ * set); the whole delete+tombstone transaction rolls back.
631
+ */
632
+ export async function revokeMember(db, params) {
633
+ const { managerKeyPair, memberKey } = params;
634
+ const stampId = await memberStampId(db, memberKey);
635
+ if (stampId == null) {
636
+ log('Member %s already absent; skipping revoke', memberKey);
637
+ return;
638
+ }
639
+ const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
640
+ await inStrandTransaction(db, async () => {
641
+ await db.exec(`delete from Strand.Member
642
+ with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
643
+ where Key = ?`, [managerKeyPair.publicKeyB64, signature, memberKey]);
644
+ await insertRevocation(db, 'Member', stampId, managerKeyPair);
645
+ });
646
+ log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
647
+ }
648
+ /**
649
+ * A member leaves the strand by deleting its own `Member` row.
650
+ *
651
+ * The self-departure branch of `Member.Authorized` verifies
652
+ * `digest('Strand.Member', 'leave', old.Key, old.StampId)` against `old.Key`
653
+ * itself via `context.MemberSignature`. The `'leave'` tag (vs the manager
654
+ * branch's `'remove'`) is belt-and-braces only — the two branches verify against
655
+ * different keys; the anti-replay fix is the stamp binding. Like
656
+ * {@link revokeMember}, the live stamp is read first (absent row → quiet no-op)
657
+ * and the delete pairs with its `Strand.Revocation` tombstone in one transaction.
658
+ *
659
+ * The same floors as {@link revokeMember} apply: a manager must resign first
660
+ * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
661
+ *
662
+ * @param db - The closed strand's database.
663
+ * @param params - The departing member's own keypair.
664
+ * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
665
+ * rejects; the whole delete+tombstone transaction rolls back.
666
+ */
667
+ export async function leaveStrand(db, params) {
668
+ const { memberKeyPair } = params;
669
+ const memberKey = memberKeyPair.publicKeyB64;
670
+ const stampId = await memberStampId(db, memberKey);
671
+ if (stampId == null) {
672
+ log('Member %s already absent; skipping leave', memberKey);
673
+ return;
674
+ }
675
+ const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
676
+ await inStrandTransaction(db, async () => {
677
+ await db.exec(`delete from Strand.Member
678
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
679
+ where Key = ?`, [signature, memberKey]);
680
+ await insertRevocation(db, 'Member', stampId, memberKeyPair);
681
+ });
682
+ log('Member %s left the strand', memberKey);
683
+ }
328
684
  /**
329
685
  * Register a network node (`PeerId`) as acting on behalf of a member.
330
686
  *
331
- * The member self-signs `MemberKey || '|' || PeerId` with its OWN key; the schema's
332
- * `MemberPeer.Authorized` verifies that signature against `coalesce(new.MemberKey,
333
- * old.MemberKey)` — i.e. the member key itself — so only the member that owns the
334
- * key can register peers for it. A deferred `MemberExists` additionally requires the
335
- * `Member` row to already exist, so a peer for a non-member is rejected at commit.
687
+ * The member self-signs the add-tagged digest
688
+ * `digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId)`
689
+ * with its OWN key; the schema's `MemberPeer.Authorized` verifies that signature
690
+ * against `new.MemberKey` — i.e. the member key itself — so only the member that
691
+ * owns the key can register peers for it. The stamp is minted fresh here and
692
+ * bound inside the digest, so a captured registration approval neither
693
+ * re-registers a cleared binding (its stamp is retired) nor authorizes a removal
694
+ * (wrong tag). A deferred `MemberExists` additionally requires the `Member` row
695
+ * to already exist, so a peer for a non-member is rejected at commit.
336
696
  *
337
- * Insert-if-absent on the composite PK `(MemberKey, PeerId)`: a re-register on
338
- * restart (or a redundant call) is a no-op rather than relying on the platform's
339
- * PK-uniqueness rejection (not enforced in bootstrap mode — see
340
- * `optimystic-insert-pk-uniqueness-not-enforced`). A member may register multiple
341
- * DISTINCT `PeerId`s (multi-device); each is its own row under the same `MemberKey`.
697
+ * Insert-if-absent: a re-register on restart (or a redundant call) is a no-op. The
698
+ * platform DOES reject a duplicate-PK insert, but a restart-safe re-register should
699
+ * succeed quietly rather than throw, so the writer guards on existence instead of
700
+ * catching. The guard ({@link memberPeerStampId}) scans the member's peers and compares
701
+ * both key columns in JavaScript rather than seeking the composite primary key — see
702
+ * {@link scanMemberPeers} for why a full-PK point lookup is not reliable on a networked
703
+ * strand. A member may register multiple DISTINCT `PeerId`s (multi-device); each is its
704
+ * own row (and its own stamp) under the same `MemberKey`.
342
705
  *
343
- * Out of scope: peer DELETION. The schema's `MemberExists` reads `new.MemberKey`,
344
- * which is null on delete, so a `MemberPeer` delete is currently rejected by the
345
- * schema (noted in the `apply-strand-membership-schema` review). Removing a peer
346
- * would need a schema tweak (e.g. `coalesce(new.MemberKey, old.MemberKey)` in
347
- * `MemberExists`), so no `removeMemberPeer` is provided here.
706
+ * Peer DELETION is signature-checked too, not rejected outright: `Authorized` carries
707
+ * distinct delete branches (self + manager), each over its own action-tagged digest
708
+ * bound to the row's stamp. Both removal paths live in {@link removeMemberPeer}.
709
+ * UPDATE is refused outright by `NoUpdate`: a re-binding is a remove plus a fresh
710
+ * register (under a fresh stamp).
348
711
  *
349
712
  * @param db - The closed strand's database (the member already exists).
350
713
  * @param params - The member's own keypair and the peer id to bind.
@@ -354,96 +717,356 @@ export async function addMemberByManager(db, params) {
354
717
  export async function registerMemberPeer(db, params) {
355
718
  const { memberKeyPair, peerId } = params;
356
719
  const memberKey = memberKeyPair.publicKeyB64;
357
- if (await memberPeerExists(db, memberKey, peerId)) {
720
+ if (await memberPeerStampId(db, memberKey, peerId) != null) {
358
721
  log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
359
722
  return;
360
723
  }
361
- const payload = `${memberKey}|${peerId}`;
362
- const signature = signStrandPayload(payload, memberKeyPair.privateKeyB64);
363
- await db.exec(`insert into Strand.MemberPeer (MemberKey, PeerId)
364
- with context Signature = ?
365
- values (?, ?)`, [signature, memberKey, peerId]);
724
+ const stampId = generateStrandStampId();
725
+ const signature = signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64);
726
+ await db.exec(`insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
727
+ with context Signature = ?, ManagerKey = null, ManagerSignature = null
728
+ values (?, ?, ?)`, [signature, memberKey, peerId, stampId]);
366
729
  log('Registered MemberPeer (%s, %s)', memberKey, peerId);
367
730
  }
368
- /** True iff a `MemberPeer` row already exists for this `(MemberKey, PeerId)`. */
369
- async function memberPeerExists(db, memberKey, peerId) {
370
- for await (const row of db.eval('select count(1) as Count from Strand.MemberPeer where MemberKey = ? and PeerId = ?', [memberKey, peerId])) {
371
- return (row.Count ?? 0) > 0;
731
+ /**
732
+ * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
733
+ * in JavaScript.
734
+ *
735
+ * Deliberately does NOT filter on the full composite primary key. `MemberPeer`'s PK
736
+ * is `(MemberKey, PeerId)`, so a `where MemberKey = ? and PeerId = ?` predicate puts
737
+ * an equality on BOTH key columns — which the optimystic virtual-table module reports
738
+ * as fully handled and serves via a single-key point lookup (one `find` descent), with
739
+ * the SQL engine adding no filter of its own. On a networked strand that descent is not
740
+ * reliable: a miss returns zero rows for a row that provably exists.
741
+ *
742
+ * Filtering on only the LEADING key column is a partial PK match, which the same module
743
+ * explicitly declines to handle — it falls through to a table scan and the SQL engine
744
+ * applies `MemberKey = ?` itself. No seek is involved, so no seek can miss. The member key
745
+ * is then re-compared here in JavaScript, so correctness depends only on the scan returning
746
+ * a SUPERSET of the matching rows — the weakest possible assumption about the storage layer.
747
+ * The `where` clause only trims what crosses into JS — it is NOT pushed down (see the cost
748
+ * note below) — so it is not a correctness dependency: a dropped or mis-applied predicate
749
+ * cannot produce a row attributed to the wrong member.
750
+ */
751
+ async function* scanMemberPeers(db, memberKey) {
752
+ // NOTE: because the predicate is not pushed down, the storage layer walks the WHOLE
753
+ // MemberPeer table (every member's rows) per call and the SQL engine filters. Fine at
754
+ // strand scale; if MemberPeer ever grows large, the fix is a reliable composite-key
755
+ // seek, not a bigger scan.
756
+ // NOTE: if a secondary index on MemberPeer.MemberKey is ever added, this query stops
757
+ // being a scan and becomes an index seek — re-introducing the seek dependency this
758
+ // shape exists to remove.
759
+ for await (const row of db.eval('select MemberKey, PeerId, StampId from Strand.MemberPeer where MemberKey = ?', [memberKey])) {
760
+ if (row.MemberKey === memberKey) {
761
+ yield { peerId: row.PeerId, stampId: row.StampId };
762
+ }
763
+ }
764
+ }
765
+ /**
766
+ * Every `PeerId` currently bound to `memberKey`, in storage order.
767
+ *
768
+ * The enumeration side of {@link removeMemberPeer}: a manager cleaning up after a
769
+ * revocation knows only the departed member's key, never which devices it registered, so
770
+ * cleanup is `listMemberPeers` then one `removeMemberPeer` per id. Reads only — the rows
771
+ * it returns may name a `MemberKey` with no `Member` row (see the `MemberPeer` table NOTE
772
+ * in the schema), so this is NOT a membership test.
773
+ *
774
+ * @param db - The closed strand's database.
775
+ * @param memberKey - The `MemberPeer.MemberKey` to enumerate.
776
+ * @returns The bound peer ids, empty if the member has none.
777
+ */
778
+ export async function listMemberPeers(db, memberKey) {
779
+ const peerIds = [];
780
+ for await (const peer of scanMemberPeers(db, memberKey)) {
781
+ peerIds.push(peer.peerId);
782
+ }
783
+ return peerIds;
784
+ }
785
+ /**
786
+ * The live StampId of the `MemberPeer` row for this `(MemberKey, PeerId)`, or
787
+ * `undefined` if no such row is visible — doubling as the existence probe.
788
+ *
789
+ * NOTE: check-then-write is not atomic. Two nodes registering the same
790
+ * (MemberKey, PeerId) concurrently can both observe "absent"; the primary key is the
791
+ * real backstop. The existence half of this guard is for the sequential restart /
792
+ * re-register path.
793
+ */
794
+ async function memberPeerStampId(db, memberKey, peerId) {
795
+ for await (const peer of scanMemberPeers(db, memberKey)) {
796
+ if (peer.peerId === peerId) {
797
+ return peer.stampId;
798
+ }
799
+ }
800
+ return undefined;
801
+ }
802
+ /**
803
+ * Delete a `MemberPeer` binding — either the member clearing its own, or a manager
804
+ * clearing another member's.
805
+ *
806
+ * Reads the row's live StampId first (absent → quiet no-op, mirroring
807
+ * {@link registerMemberPeer}'s insert-if-absent, so a repeated or restarted cleanup
808
+ * is not a silent zero-row "success" the caller cannot distinguish from a real one),
809
+ * then runs ONE transaction pairing the delete with its `Strand.Revocation`
810
+ * tombstone — `MemberPeer.RevocationRecorded` requires it, and the retirement is
811
+ * what keeps the captured registration approval from re-binding the cleared row.
812
+ *
813
+ * `MemberPeer` rows do NOT cascade when the member is revoked (`MemberExists` runs on
814
+ * insert only, and nothing deletes them), so a removed member's peer bindings survive
815
+ * as orphans — enumerate them with {@link listMemberPeers}, which is how a manager
816
+ * discovers what to clear. Only that member can sign the self branch, and a member being
817
+ * removed against its will has no reason to cooperate — hence the manager branch, which
818
+ * is the cleanup path after a revocation.
819
+ *
820
+ * The manager branch is deliberately NOT gated on the member already being gone: a
821
+ * manager may clear a still-present member's binding too, matching "any manager can
822
+ * remove any member". It verifies the manager-remove-tagged
823
+ * `digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId)`
824
+ * against a `committed.Manager` row; the self branch verifies the remove-tagged
825
+ * sibling against the row's own member key.
826
+ *
827
+ * Cleanup is an explicit follow-up call by the revoking manager, never a cascade inside
828
+ * {@link revokeMember}: a revocation that also had to clear an unbounded number of peer
829
+ * rows in the same transaction would couple two concerns and make its failure modes
830
+ * harder to reason about.
831
+ *
832
+ * @param db - The closed strand's database.
833
+ * @param params - Either the owning member's keypair, or a manager's keypair plus the
834
+ * target member key.
835
+ * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
836
+ * is still present after the delete (see the point-lookup note below).
837
+ */
838
+ export async function removeMemberPeer(db, params) {
839
+ const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
840
+ const { peerId } = params;
841
+ const stampId = await memberPeerStampId(db, memberKey, peerId);
842
+ if (stampId == null) {
843
+ log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
844
+ return;
372
845
  }
373
- return false;
846
+ await inStrandTransaction(db, async () => {
847
+ if ('memberKeyPair' in params) {
848
+ await deleteOwnMemberPeer(db, params, stampId);
849
+ }
850
+ else {
851
+ await deleteMemberPeerByManager(db, params, stampId);
852
+ }
853
+ });
854
+ // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
855
+ // optimystic vtab module serves via a single-key seek that can MISS on a networked
856
+ // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
857
+ // reports success. `Revocation.RowIsGone` now catches that first — the tombstone filed
858
+ // in the same transaction refuses to retire a stamp whose row is still visible, so the
859
+ // commit above fails loudly and this re-check is belt-and-braces behind it. Tracked as
860
+ // `debt-composite-pk-point-lookup-unreliable-untracked`; this is an availability
861
+ // failure mode, never a security one (a miss removes nothing, it never over-removes).
862
+ if (await memberPeerStampId(db, memberKey, peerId) != null) {
863
+ throw new Error(`MemberPeer (${memberKey}, ${peerId}) still present after delete`);
864
+ }
865
+ log('Removed MemberPeer (%s, %s)', memberKey, peerId);
866
+ }
867
+ /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
868
+ async function deleteOwnMemberPeer(db, params, stampId) {
869
+ const { memberKeyPair, peerId } = params;
870
+ const memberKey = memberKeyPair.publicKeyB64;
871
+ const signature = signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64);
872
+ await db.exec(`delete from Strand.MemberPeer
873
+ with context Signature = ?, ManagerKey = null, ManagerSignature = null
874
+ where MemberKey = ? and PeerId = ?`, [signature, memberKey, peerId]);
875
+ await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
374
876
  }
877
+ /** The manager branch: a manager signs the manager-remove-tagged digest + tombstone. */
878
+ async function deleteMemberPeerByManager(db, params, stampId) {
879
+ const { managerKeyPair, memberKey, peerId } = params;
880
+ const signature = signStrandApproval(['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId], managerKeyPair.privateKeyB64);
881
+ await db.exec(`delete from Strand.MemberPeer
882
+ with context Signature = null, ManagerKey = ?, ManagerSignature = ?
883
+ where MemberKey = ? and PeerId = ?`, [managerKeyPair.publicKeyB64, signature, memberKey, peerId]);
884
+ await insertRevocation(db, 'MemberPeer', stampId, managerKeyPair);
885
+ }
886
+ // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
375
887
  /**
376
- * Promote a member to `Manager` on the signature of an existing manager.
888
+ * The live `(Generation, StampId)` of the `Strand.Manager` row keyed by
889
+ * `memberKey`, or `undefined` if no such row is visible.
377
890
  *
378
- * The existing-manager branch of `Manager.Authorized` verifies
379
- * `digest(coalesce(new.MemberKey, old.MemberKey))` — `new.MemberKey` =
380
- * `newManagerKey` on insert — against a `Manager` row matching
381
- * `context.ManagerKey`. So the signer signs the new manager key directly (no
382
- * `'|'` join) and binds itself as `context.ManagerKey`.
891
+ * `Manager`'s primary key is the single `MemberKey` column, so any where-equality
892
+ * on it is a full-PK point lookup — unreliable on a networked strand (see
893
+ * {@link scanMemberPeers}). An unfiltered scan + JavaScript filter instead, same
894
+ * as {@link memberStampId}. Serves both {@link addManager} (the authorizer's
895
+ * generation) and {@link removeManager} (the target's stamp).
896
+ */
897
+ async function managerRow(db, memberKey) {
898
+ for await (const row of db.eval('select MemberKey, Generation, StampId from Strand.Manager')) {
899
+ if (row.MemberKey === memberKey) {
900
+ return { generation: Number(row.Generation), stampId: row.StampId };
901
+ }
902
+ }
903
+ return undefined;
904
+ }
905
+ /**
906
+ * Promote a key that is ALREADY a `Strand.Member` to `Manager` on the signature of
907
+ * an existing manager — use {@link admitManager} to admit and promote atomically.
908
+ *
909
+ * The promotion branch of `Manager.Authorized` requires the authorizer to be a
910
+ * `Manager` row of STRICTLY SMALLER `Generation` than the new row, verifying
911
+ * `digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId)`
912
+ * against `context.ManagerKey`. So this writer reads the authorizer's generation,
913
+ * seats the new manager at that value + 1 (the natural successor; the schema
914
+ * enforces only the strict ordering), mints a fresh stamp, and signs the
915
+ * add-tagged digest over all three: the generation inside the signed digest keeps
916
+ * a captured promotion from replaying at a different generation, and the stamp
917
+ * keeps it from re-seating a second incarnation once this one is removed.
918
+ * `new.Generation` is an SQL INTEGER digest arg — the digest framing is
919
+ * type-tagged, so the TS side signs over the NUMBER itself
920
+ * (see {@link signStrandApproval}).
921
+ *
922
+ * The strict ordering — not this writer — is what makes a same-transaction takeover
923
+ * impossible; the `Manager.Generation` column comment in `schemas/strand.qsql` carries
924
+ * that argument. All this writer owes it is a generation strictly above the
925
+ * authorizer's, inside the signed payload.
383
926
  *
384
- * Once the founder manager exists, the schema's `(select count(1) from Manager)
385
- * <= 1` bootstrap shortcut no longer applies to a second add (at commit the count
386
- * includes the new row, so it is ≥ 2), so this genuinely exercises signature
387
- * verification — a non-manager signer, or a signature over the wrong key, is
388
- * rejected.
927
+ * When the authorizer has NO `Manager` row (a non-manager signer, or an open
928
+ * strand with no managers at all), the scan finds nothing and the writer falls
929
+ * back to generation 1 and issues the insert anyway — deliberately letting the
930
+ * SCHEMA be the rejector (`Manager.Authorized` / `OnlyClosed`), not a
931
+ * writer-thrown error, so enforcement is pinned where it actually lives.
389
932
  *
390
933
  * @param db - The closed strand's database (founder manager already seated).
391
934
  * @param params - The authorizing manager's keypair and the new manager key.
392
- * @throws If `Manager.Authorized` rejects; the insert rolls back.
935
+ * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
936
+ * `newManagerKey` holds no `Member` row (admit it first, or use
937
+ * {@link admitManager}); the insert rolls back.
393
938
  */
394
939
  export async function addManager(db, params) {
395
940
  const { byManagerKeyPair, newManagerKey } = params;
396
- const signature = signStrandPayload(newManagerKey, byManagerKeyPair.privateKeyB64);
397
- await db.exec(`insert into Strand.Manager (MemberKey)
941
+ const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
942
+ // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
943
+ // to its authorizer and the schema rejects. Unreachable while generations only ever
944
+ // grow by 1 from 0, but the schema enforces ordering, not adjacency, so a manager may
945
+ // seat a successor at any larger value; if arbitrary generations ever become writable
946
+ // from outside this function, clamp or reject here rather than emitting a dead row.
947
+ const generation = authorizer == null ? 1 : authorizer.generation + 1;
948
+ const stampId = generateStrandStampId();
949
+ const signature = signStrandApproval(['Strand.Manager', 'add', newManagerKey, generation, stampId], byManagerKeyPair.privateKeyB64);
950
+ await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
398
951
  with context ManagerKey = ?, Signature = ?
399
- values (?)`, [byManagerKeyPair.publicKeyB64, signature, newManagerKey]);
400
- log('Added manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
952
+ values (?, ?, ?)`, [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId]);
953
+ log('Added manager %s (generation %d) by %s', newManagerKey, generation, byManagerKeyPair.publicKeyB64);
954
+ }
955
+ /**
956
+ * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
957
+ * pair for a key that is not in the strand yet. {@link addManager} alone promotes a
958
+ * key that is already a member.
959
+ *
960
+ * ONE transaction, because `Manager.MemberExists` is a deferred check reading the LIVE
961
+ * `Member` table: the sibling `Member` insert satisfies it at the shared commit, and a
962
+ * rejection of either half rolls BOTH rows back — never a `Member` row seated by a
963
+ * failed promotion, nor a `Manager` row with no member behind it.
964
+ *
965
+ * NO new authority: both halves are signed by the SAME manager, over two distinct
966
+ * action-tagged digests — `digest('Strand.Member', 'add', …)` for the admission and
967
+ * `digest('Strand.Manager', 'add', …)` for the promotion — each bound to its own row's
968
+ * fresh stamp. This composes exactly the two approvals that manager could already
969
+ * issue separately.
970
+ *
971
+ * The promoting manager must be a PRE-transaction manager: the direct-admit branch of
972
+ * `Member.Authorized` reads `committed.Manager`, so `admitManager` cannot be chained —
973
+ * a manager seated by an `admitManager` earlier in the SAME transaction cannot admit
974
+ * the next one. (A same-transaction promotion CHAIN rooted at a pre-existing manager
975
+ * is fine; only the `Member` half needs the committed authorizer.)
976
+ *
977
+ * NOT insert-if-absent: a repeat call for a key that is already a member fails on the
978
+ * `Member` primary key, matching {@link addMemberByManager}'s unguarded shape.
979
+ *
980
+ * @param db - The closed strand's database (founder manager already seated).
981
+ * @param params - The authorizing manager's keypair and the key to admit + promote.
982
+ * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
983
+ * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
984
+ */
985
+ export async function admitManager(db, params) {
986
+ const { byManagerKeyPair, newManagerKey } = params;
987
+ await inStrandTransaction(db, async () => {
988
+ await addMemberByManager(db, { managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
989
+ await addManager(db, { byManagerKeyPair, newManagerKey });
990
+ });
991
+ log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
401
992
  }
402
993
  /**
403
994
  * Remove a `Manager` row — either an admin removing a different manager or a
404
995
  * manager resigning itself.
405
996
  *
406
- * On a DELETE, `coalesce(new.MemberKey, old.MemberKey)` binds `old.MemberKey =
407
- * targetManagerKey` (there is no `new` row), so the signed payload is the target
408
- * key for BOTH accepting branches of `Manager.Authorized`, and a single context
409
- * construction serves both:
410
- * - **Admin removal**: `byManagerKeyPair` is a different existing manager; the
411
- * existing-manager branch verifies the signature against `A.MemberKey =
412
- * context.ManagerKey`.
413
- * - **Self-resignation**: `byManagerKeyPair` IS the target (its `publicKeyB64`
414
- * equals `targetManagerKey`); the former-manager self branch verifies
415
- * `old.MemberKey = context.ManagerKey` and the self-signature over `old.MemberKey`.
416
- *
417
- * The caller selects the case purely by which keypair it passes — no branching here.
418
- *
419
- * KNOWN PLATFORM GAP (not fixable here): the optimystic bootstrap-mode transactor
420
- * evaluates deferred (subquery-bearing) CHECK constraints only on INSERT, not on
421
- * DELETE. `Manager.Authorized` is deferred, so the platform currently accepts ANY
422
- * `Manager` delete regardless of signature — `removeManager`'s authorization is
423
- * effectively unenforced at runtime until that gap is closed (filed as
424
- * `optimystic-deferred-check-not-enforced-on-delete`). This writer still builds the
425
- * correct, signed delete so it works unchanged once enforcement lands — exactly as
426
- * the invite path issues correct inserts despite the open PK-uniqueness gap.
427
- *
428
- * KNOWN SCHEMA HAZARD (not guarded here): even with enforcement, `Manager.
429
- * Authorized`'s `(select count(1) from Manager) <= 1` bootstrap branch is true at
430
- * commit whenever a delete drops the count to ≤ 1, so removing the strand's LAST (or
431
- * second-to-last) manager would be accepted regardless of signature — and removing
432
- * the last one orphans the strand (no one can ever add another). The schema does not
433
- * prevent this; a "min-one-manager" invariant is deferred to a future schema change
434
- * rather than grown into this writer.
997
+ * Reads the target row's live `(Generation, StampId)` first (absent → quiet
998
+ * no-op, same restart-safe shape as {@link revokeMember}), then runs ONE
999
+ * transaction pairing the delete with its `Strand.Revocation` tombstone
1000
+ * (`Manager.RevocationRecorded`). The two delete-side branches of
1001
+ * `Manager.Authorized` verify DIFFERENT action-tagged digests, each bound to
1002
+ * `(old.MemberKey, old.StampId)`:
1003
+ * - **Admin removal** (`byManagerKeyPair` is a different existing manager): the
1004
+ * removal branch verifies `digest('Strand.Manager', 'remove', old.MemberKey,
1005
+ * old.StampId)` against `A.MemberKey = context.ManagerKey`. Deliberately NO
1006
+ * generation condition: a later-generation manager may remove an
1007
+ * earlier-generation one (generation is lineage, not privilege), and deletes
1008
+ * are safe once inserts are — every accepting branch requires a `Manager` row
1009
+ * in the post-image, and the promotion branch's generation ordering keeps
1010
+ * attacker rows out of it. The stamp binding is what makes a captured removal
1011
+ * single-use: a re-promoted manager carries a fresh stamp the old approval
1012
+ * does not match — closing the removal half of
1013
+ * `bug-strand-manager-authority-antireplay` (the insert half was already
1014
+ * closed by the generation-bearing promotion payload).
1015
+ * - **Self-resignation** (`byManagerKeyPair` IS the target): the former-manager
1016
+ * self branch verifies `digest('Strand.Manager', 'resign', old.MemberKey,
1017
+ * old.StampId)` against `old.MemberKey` itself. The distinct `'resign'` tag is
1018
+ * belt-and-braces (different verifying keys); the anti-replay fix is the stamp.
1019
+ *
1020
+ * The caller selects the case purely by which keypair it passes; the only
1021
+ * branching here is the action tag, chosen by comparing the signer to the target.
1022
+ *
1023
+ * Deferred (subquery-bearing) CHECK constraints are evaluated on DELETE as well
1024
+ * as INSERT by Quereus plus the Optimystic vtab session — enforcement lives above
1025
+ * the transactor (proven on the network transactor by
1026
+ * `strand-membership-network-transactor-parity.spec.ts`), so `Manager.Authorized`
1027
+ * — deferred — IS enforced here: a signer that is neither an existing manager nor
1028
+ * the target itself is rejected at commit.
1029
+ *
1030
+ * THE LAST MANAGER CANNOT BE REMOVED. `Manager.MinOneManager` (`check on delete`,
1031
+ * deferred, so it sees the POST-delete count) rejects any delete that would leave the
1032
+ * strand with zero managers — an admin-less closed strand could never admit anyone
1033
+ * again, since `Invite`, `addMemberByManager`, and `addManager` all require a
1034
+ * `Manager` row. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1035
+ * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1036
+ * that drops the count toward the floor: a second-to-last removal is authorized
1037
+ * exactly like any other.
1038
+ *
1039
+ * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
1040
+ * and resign SECOND. A same-transaction delete-then-insert swap is rejected — the
1041
+ * bootstrap branch is gated to the founding state (at most one `Member`), and the
1042
+ * successor's insert has no other manager to authorize it once the sole manager's
1043
+ * row is gone.
1044
+ *
1045
+ * Cross-node caveat: `MinOneManager` counts rows visible to THIS transaction, so two
1046
+ * nodes concurrently removing different managers can each see a survivor and still
1047
+ * converge to zero. Noted in the schema; a cross-node floor is not attempted here.
435
1048
  *
436
1049
  * @param db - The closed strand's database.
437
1050
  * @param params - The authorizing keypair and the target manager key.
438
- * @throws If `Manager.Authorized` rejects (e.g. a non-manager admin removal
439
- * while > 2 managers remain); the delete rolls back.
1051
+ * @throws If `Manager.Authorized` rejects (a signer that is neither another existing
1052
+ * manager nor the target itself) or `Manager.MinOneManager` rejects (the removal
1053
+ * would leave no managers); the whole delete+tombstone transaction rolls back.
440
1054
  */
441
1055
  export async function removeManager(db, params) {
442
1056
  const { byManagerKeyPair, targetManagerKey } = params;
443
- const signature = signStrandPayload(targetManagerKey, byManagerKeyPair.privateKeyB64);
444
- await db.exec(`delete from Strand.Manager
445
- with context ManagerKey = ?, Signature = ?
446
- where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
1057
+ const target = await managerRow(db, targetManagerKey);
1058
+ if (target == null) {
1059
+ log('Manager %s already absent; skipping removal', targetManagerKey);
1060
+ return;
1061
+ }
1062
+ const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1063
+ const signature = signStrandApproval(['Strand.Manager', tag, targetManagerKey, target.stampId], byManagerKeyPair.privateKeyB64);
1064
+ await inStrandTransaction(db, async () => {
1065
+ await db.exec(`delete from Strand.Manager
1066
+ with context ManagerKey = ?, Signature = ?
1067
+ where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
1068
+ await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1069
+ });
447
1070
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
448
1071
  }
449
1072
  //# sourceMappingURL=strand-membership-writer.js.map