@serfab/cadre-core 0.10.0 → 0.12.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -414,8 +414,8 @@ export interface RevokeMemberParams {
414
414
  * A manager must resign its `Manager` row before (or in the same transaction
415
415
  * as) losing membership — `Member.NotAManager` rejects un-membering a key that
416
416
  * still holds a Manager row. And the strand never drops to zero members
417
- * (`Member.MinOneMember`, a local-count floor with the same cross-node caveat
418
- * as `MinOneManager`).
417
+ * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
418
+ * schema NOTE states).
419
419
  *
420
420
  * @param db - The closed strand's database.
421
421
  * @param params - The revoking manager keypair and the target member key.
@@ -722,18 +722,26 @@ export interface RemoveManagerParams {
722
722
  * The caller selects the case purely by which keypair it passes; the only
723
723
  * branching here is the action tag, chosen by comparing the signer to the target.
724
724
  *
725
- * The optimystic bootstrap-mode transactor evaluates deferred (subquery-bearing)
726
- * CHECK constraints on DELETE as well as INSERT, so `Manager.Authorized` — deferred —
727
- * IS enforced here: a signer that is neither an existing manager nor the target
728
- * itself is rejected at commit.
729
- *
730
- * THE LAST MANAGER CANNOT BE REMOVED. `Manager.MinOneManager` (`check on delete`,
731
- * deferred, so it sees the POST-delete count) rejects any delete that would leave the
732
- * strand with zero managers — an admin-less closed strand could never admit anyone
733
- * again, since `Invite`, `addMemberByManager`, and `addManager` all require a
734
- * `Manager` row. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
725
+ * Deferred (subquery-bearing) CHECK constraints are evaluated on DELETE as well
726
+ * as INSERT by Quereus plus the Optimystic vtab session — enforcement lives above
727
+ * the transactor (proven on the network transactor by
728
+ * `strand-membership-network-transactor-parity.spec.ts`), so `Manager.Authorized`
729
+ * — deferred — IS enforced here: a signer that is neither an existing manager nor
730
+ * the target itself is rejected at commit.
731
+ *
732
+ * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
733
+ * branch of `Manager.Authorized` requires at least one manager in the POST-delete
734
+ * image, so a resignation that would empty the `Manager` table is schema-rejected;
735
+ * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
736
+ * that permanently freezes admission. This writer refuses the sole-manager
737
+ * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
738
+ * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
739
+ * regardless. Reading the count inside a caller-joined transaction is correct: an
740
+ * add-then-resign hand-off composed in one transaction sees 2 and passes, while
741
+ * the already-rejected delete-then-add swap sees 1 and is refused here as well as
742
+ * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
735
743
  * (`old.MemberKey is null`), so it does not waive the signature check on a delete
736
- * that drops the count toward the floor: a second-to-last removal is authorized
744
+ * that drops the count toward zero: a second-to-last removal is authorized
737
745
  * exactly like any other.
738
746
  *
739
747
  * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
@@ -742,14 +750,100 @@ export interface RemoveManagerParams {
742
750
  * successor's insert has no other manager to authorize it once the sole manager's
743
751
  * row is gone.
744
752
  *
745
- * Cross-node caveat: `MinOneManager` counts rows visible to THIS transaction, so two
746
- * nodes concurrently removing different managers can each see a survivor and still
747
- * converge to zero. Noted in the schema; a cross-node floor is not attempted here.
753
+ * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
754
+ * transaction, so two nodes concurrently removing different managers can each see
755
+ * a survivor and still converge to zero — an unintended seal. Noted in the schema
756
+ * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
757
+ * attempted here.
748
758
  *
749
759
  * @param db - The closed strand's database.
750
760
  * @param params - The authorizing keypair and the target manager key.
751
- * @throws If `Manager.Authorized` rejects (a signer that is neither another existing
752
- * manager nor the target itself) or `Manager.MinOneManager` rejects (the removal
753
- * would leave no managers); the whole delete+tombstone transaction rolls back.
761
+ * @throws If the signer IS the target and it is the sole manager (before writing —
762
+ * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
763
+ * rejects (a signer that is neither another existing manager nor the target
764
+ * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
765
+ * whole delete+tombstone transaction rolls back.
754
766
  */
755
767
  export declare function removeManager(db: Database, params: RemoveManagerParams): Promise<void>;
768
+ /** Parameters for {@link sealStrand}. */
769
+ export interface SealStrandParams {
770
+ /**
771
+ * The SOLE manager's strand keypair. Its `publicKeyB64` must be the one live
772
+ * `Strand.Manager` row; it signs the seal-tagged digest over its own key and
773
+ * live stamp, and files the accompanying tombstone (it is still a committed
774
+ * member, which is what `Revocation.Authorized` checks).
775
+ */
776
+ managerKeyPair: Ed25519KeyPair;
777
+ }
778
+ /**
779
+ * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
780
+ * membership.
781
+ *
782
+ * A sealed strand has zero `Manager` rows, and every admission path — issuing or
783
+ * cancelling an invitation, consuming one issued BEFORE the seal
784
+ * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
785
+ * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
786
+ * everyone who contributed: no key holds the power to admit a party who would then
787
+ * read the strand's whole history. Sealing is irreversible — the founding branch
788
+ * of `Manager.Authorized` closes for good once any manager stamp is retired into
789
+ * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
790
+ * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
791
+ * `Revocation` tombstones.
792
+ *
793
+ * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
794
+ * when the POST-image manager count is ZERO — a distinct action tag from
795
+ * `'resign'`, so a captured resignation approval can never seal and a raw writer
796
+ * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
797
+ * `Strand.Revocation` tombstone in one transaction.
798
+ *
799
+ * The TS count/identity checks below are UX guards, not the trust boundary — the
800
+ * schema rejects a mis-tagged or mis-counted approval regardless of what this
801
+ * writer (or any raw writer) presents.
802
+ *
803
+ * NOTE: a seal approval is a bearer token for its row incarnation, like every other
804
+ * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
805
+ * satisfied for EVERY row deleted in the same transaction, so N managers each
806
+ * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
807
+ * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
808
+ * while its caller is the SOLE manager, and spends it immediately, so a second
809
+ * manager never has one to combine. If seal-approval MINTING is ever split from
810
+ * spending — offline signing, a two-phase seal, a queued approval — one manager
811
+ * holding another's unspent seal approval could freeze the strand irreversibly
812
+ * without its current consent; re-gate here (or narrow the schema branch) then.
813
+ *
814
+ * @param db - The closed strand's database.
815
+ * @param params - The sole manager's own keypair.
816
+ * @throws If more than one manager exists (the caller wants {@link removeManager}),
817
+ * or the supplied keypair holds no `Manager` row while one exists, or the strand
818
+ * holds no `Manager` row and has never retired one (not founded yet — see
819
+ * {@link isStrandSealed}), or any schema constraint rejects; the whole
820
+ * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
821
+ * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
822
+ * and {@link removeManager}'s absent-row no-op).
823
+ */
824
+ export declare function sealStrand(db: Database, params: SealStrandParams): Promise<void>;
825
+ /**
826
+ * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
827
+ * has retired at least one `Manager` stamp into `Strand.Revocation`.
828
+ *
829
+ * All three conjuncts are load-bearing:
830
+ * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
831
+ * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
832
+ * strand as sealed.
833
+ * - the manager count — sealing IS the empty `Manager` table.
834
+ * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
835
+ * what makes it permanent is the founding branch of `Manager.Authorized`
836
+ * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
837
+ * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
838
+ * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
839
+ * `Member` and `Manager` as three sequential statements, so the window between
840
+ * the first and the last is exactly "closed, zero managers, still foundable".
841
+ *
842
+ * Reads locally visible rows — a node that has not yet converged on the seal
843
+ * still reports `false` (the schema's seal-propagation NOTE).
844
+ *
845
+ * @param db - The strand's database.
846
+ * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
847
+ * permanently closed its founding branch.
848
+ */
849
+ export declare function isStrandSealed(db: Database): Promise<boolean>;
@@ -175,8 +175,8 @@ async function inStrandTransaction(db, fn) {
175
175
  */
176
176
  async function insertRevocation(db, tableName, stampId, retiree) {
177
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 = ?
178
+ await db.exec(`insert into Strand.Revocation (TableName, StampId)
179
+ with context MemberKey = ?, Signature = ?
180
180
  values (?, ?)`, [retiree.publicKeyB64, signature, tableName, stampId]);
181
181
  }
182
182
  /**
@@ -192,6 +192,27 @@ async function strandTableCount(db, table) {
192
192
  }
193
193
  return 0;
194
194
  }
195
+ /**
196
+ * Whether any `Strand.Revocation` row has ever retired a `Manager` seat — the
197
+ * exact condition that permanently closes the founding branch of
198
+ * `Manager.Authorized` (see the schema's bootstrap-branch comment). Unfiltered
199
+ * scan + JavaScript comparison, the module's scan-not-seek idiom (rationale on
200
+ * {@link scanMemberPeers}).
201
+ *
202
+ * NOTE: walks the whole append-only `Strand.Revocation` table when no `Manager`
203
+ * tombstone is present (it returns on the first one it finds). Cheap at strand
204
+ * scale; if membership churn ever makes that table large — the growth the
205
+ * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
206
+ * — this wants a stored "founding closed" marker rather than a scan per call.
207
+ */
208
+ async function strandHasManagerRevocation(db) {
209
+ for await (const row of db.eval('select TableName from Strand.Revocation')) {
210
+ if (row.TableName === 'Manager') {
211
+ return true;
212
+ }
213
+ }
214
+ return false;
215
+ }
195
216
  /**
196
217
  * Insert the singleton `Strand.Header` if absent.
197
218
  *
@@ -206,8 +227,8 @@ async function insertHeaderIfAbsent(db, params) {
206
227
  log('Header already present for strand %s; skipping', params.strandId);
207
228
  return;
208
229
  }
209
- await db.exec(`insert into Strand.Header
210
- (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
230
+ await db.exec(`insert into Strand.Header
231
+ (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
211
232
  values (?, ?, ?, ?, ?, ?, ?, ?)`, [
212
233
  params.strandId,
213
234
  params.type,
@@ -237,8 +258,8 @@ async function insertFounderMemberIfAbsent(db, memberKey, strandId) {
237
258
  log('Member already present for strand %s; skipping founder Member', strandId);
238
259
  return;
239
260
  }
240
- await db.exec(`insert into Strand.Member (Key, StampId)
241
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
261
+ await db.exec(`insert into Strand.Member (Key, StampId)
262
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
242
263
  values (?, ?)`, [memberKey, generateStrandStampId()]);
243
264
  log('Inserted founding Member for strand %s', strandId);
244
265
  }
@@ -260,14 +281,27 @@ async function insertFounderMemberIfAbsent(db, memberKey, strandId) {
260
281
  * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
261
282
  * sequential auto-commit Header→Member→Manager order in
262
283
  * {@link bootstrapFounderMembership} satisfies both.
284
+ *
285
+ * SEALED strands are skipped, not re-founded: a strand whose sole manager has
286
+ * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
287
+ * closed for good (the schema's bootstrap branch requires no `Manager`-table
288
+ * `Revocation` row to exist). Attempting the founding insert here would be
289
+ * schema-rejected and turn every founder restart of a sealed strand into a
290
+ * loud failure — so this mirrors the schema's own gate and returns quietly.
291
+ * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
292
+ * rows at all, so that restart still completes the founding.
263
293
  */
264
294
  async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
265
295
  if (await strandTableCount(db, 'Manager') > 0) {
266
296
  log('Manager already present for strand %s; skipping founder Manager', strandId);
267
297
  return;
268
298
  }
269
- await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
270
- with context ManagerKey = null, Signature = null
299
+ if (await strandHasManagerRevocation(db)) {
300
+ log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
301
+ return;
302
+ }
303
+ await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
304
+ with context ManagerKey = null, Signature = null
271
305
  values (?, 0, ?)`, [memberKey, generateStrandStampId()]);
272
306
  log('Inserted founding Manager for strand %s', strandId);
273
307
  }
@@ -348,8 +382,8 @@ export async function issueInvite(db, params) {
348
382
  const payload = `${inviteKey}|${expirationSegment}`;
349
383
  const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
350
384
  const inviteSignature = signStrandPayload(payload, invitePrivateKey);
351
- await db.exec(`insert into Strand.Invite (Key, Expiration)
352
- with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
385
+ await db.exec(`insert into Strand.Invite (Key, Expiration)
386
+ with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
353
387
  values (?, ?)`, [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn]);
354
388
  log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
355
389
  return { inviteKey, invitePrivateKey };
@@ -409,13 +443,13 @@ export async function consumeInvite(db, params) {
409
443
  await inStrandTransaction(db, async () => {
410
444
  // 1. Member — admitted by the deferred invite branch (the matching
411
445
  // ConsumedInvite below), so no manager signature is supplied.
412
- await db.exec(`insert into Strand.Member (Key, StampId)
413
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
446
+ await db.exec(`insert into Strand.Member (Key, StampId)
447
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
414
448
  values (?, ?)`, [memberKey, generateStrandStampId()]);
415
449
  // 2. ConsumedInvite — proves possession of the invite private key and that the
416
450
  // invite has not expired (NotExpired gate against the canonical Now).
417
- await db.exec(`insert into Strand.ConsumedInvite (InviteKey, MemberKey)
418
- with context InviteSignature = ?, Now = ?
451
+ await db.exec(`insert into Strand.ConsumedInvite (InviteKey, MemberKey)
452
+ with context InviteSignature = ?, Now = ?
419
453
  values (?, ?)`, [inviteSignature, nowCanonical, inviteKey, memberKey]);
420
454
  });
421
455
  log('Consumed invite %s -> member %s', inviteKey, memberKey);
@@ -485,8 +519,8 @@ export async function cancelInvite(db, params) {
485
519
  return;
486
520
  }
487
521
  const signature = signStrandApproval(['Strand.CancelledInvite', 'cancel', inviteKey], managerKeyPair.privateKeyB64);
488
- await db.exec(`insert into Strand.CancelledInvite (InviteKey)
489
- with context ManagerKey = ?, ManagerSignature = ?
522
+ await db.exec(`insert into Strand.CancelledInvite (InviteKey)
523
+ with context ManagerKey = ?, ManagerSignature = ?
490
524
  values (?)`, [managerKeyPair.publicKeyB64, signature, inviteKey]);
491
525
  log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
492
526
  }
@@ -581,8 +615,8 @@ export async function addMemberByManager(db, params) {
581
615
  const { managerKeyPair, memberKey } = params;
582
616
  const stampId = generateStrandStampId();
583
617
  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
618
+ await db.exec(`insert into Strand.Member (Key, StampId)
619
+ with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
586
620
  values (?, ?)`, [managerKeyPair.publicKeyB64, signature, memberKey, stampId]);
587
621
  log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
588
622
  }
@@ -619,8 +653,8 @@ export async function addMemberByManager(db, params) {
619
653
  * A manager must resign its `Manager` row before (or in the same transaction
620
654
  * as) losing membership — `Member.NotAManager` rejects un-membering a key that
621
655
  * 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`).
656
+ * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
657
+ * schema NOTE states).
624
658
  *
625
659
  * @param db - The closed strand's database.
626
660
  * @param params - The revoking manager keypair and the target member key.
@@ -638,8 +672,8 @@ export async function revokeMember(db, params) {
638
672
  }
639
673
  const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
640
674
  await inStrandTransaction(db, async () => {
641
- await db.exec(`delete from Strand.Member
642
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
675
+ await db.exec(`delete from Strand.Member
676
+ with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
643
677
  where Key = ?`, [managerKeyPair.publicKeyB64, signature, memberKey]);
644
678
  await insertRevocation(db, 'Member', stampId, managerKeyPair);
645
679
  });
@@ -674,8 +708,8 @@ export async function leaveStrand(db, params) {
674
708
  }
675
709
  const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
676
710
  await inStrandTransaction(db, async () => {
677
- await db.exec(`delete from Strand.Member
678
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
711
+ await db.exec(`delete from Strand.Member
712
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
679
713
  where Key = ?`, [signature, memberKey]);
680
714
  await insertRevocation(db, 'Member', stampId, memberKeyPair);
681
715
  });
@@ -723,8 +757,8 @@ export async function registerMemberPeer(db, params) {
723
757
  }
724
758
  const stampId = generateStrandStampId();
725
759
  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
760
+ await db.exec(`insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
761
+ with context Signature = ?, ManagerKey = null, ManagerSignature = null
728
762
  values (?, ?, ?)`, [signature, memberKey, peerId, stampId]);
729
763
  log('Registered MemberPeer (%s, %s)', memberKey, peerId);
730
764
  }
@@ -869,8 +903,8 @@ async function deleteOwnMemberPeer(db, params, stampId) {
869
903
  const { memberKeyPair, peerId } = params;
870
904
  const memberKey = memberKeyPair.publicKeyB64;
871
905
  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
906
+ await db.exec(`delete from Strand.MemberPeer
907
+ with context Signature = ?, ManagerKey = null, ManagerSignature = null
874
908
  where MemberKey = ? and PeerId = ?`, [signature, memberKey, peerId]);
875
909
  await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
876
910
  }
@@ -878,8 +912,8 @@ async function deleteOwnMemberPeer(db, params, stampId) {
878
912
  async function deleteMemberPeerByManager(db, params, stampId) {
879
913
  const { managerKeyPair, memberKey, peerId } = params;
880
914
  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 = ?
915
+ await db.exec(`delete from Strand.MemberPeer
916
+ with context Signature = null, ManagerKey = ?, ManagerSignature = ?
883
917
  where MemberKey = ? and PeerId = ?`, [managerKeyPair.publicKeyB64, signature, memberKey, peerId]);
884
918
  await insertRevocation(db, 'MemberPeer', stampId, managerKeyPair);
885
919
  }
@@ -947,8 +981,8 @@ export async function addManager(db, params) {
947
981
  const generation = authorizer == null ? 1 : authorizer.generation + 1;
948
982
  const stampId = generateStrandStampId();
949
983
  const signature = signStrandApproval(['Strand.Manager', 'add', newManagerKey, generation, stampId], byManagerKeyPair.privateKeyB64);
950
- await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
951
- with context ManagerKey = ?, Signature = ?
984
+ await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
985
+ with context ManagerKey = ?, Signature = ?
952
986
  values (?, ?, ?)`, [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId]);
953
987
  log('Added manager %s (generation %d) by %s', newManagerKey, generation, byManagerKeyPair.publicKeyB64);
954
988
  }
@@ -1020,18 +1054,26 @@ export async function admitManager(db, params) {
1020
1054
  * The caller selects the case purely by which keypair it passes; the only
1021
1055
  * branching here is the action tag, chosen by comparing the signer to the target.
1022
1056
  *
1023
- * The optimystic bootstrap-mode transactor evaluates deferred (subquery-bearing)
1024
- * CHECK constraints on DELETE as well as INSERT, so `Manager.Authorized` — deferred —
1025
- * IS enforced here: a signer that is neither an existing manager nor the target
1026
- * itself is rejected at commit.
1027
- *
1028
- * THE LAST MANAGER CANNOT BE REMOVED. `Manager.MinOneManager` (`check on delete`,
1029
- * deferred, so it sees the POST-delete count) rejects any delete that would leave the
1030
- * strand with zero managers — an admin-less closed strand could never admit anyone
1031
- * again, since `Invite`, `addMemberByManager`, and `addManager` all require a
1032
- * `Manager` row. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1057
+ * Deferred (subquery-bearing) CHECK constraints are evaluated on DELETE as well
1058
+ * as INSERT by Quereus plus the Optimystic vtab session — enforcement lives above
1059
+ * the transactor (proven on the network transactor by
1060
+ * `strand-membership-network-transactor-parity.spec.ts`), so `Manager.Authorized`
1061
+ * — deferred — IS enforced here: a signer that is neither an existing manager nor
1062
+ * the target itself is rejected at commit.
1063
+ *
1064
+ * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
1065
+ * branch of `Manager.Authorized` requires at least one manager in the POST-delete
1066
+ * image, so a resignation that would empty the `Manager` table is schema-rejected;
1067
+ * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
1068
+ * that permanently freezes admission. This writer refuses the sole-manager
1069
+ * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
1070
+ * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
1071
+ * regardless. Reading the count inside a caller-joined transaction is correct: an
1072
+ * add-then-resign hand-off composed in one transaction sees 2 and passes, while
1073
+ * the already-rejected delete-then-add swap sees 1 and is refused here as well as
1074
+ * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1033
1075
  * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1034
- * that drops the count toward the floor: a second-to-last removal is authorized
1076
+ * that drops the count toward zero: a second-to-last removal is authorized
1035
1077
  * exactly like any other.
1036
1078
  *
1037
1079
  * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
@@ -1040,15 +1082,19 @@ export async function admitManager(db, params) {
1040
1082
  * successor's insert has no other manager to authorize it once the sole manager's
1041
1083
  * row is gone.
1042
1084
  *
1043
- * Cross-node caveat: `MinOneManager` counts rows visible to THIS transaction, so two
1044
- * nodes concurrently removing different managers can each see a survivor and still
1045
- * converge to zero. Noted in the schema; a cross-node floor is not attempted here.
1085
+ * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
1086
+ * transaction, so two nodes concurrently removing different managers can each see
1087
+ * a survivor and still converge to zero — an unintended seal. Noted in the schema
1088
+ * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
1089
+ * attempted here.
1046
1090
  *
1047
1091
  * @param db - The closed strand's database.
1048
1092
  * @param params - The authorizing keypair and the target manager key.
1049
- * @throws If `Manager.Authorized` rejects (a signer that is neither another existing
1050
- * manager nor the target itself) or `Manager.MinOneManager` rejects (the removal
1051
- * would leave no managers); the whole delete+tombstone transaction rolls back.
1093
+ * @throws If the signer IS the target and it is the sole manager (before writing —
1094
+ * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1095
+ * rejects (a signer that is neither another existing manager nor the target
1096
+ * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1097
+ * whole delete+tombstone transaction rolls back.
1052
1098
  */
1053
1099
  export async function removeManager(db, params) {
1054
1100
  const { byManagerKeyPair, targetManagerKey } = params;
@@ -1058,13 +1104,135 @@ export async function removeManager(db, params) {
1058
1104
  return;
1059
1105
  }
1060
1106
  const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1107
+ if (tag === 'resign' && await strandTableCount(db, 'Manager') === 1) {
1108
+ throw new Error('Cannot resign as the SOLE manager: an ordinary resignation never empties the '
1109
+ + 'Manager table. To permanently freeze admission instead, call sealStrand.');
1110
+ }
1061
1111
  const signature = signStrandApproval(['Strand.Manager', tag, targetManagerKey, target.stampId], byManagerKeyPair.privateKeyB64);
1062
1112
  await inStrandTransaction(db, async () => {
1063
- await db.exec(`delete from Strand.Manager
1064
- with context ManagerKey = ?, Signature = ?
1113
+ await db.exec(`delete from Strand.Manager
1114
+ with context ManagerKey = ?, Signature = ?
1065
1115
  where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
1066
1116
  await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1067
1117
  });
1068
1118
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1069
1119
  }
1120
+ /**
1121
+ * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1122
+ * membership.
1123
+ *
1124
+ * A sealed strand has zero `Manager` rows, and every admission path — issuing or
1125
+ * cancelling an invitation, consuming one issued BEFORE the seal
1126
+ * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
1127
+ * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
1128
+ * everyone who contributed: no key holds the power to admit a party who would then
1129
+ * read the strand's whole history. Sealing is irreversible — the founding branch
1130
+ * of `Manager.Authorized` closes for good once any manager stamp is retired into
1131
+ * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
1132
+ * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
1133
+ * `Revocation` tombstones.
1134
+ *
1135
+ * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
1136
+ * when the POST-image manager count is ZERO — a distinct action tag from
1137
+ * `'resign'`, so a captured resignation approval can never seal and a raw writer
1138
+ * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
1139
+ * `Strand.Revocation` tombstone in one transaction.
1140
+ *
1141
+ * The TS count/identity checks below are UX guards, not the trust boundary — the
1142
+ * schema rejects a mis-tagged or mis-counted approval regardless of what this
1143
+ * writer (or any raw writer) presents.
1144
+ *
1145
+ * NOTE: a seal approval is a bearer token for its row incarnation, like every other
1146
+ * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
1147
+ * satisfied for EVERY row deleted in the same transaction, so N managers each
1148
+ * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
1149
+ * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
1150
+ * while its caller is the SOLE manager, and spends it immediately, so a second
1151
+ * manager never has one to combine. If seal-approval MINTING is ever split from
1152
+ * spending — offline signing, a two-phase seal, a queued approval — one manager
1153
+ * holding another's unspent seal approval could freeze the strand irreversibly
1154
+ * without its current consent; re-gate here (or narrow the schema branch) then.
1155
+ *
1156
+ * @param db - The closed strand's database.
1157
+ * @param params - The sole manager's own keypair.
1158
+ * @throws If more than one manager exists (the caller wants {@link removeManager}),
1159
+ * or the supplied keypair holds no `Manager` row while one exists, or the strand
1160
+ * holds no `Manager` row and has never retired one (not founded yet — see
1161
+ * {@link isStrandSealed}), or any schema constraint rejects; the whole
1162
+ * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
1163
+ * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1164
+ * and {@link removeManager}'s absent-row no-op).
1165
+ */
1166
+ export async function sealStrand(db, params) {
1167
+ const { managerKeyPair } = params;
1168
+ const managerCount = await strandTableCount(db, 'Manager');
1169
+ if (managerCount === 0) {
1170
+ // Zero managers is NOT by itself a seal — a closed strand between its Header
1171
+ // and Manager founding inserts looks identical. The retired `Manager` stamp is
1172
+ // what separates "frozen forever" from "still foundable"; returning quietly on
1173
+ // the latter would report a seal that never happened.
1174
+ if (await strandHasManagerRevocation(db)) {
1175
+ log('Strand already sealed (no Manager rows, manager stamp retired); skipping seal');
1176
+ return;
1177
+ }
1178
+ throw new Error('Cannot seal: the strand holds no Manager row and has never retired one — it is '
1179
+ + 'not founded yet, not sealed. Bootstrap the founding manager first.');
1180
+ }
1181
+ if (managerCount > 1) {
1182
+ throw new Error(`Cannot seal: ${managerCount} managers exist and sealing is the SOLE manager's act. `
1183
+ + 'The other managers must step down first (removeManager).');
1184
+ }
1185
+ const self = await managerRow(db, managerKeyPair.publicKeyB64);
1186
+ if (self == null) {
1187
+ throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1188
+ }
1189
+ const signature = signStrandApproval(['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId], managerKeyPair.privateKeyB64);
1190
+ await inStrandTransaction(db, async () => {
1191
+ await db.exec(`delete from Strand.Manager
1192
+ with context ManagerKey = ?, Signature = ?
1193
+ where MemberKey = ?`, [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64]);
1194
+ await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1195
+ });
1196
+ log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1197
+ }
1198
+ /**
1199
+ * Whether the strand is `Type = 'c'` (closed). Reads the singleton `Header` via
1200
+ * the same `db.eval` scan idiom as {@link strandTableCount}; a strand with no
1201
+ * `Header` row yet reports `false`.
1202
+ */
1203
+ async function strandIsClosed(db) {
1204
+ for await (const row of db.eval('select Type from Strand.Header')) {
1205
+ return row.Type === 'c';
1206
+ }
1207
+ return false;
1208
+ }
1209
+ /**
1210
+ * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
1211
+ * has retired at least one `Manager` stamp into `Strand.Revocation`.
1212
+ *
1213
+ * All three conjuncts are load-bearing:
1214
+ * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
1215
+ * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
1216
+ * strand as sealed.
1217
+ * - the manager count — sealing IS the empty `Manager` table.
1218
+ * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
1219
+ * what makes it permanent is the founding branch of `Manager.Authorized`
1220
+ * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
1221
+ * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
1222
+ * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
1223
+ * `Member` and `Manager` as three sequential statements, so the window between
1224
+ * the first and the last is exactly "closed, zero managers, still foundable".
1225
+ *
1226
+ * Reads locally visible rows — a node that has not yet converged on the seal
1227
+ * still reports `false` (the schema's seal-propagation NOTE).
1228
+ *
1229
+ * @param db - The strand's database.
1230
+ * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
1231
+ * permanently closed its founding branch.
1232
+ */
1233
+ export async function isStrandSealed(db) {
1234
+ return await strandIsClosed(db)
1235
+ && await strandTableCount(db, 'Manager') === 0
1236
+ && await strandHasManagerRevocation(db);
1237
+ }
1070
1238
  //# sourceMappingURL=strand-membership-writer.js.map