@serfab/cadre-core 0.12.0 → 1.0.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.
- package/README.md +23 -8
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +883 -102
- package/dist/cadre-node.js +1800 -209
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +318 -44
- package/dist/control-database.js +516 -74
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +133 -21
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +17 -8
- package/dist/index.js +54 -9
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +15 -1
- package/dist/membership-connection-gater.js +9 -3
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +21 -7
- package/dist/node-local-snapshot.js +30 -9
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/relay-addrs.d.ts +160 -49
- package/dist/relay-addrs.js +287 -60
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +41 -11
- package/dist/strand-database.js +48 -18
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +99 -5
- package/dist/strand-formation-manager.js +119 -8
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +97 -3
- package/dist/strand-formation-protocol.js +108 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +389 -19
- package/dist/strand-instance-manager.js +678 -65
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +143 -11
- package/dist/strand-membership-writer.js +419 -135
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +116 -0
- package/dist/strand-network-config.js +192 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +17 -1
- package/dist/strand-solicitation.js +13 -2
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +12 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +472 -46
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +1926 -228
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +586 -74
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +133 -21
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +134 -15
- package/src/membership-connection-gater.ts +9 -3
- package/src/node-local-snapshot.ts +38 -9
- package/src/peer-addr-book.ts +251 -250
- package/src/peer-dial.ts +215 -0
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
- package/src/relay-addrs.ts +458 -218
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +61 -21
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +162 -8
- package/src/strand-formation-protocol.ts +850 -692
- package/src/strand-instance-manager.ts +877 -74
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +550 -190
- package/src/strand-network-config.ts +250 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +499 -471
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +325 -314
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +487 -52
- package/dist/strand-backfill.js.map +0 -1
package/src/control-database.ts
CHANGED
|
@@ -7,7 +7,7 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
|
7
7
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
8
8
|
import type { Libp2p } from '@libp2p/interface';
|
|
9
9
|
import type { IRepo } from '@optimystic/db-core';
|
|
10
|
-
import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
|
|
10
|
+
import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
|
|
11
11
|
import { CONTROL_SCHEMA } from './control-schema.js';
|
|
12
12
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
13
13
|
import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
|
|
@@ -15,8 +15,10 @@ import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from
|
|
|
15
15
|
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
16
16
|
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
17
17
|
import type { ControlWriteRetryOptions } from './control-write-retry.js';
|
|
18
|
-
import { retryControlRead } from './control-read-retry.js';
|
|
18
|
+
import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
|
|
19
19
|
import type { ControlReadRetryOptions } from './control-read-retry.js';
|
|
20
|
+
import { chainMessages } from './control-retry.js';
|
|
21
|
+
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
20
22
|
|
|
21
23
|
export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
|
|
22
24
|
|
|
@@ -290,17 +292,78 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
|
|
|
290
292
|
CadrePeer: 'PeerId',
|
|
291
293
|
ValidationKey: 'Key',
|
|
292
294
|
Strand: 'Id',
|
|
295
|
+
StrandPartyKey: 'Id',
|
|
293
296
|
DeviceToken: 'PeerId',
|
|
294
297
|
};
|
|
295
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The rejection a second insert of an already-seated strand id produces, as the optimystic
|
|
301
|
+
* vtab words it (`uniqueConstraintMessage`, qualified by table name only — no schema
|
|
302
|
+
* prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
|
|
303
|
+
*
|
|
304
|
+
* `Strand.StampId` is unique too, but a fresh stamp is minted per insert attempt, so only
|
|
305
|
+
* the primary key can collide on a repeat publish. Matching the column explicitly keeps
|
|
306
|
+
* any OTHER uniqueness failure out of the idempotency branch.
|
|
307
|
+
*/
|
|
308
|
+
const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Did this write fail because the strand id is already seated?
|
|
312
|
+
*
|
|
313
|
+
* {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
|
|
314
|
+
* landed" (re-read, and no-op when the row matches) apart from every other rejection —
|
|
315
|
+
* an unauthorized signer, a retired stamp — which must keep surfacing.
|
|
316
|
+
*
|
|
317
|
+
* Matched by TEXT, not by type: the typed engine error does not survive the trip out of
|
|
318
|
+
* optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
|
|
319
|
+
* Fails CLOSED — a rewording upstream turns the idempotent branch back into the raw
|
|
320
|
+
* uniqueness error the caller saw before, never into a silent overwrite. The
|
|
321
|
+
* `publish-strand.spec.ts` repeat-publish cases assert against the live engine error, so a
|
|
322
|
+
* reword reddens there.
|
|
323
|
+
*/
|
|
324
|
+
export function isStrandIdConflict(error: unknown): boolean {
|
|
325
|
+
return errorChainMatches(error, STRAND_ID_CONFLICT);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
|
|
330
|
+
* ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
|
|
331
|
+
* block. The schema's table comment says why that matters and why the row can never read
|
|
332
|
+
* as a retirement; `RowIsGone` pins this exact triple.
|
|
333
|
+
*/
|
|
334
|
+
export const REVOCATION_LEDGER_MARKER = {
|
|
335
|
+
tableName: 'Revocation',
|
|
336
|
+
rowKey: 'ledger',
|
|
337
|
+
stampId: 'opened',
|
|
338
|
+
} as const;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The rejection a second insert of the ledger marker produces: the optimystic vtab's
|
|
342
|
+
* primary-key wording (see {@link STRAND_ID_CONFLICT}), naming both columns of
|
|
343
|
+
* `Revocation`'s composite key. Applied only to the marker's own insert, whose key
|
|
344
|
+
* ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
|
|
345
|
+
* has no other unique constraint.
|
|
346
|
+
*/
|
|
347
|
+
const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
|
|
348
|
+
|
|
349
|
+
/** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
|
|
350
|
+
function isRevocationLedgerConflict(error: unknown): boolean {
|
|
351
|
+
return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
|
|
355
|
+
function errorChainMatches(error: unknown, pattern: RegExp): boolean {
|
|
356
|
+
return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
|
|
357
|
+
}
|
|
358
|
+
|
|
296
359
|
/**
|
|
297
360
|
* Guarded tables a node may reap locally once their tombstone has committed — the
|
|
298
361
|
* tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
|
|
299
|
-
* `CadrePeer.AuthorizedDelete` in the schema). `Strand`
|
|
300
|
-
*
|
|
301
|
-
* nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
|
|
302
|
-
* `OwnerKey` has no production removal path and `MinOneOwner` makes an
|
|
303
|
-
* owner-key reap a party-bricking hazard.
|
|
362
|
+
* `CadrePeer.AuthorizedDelete` in the schema). `Strand` and `StrandPartyKey` are
|
|
363
|
+
* deliberately absent: their rows carry `MemberPrivateKey` / `PrivateKey` — party
|
|
364
|
+
* secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
|
|
365
|
+
* future change). `OwnerKey` has no production removal path and `MinOneOwner` makes an
|
|
366
|
+
* automated owner-key reap a party-bricking hazard.
|
|
304
367
|
*/
|
|
305
368
|
export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey'] as const satisfies readonly RevocableTable[];
|
|
306
369
|
export type ReapableTable = (typeof REAPABLE_TABLES)[number];
|
|
@@ -346,6 +409,20 @@ export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
|
|
|
346
409
|
*/
|
|
347
410
|
export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
|
|
348
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Notified when the control-write retry funnel GIVES UP on a local control write — the
|
|
414
|
+
* classifier declined the failure as non-transient, or every attempt / the elapsed budget
|
|
415
|
+
* ran out. Carries the operation label, how far it got and the error
|
|
416
|
+
* ({@link ControlRetryAbandonment}).
|
|
417
|
+
*
|
|
418
|
+
* The seam that stops an abandoned BACKGROUND write from disappearing: those are `void`-ed
|
|
419
|
+
* with a `debug`-only catch, and that namespace is off unless somebody set `DEBUG=`, so
|
|
420
|
+
* without this the write, the operator and the embedding app all learn nothing. Synchronous
|
|
421
|
+
* and must not throw — the retry loop swallows and logs if it does, because the write's own
|
|
422
|
+
* failure is what has to reach the caller.
|
|
423
|
+
*/
|
|
424
|
+
export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
|
|
425
|
+
|
|
349
426
|
export interface ControlDatabaseConfig {
|
|
350
427
|
/** Party ID for the control network */
|
|
351
428
|
partyId: string;
|
|
@@ -372,8 +449,16 @@ export class ControlDatabase {
|
|
|
372
449
|
private initialized = false;
|
|
373
450
|
private membershipListener: MembershipChangeListener | null = null;
|
|
374
451
|
private guardedDeleteListener: GuardedDeleteListener | null = null;
|
|
452
|
+
private controlWriteAbandonedListener: ControlWriteAbandonedListener | null = null;
|
|
375
453
|
/** Tail of the local-write chain — see {@link withWriteLock}. */
|
|
376
454
|
private writeQueue: Promise<unknown> = Promise.resolve();
|
|
455
|
+
/**
|
|
456
|
+
* Locked write bodies currently running (see {@link runWriteBody}); what
|
|
457
|
+
* {@link readRowsOnce} consults to route an unlocked read around a write that has
|
|
458
|
+
* started but not yet opened its transaction. At most 1 while every writer takes the
|
|
459
|
+
* lock — a count rather than a flag so it stays truthful if that ever stops holding.
|
|
460
|
+
*/
|
|
461
|
+
private runningWriteBodies = 0;
|
|
377
462
|
/**
|
|
378
463
|
* Pacing seams for {@link lockedWithRetry}. Production leaves this empty (real backoff,
|
|
379
464
|
* real clock); specs inject a recorded `sleep` / fake `now` so no test waits out a real
|
|
@@ -474,8 +559,8 @@ export class ControlDatabase {
|
|
|
474
559
|
// NOTE: this is where a slow launch is felt. Duration is (raw-storage operations
|
|
475
560
|
// issued) × per-operation storage latency: ~1ms/op on an idle machine, but
|
|
476
561
|
// 50-90ms/op on a loaded disk or a phone's flash under launch contention. A cold
|
|
477
|
-
// start now reaches the backend
|
|
478
|
-
// — dominated by its
|
|
562
|
+
// start now reaches the backend 45 times (9 tables + 1 index, 20 distinct blocks
|
|
563
|
+
// — dominated by its genuine writes), a warm restart 13, because cadre-core
|
|
479
564
|
// wraps every embedder storage in `@optimystic/db-p2p`'s write-through cache
|
|
480
565
|
// (@serfab/quereus-plugin-sereus's cached-storage.ts). Uncached the same start issued ~2000 operations — the
|
|
481
566
|
// upstream re-read amplification measured in
|
|
@@ -596,11 +681,33 @@ export class ControlDatabase {
|
|
|
596
681
|
* opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
|
|
597
682
|
* publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
|
|
598
683
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
684
|
+
* "A write is in flight" is {@link writeInFlight}, and it needs TWO signals because a
|
|
685
|
+
* write is invisible to `getAutocommit()` for part of its life. `Database.exec` takes
|
|
686
|
+
* the exec mutex first; the implicit transaction — the moment `getAutocommit()` turns
|
|
687
|
+
* false — opens only after the write has acquired the mutex, planned the statement,
|
|
688
|
+
* and awaited `begin()` on every connection. While the write WAITS for the mutex (a
|
|
689
|
+
* serialized read holds it, say) it still reports autocommit, so a read routed on
|
|
690
|
+
* `getAutocommit()` alone queued behind the write and answered only after its whole
|
|
691
|
+
* commit — tens of seconds against a slow cohort member, which is what timed out the
|
|
692
|
+
* degraded-cohort scenario's `isMember` read (verified against the engine directly;
|
|
693
|
+
* `complete/control-read-queues-behind-a-write-waiting-for-the-database`). So an
|
|
694
|
+
* UNLOCKED read also takes the committed path while any locked write body is running
|
|
695
|
+
* ({@link runningWriteBodies}), which covers that queued window and everything else a
|
|
696
|
+
* body does before its transaction opens. A read issued INSIDE a locked body
|
|
697
|
+
* (`underWriteLock`) must not: that body is the one being counted, and its guard reads
|
|
698
|
+
* need the refreshing path — so it keeps the transaction-only test.
|
|
699
|
+
*
|
|
700
|
+
* Both signals are sampled synchronously just before `eval`, which claims its place
|
|
701
|
+
* on the mutex in that same tick, so the only race left is a body starting or ending
|
|
702
|
+
* in between: a body that starts after the sample queues its statements BEHIND this
|
|
703
|
+
* read, and one that ended before it has nothing left to queue behind.
|
|
704
|
+
*
|
|
705
|
+
* Residual gaps, both of which still queue a read behind a write:
|
|
706
|
+
* - an EXPLICIT transaction ({@link inTransaction}) disqualifies the committed path in
|
|
707
|
+
* Quereus itself, so a read overlapping such a body's `COMMIT` falls back to the
|
|
708
|
+
* serialized path and waits for it;
|
|
709
|
+
* - a writer that bypasses {@link withWriteLock} is not counted (in this repo only
|
|
710
|
+
* `reference-app-web`'s deliberately-rejected diagnostics insert does).
|
|
604
711
|
*
|
|
605
712
|
* What a committed read gives up is observing a write that has not finished
|
|
606
713
|
* committing, and that is safe for every caller here: an AWAITED write is committed by
|
|
@@ -608,14 +715,23 @@ export class ControlDatabase {
|
|
|
608
715
|
* open explicit transaction and stays serialized, so it still sees its own
|
|
609
716
|
* transaction's rows. A read after an UNAWAITED write on this database would be wrong
|
|
610
717
|
* — no such caller exists, and {@link withWriteLock} is what keeps it that way.
|
|
718
|
+
*
|
|
719
|
+
* NOTE: counting whole bodies widens the stale (non-refreshing) window from "a
|
|
720
|
+
* transaction is open" to "a locked body is running", which adds a body's pre-transaction
|
|
721
|
+
* work and its post-commit tail (a `CadrePeer` write's membership-listener read). The
|
|
722
|
+
* slow part of a write against a degraded cohort is its commit, already inside the old
|
|
723
|
+
* window, so the added time is short; if a poller for replicated rows ever starves
|
|
724
|
+
* behind back-to-back local writes, narrow the count to the span from a body's first
|
|
725
|
+
* statement to its transaction opening.
|
|
611
726
|
*/
|
|
612
|
-
private async readRowsOnce(sql: string, params
|
|
727
|
+
private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<Record<string, SqlValue>[]> {
|
|
613
728
|
let iterator: AsyncIterableIterator<Record<string, SqlValue>>;
|
|
614
|
-
if (this.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
log('read-eval: a write is in flight, asking for a committed read: %s', sql);
|
|
729
|
+
if (this.writeInFlight(underWriteLock)) {
|
|
730
|
+
log('read-eval: a write is in flight (%s), asking for a committed read: %s',
|
|
731
|
+
this.db!.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
|
|
618
732
|
iterator = this.db!.eval(sql, params, { readConcurrency: 'committed' });
|
|
733
|
+
} else {
|
|
734
|
+
iterator = this.db!.eval(sql, params);
|
|
619
735
|
}
|
|
620
736
|
const rows: Record<string, SqlValue>[] = [];
|
|
621
737
|
for await (const row of iterator) {
|
|
@@ -624,6 +740,20 @@ export class ControlDatabase {
|
|
|
624
740
|
return rows;
|
|
625
741
|
}
|
|
626
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Whether a control read should ask for a committed read — see {@link readRowsOnce}.
|
|
745
|
+
* `getAutocommit()` reports the whole `Database` (the property
|
|
746
|
+
* {@link assertCommitBoundary} leans on), so false means some writer's transaction is
|
|
747
|
+
* open. An unlocked read also counts a running locked body that has not opened its
|
|
748
|
+
* transaction yet; a read inside such a body does not, since it is that body.
|
|
749
|
+
*/
|
|
750
|
+
private writeInFlight(underWriteLock: boolean): boolean {
|
|
751
|
+
if (!this.db!.getAutocommit()) {
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
return !underWriteLock && this.runningWriteBodies > 0;
|
|
755
|
+
}
|
|
756
|
+
|
|
627
757
|
/**
|
|
628
758
|
* {@link readRowsOnce} plus the bounded transient-failure retry
|
|
629
759
|
* ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
|
|
@@ -639,8 +769,8 @@ export class ControlDatabase {
|
|
|
639
769
|
* primary-key lookup or a `count(1)`); nothing streams.
|
|
640
770
|
*
|
|
641
771
|
* Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
|
|
642
|
-
* (
|
|
643
|
-
* and change the right answer.
|
|
772
|
+
* ({@link writeInFlight}) is re-evaluated per attempt — a write can finish between
|
|
773
|
+
* attempts and change the right answer.
|
|
644
774
|
*
|
|
645
775
|
* The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
|
|
646
776
|
* under the write funnel's: the tightest caller deadline over a control read is the
|
|
@@ -673,13 +803,20 @@ export class ControlDatabase {
|
|
|
673
803
|
label: string,
|
|
674
804
|
retry = true
|
|
675
805
|
): Promise<Record<string, SqlValue>[]> {
|
|
806
|
+
// `retry: false` marks the reads that may run inside a locked write body, which is
|
|
807
|
+
// also what readRowsOnce's committed-read routing must know (see writeInFlight).
|
|
808
|
+
// NOTE: the membership-gate refresh passes it on EVERY trigger, including the
|
|
809
|
+
// unlocked ones (start, reconcile, seed-applied), so those keep the transaction-only
|
|
810
|
+
// routing and can still queue behind a write waiting for the exec mutex (as before this
|
|
811
|
+
// routing existed). Tolerable while its awaiting callers (reconcile, applySeed) carry no
|
|
812
|
+
// tight deadline; if one ever does, pass the locked/unlocked distinction through instead.
|
|
676
813
|
if (!retry) {
|
|
677
|
-
return this.readRowsOnce(sql, params);
|
|
814
|
+
return this.readRowsOnce(sql, params, true);
|
|
678
815
|
}
|
|
679
816
|
// The label lands LAST so the call site's name survives the spec-injected pacing —
|
|
680
817
|
// it changes log attribution only, never behaviour (same layering as lockedWithRetry).
|
|
681
818
|
return retryControlRead(
|
|
682
|
-
() => this.readRowsOnce(sql, params),
|
|
819
|
+
() => this.readRowsOnce(sql, params, false),
|
|
683
820
|
{ ...this.controlReadRetryPacing, label }
|
|
684
821
|
);
|
|
685
822
|
}
|
|
@@ -690,11 +827,12 @@ export class ControlDatabase {
|
|
|
690
827
|
async queryStrands(): Promise<StrandRow[]> {
|
|
691
828
|
this.ensureInitialized();
|
|
692
829
|
const results: StrandRow[] = [];
|
|
693
|
-
for (const row of await this.readRows('select Id, MemberPrivateKey, Type from CadreControl.Strand', undefined, 'strands')) {
|
|
830
|
+
for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
|
|
694
831
|
results.push({
|
|
695
832
|
Id: row.Id as string,
|
|
696
833
|
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
697
834
|
Type: row.Type as 'o' | 'c',
|
|
835
|
+
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
698
836
|
});
|
|
699
837
|
}
|
|
700
838
|
return results;
|
|
@@ -709,7 +847,7 @@ export class ControlDatabase {
|
|
|
709
847
|
async queryStrand(strandId: string): Promise<StrandRow | null> {
|
|
710
848
|
this.ensureInitialized();
|
|
711
849
|
for (const row of await this.readRows(
|
|
712
|
-
'select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?',
|
|
850
|
+
'select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?',
|
|
713
851
|
[strandId],
|
|
714
852
|
'strand'
|
|
715
853
|
)) {
|
|
@@ -717,6 +855,7 @@ export class ControlDatabase {
|
|
|
717
855
|
Id: row.Id as string,
|
|
718
856
|
MemberPrivateKey: row.MemberPrivateKey as string | null,
|
|
719
857
|
Type: row.Type as 'o' | 'c',
|
|
858
|
+
FounderOwnerKey: row.FounderOwnerKey as string | null,
|
|
720
859
|
};
|
|
721
860
|
}
|
|
722
861
|
return null;
|
|
@@ -809,10 +948,12 @@ export class ControlDatabase {
|
|
|
809
948
|
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
810
949
|
* that seam to model the replicated live-row-plus-tombstone merge state.
|
|
811
950
|
*
|
|
812
|
-
* NOTE: runs a second query (the retired-stamp set) per call,
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
951
|
+
* NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
|
|
952
|
+
* refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
|
|
953
|
+
* while `Revocation` has never been written every call consults the cohort about it, and
|
|
954
|
+
* the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
|
|
955
|
+
* the exclusion into one statement would not help (it reads the same block), and caching
|
|
956
|
+
* the set would hide a tombstone arriving by replication.
|
|
816
957
|
*
|
|
817
958
|
* NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
|
|
818
959
|
* (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
|
|
@@ -931,6 +1072,30 @@ export class ControlDatabase {
|
|
|
931
1072
|
return this.queryStampId('Strand', strandId);
|
|
932
1073
|
}
|
|
933
1074
|
|
|
1075
|
+
/** `StrandPartyKey` stamp nonce — bound into {@link deleteStrandPartyKey}'s remove digest. */
|
|
1076
|
+
queryStrandPartyKeyStampId(strandId: string): Promise<string | null> {
|
|
1077
|
+
return this.queryStampId('StrandPartyKey', strandId);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Read THIS party's own strand membership private key (base64 protobuf; decode with
|
|
1082
|
+
* `strandMemberKeyPair`) for one strand, or null when no `StrandPartyKey` row exists.
|
|
1083
|
+
* The identity source the closed-strand founder bootstrap derives its `Member.Key` /
|
|
1084
|
+
* `Manager.MemberKey` from — deliberately NOT `Strand.MemberPrivateKey`, the
|
|
1085
|
+
* strand-wide read secret every joining party receives.
|
|
1086
|
+
*/
|
|
1087
|
+
async queryStrandPartyKey(strandId: string): Promise<string | null> {
|
|
1088
|
+
this.ensureInitialized();
|
|
1089
|
+
for (const row of await this.readRows(
|
|
1090
|
+
'select PrivateKey from CadreControl.StrandPartyKey where Id = ?',
|
|
1091
|
+
[strandId],
|
|
1092
|
+
'strand-party-key'
|
|
1093
|
+
)) {
|
|
1094
|
+
return (row.PrivateKey as string | null) ?? null;
|
|
1095
|
+
}
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
934
1099
|
/** `ValidationKey` stamp nonce — bound into {@link deleteValidationKey}'s remove digest. */
|
|
935
1100
|
queryValidationKeyStampId(key: string): Promise<string | null> {
|
|
936
1101
|
return this.queryStampId('ValidationKey', key);
|
|
@@ -948,9 +1113,18 @@ export class ControlDatabase {
|
|
|
948
1113
|
* ({@link queryCadrePeers}, {@link queryPeerRecord},
|
|
949
1114
|
* {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
|
|
950
1115
|
*
|
|
951
|
-
* NOTE:
|
|
952
|
-
*
|
|
953
|
-
*
|
|
1116
|
+
* NOTE: the per-call cost that matters is the storage layer's, not the row count. While
|
|
1117
|
+
* `Revocation` has never been written, this node does not hold its block, and Optimystic
|
|
1118
|
+
* consults a block's cohort on every read of a block it does not hold: 1 consult per
|
|
1119
|
+
* call (measured), each a round trip to every other member on a multi-machine party. The
|
|
1120
|
+
* ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
|
|
1121
|
+
* at most once per read-repair window (10 s). Do not cache the set instead: a cache
|
|
1122
|
+
* cannot see a tombstone arriving by replication, so it would delay a revocation. The
|
|
1123
|
+
* row count only grows (append-only) but stays small while removals are rare; revisit if
|
|
1124
|
+
* removals become routine and the scan itself shows up in a profile.
|
|
1125
|
+
*
|
|
1126
|
+
* An isolated node that has never received the `Revocation` block reads it as holding no
|
|
1127
|
+
* revocations instead of throwing — see {@link readRevokedStampRows}.
|
|
954
1128
|
*
|
|
955
1129
|
* `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
|
|
956
1130
|
* under the write lock — see that method's note.
|
|
@@ -958,24 +1132,77 @@ export class ControlDatabase {
|
|
|
958
1132
|
async queryRevokedStamps(tableName: RevocableTable, retry = true): Promise<Set<string>> {
|
|
959
1133
|
this.ensureInitialized();
|
|
960
1134
|
const stamps = new Set<string>();
|
|
961
|
-
for (const row of await this.
|
|
1135
|
+
for (const row of await this.readRevokedStampRows(tableName, retry)) {
|
|
962
1136
|
stamps.add(row.StampId as string);
|
|
963
1137
|
}
|
|
964
1138
|
return stamps;
|
|
965
1139
|
}
|
|
966
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
|
|
1143
|
+
* fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
|
|
1144
|
+
* the block and could ask no other cohort member about it. Every other failure rethrows.
|
|
1145
|
+
* The retry in {@link readRows} runs first, so a node whose connectivity returns within
|
|
1146
|
+
* the read budget still gets the real answer.
|
|
1147
|
+
*
|
|
1148
|
+
* Without this, a machine cut off from every other machine in its party before it ever
|
|
1149
|
+
* received the block — the block does not exist anywhere until the owner files the
|
|
1150
|
+
* ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
|
|
1151
|
+
* answer ANY membership, peer-record or device-token lookup, although it holds those
|
|
1152
|
+
* rows locally.
|
|
1153
|
+
*
|
|
1154
|
+
* NOTE: accepted tradeoff — `cohort-unreachable` on THIS read, and only this read, is
|
|
1155
|
+
* treated as "no revocations known". It adds no fail-open that does not already exist:
|
|
1156
|
+
* an isolated node holding a stale `Revocation` block is served it silently (the consult
|
|
1157
|
+
* reaches nobody, so no doubt is raised), and a revocation authored elsewhere is invisible
|
|
1158
|
+
* to it either way until connectivity returns. The other reasons still throw:
|
|
1159
|
+
* `peers-unreachable` means part of the cohort answered, and `claimed-elsewhere` /
|
|
1160
|
+
* `unmaterializable` mean the block exists, so an empty answer could admit a revoked
|
|
1161
|
+
* member. Do not move this into {@link readRows}: an empty answer is known-safe only for
|
|
1162
|
+
* a table whose empty state is its never-written state; decide any other table in its
|
|
1163
|
+
* own reader. Revisit if optimystic starts reporting a held-but-stale block distinctly
|
|
1164
|
+
* (the equivalence above then breaks), or if revocation enforcement must fail closed
|
|
1165
|
+
* under partition.
|
|
1166
|
+
*
|
|
1167
|
+
* NOTE: an isolated node pays the whole read retry (up to
|
|
1168
|
+
* `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
|
|
1169
|
+
* answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
|
|
1170
|
+
* node's lookups are seen exceeding the admission gate's 2 s deadline
|
|
1171
|
+
* (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
|
|
1172
|
+
* fails `cohort-unreachable`.
|
|
1173
|
+
*/
|
|
1174
|
+
private async readRevokedStampRows(tableName: RevocableTable, retry: boolean): Promise<Record<string, SqlValue>[]> {
|
|
1175
|
+
try {
|
|
1176
|
+
return await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry);
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
if (!isCohortUnreachableRead(error)) {
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
log('revoked-stamps(%s): no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', tableName, error);
|
|
1182
|
+
return [];
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
967
1186
|
/**
|
|
968
1187
|
* Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
|
|
969
1188
|
* `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
|
|
970
1189
|
* enumerates what this node holds before {@link reissueRevocations} re-broadcasts
|
|
971
|
-
* it. Plain scan with no `where`, so
|
|
972
|
-
* (see the statement comment in
|
|
973
|
-
* like every other read.
|
|
1190
|
+
* it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
|
|
1191
|
+
* the composite-primary-key point-lookup hazard (see the statement comment in
|
|
1192
|
+
* {@link reissueRevocations}) does not arise. Unlocked, like every other read.
|
|
1193
|
+
*
|
|
1194
|
+
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
|
|
1195
|
+
* neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
|
|
1196
|
+
* a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
|
|
1197
|
+
* admits no other row under `'Revocation'`.
|
|
974
1198
|
*/
|
|
975
1199
|
async queryRevocations(): Promise<RevocationRow[]> {
|
|
976
1200
|
this.ensureInitialized();
|
|
977
1201
|
const rows: RevocationRow[] = [];
|
|
978
1202
|
for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
|
|
1203
|
+
if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
979
1206
|
rows.push({
|
|
980
1207
|
tableName: row.TableName as RevocableTable,
|
|
981
1208
|
rowKey: row.RowKey as string,
|
|
@@ -1144,6 +1371,10 @@ export class ControlDatabase {
|
|
|
1144
1371
|
/**
|
|
1145
1372
|
* Insert a strand into the control database using an owner signature.
|
|
1146
1373
|
*
|
|
1374
|
+
* Fails with a `Strand.Id` uniqueness violation when the id is already seated — the
|
|
1375
|
+
* caller decides whether that is a duplicate of its own earlier write (idempotent) or a
|
|
1376
|
+
* genuine conflict; see {@link isStrandIdConflict} and `CadreNode.publishStrand`.
|
|
1377
|
+
*
|
|
1147
1378
|
* The owner signs the canonical row-bound authorization message (see
|
|
1148
1379
|
* {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
|
|
1149
1380
|
* this strand's contents and cannot be transplanted onto an attacker-chosen row. The
|
|
@@ -1171,16 +1402,19 @@ export class ControlDatabase {
|
|
|
1171
1402
|
const stampId = generateStampId(peerId);
|
|
1172
1403
|
|
|
1173
1404
|
// Field order MUST match the schema's Strand `AuthorizedInsert` verify:
|
|
1174
|
-
// Id, Type, MemberPrivateKey ('' when null), StampId.
|
|
1405
|
+
// Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
|
|
1406
|
+
// deliberately NOT in the digest — the schema binds it by equality to the
|
|
1407
|
+
// verified context.OwnerKey instead (see the constraint's comment).
|
|
1175
1408
|
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
1176
1409
|
const signature = signMessage(message);
|
|
1177
1410
|
|
|
1178
1411
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
1412
|
+
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
1179
1413
|
await this.execWrite(`
|
|
1180
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
1414
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
1181
1415
|
with context OwnerKey = ?, Signature = ?
|
|
1182
|
-
values (?, ?, ?, ?)
|
|
1183
|
-
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
|
|
1416
|
+
values (?, ?, ?, ?, ?)
|
|
1417
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
|
|
1184
1418
|
|
|
1185
1419
|
log('Strand inserted: %s', strandId);
|
|
1186
1420
|
}
|
|
@@ -1200,6 +1434,15 @@ export class ControlDatabase {
|
|
|
1200
1434
|
* The remove digest binds only (Id, StampId) — not Type/MemberPrivateKey — so this
|
|
1201
1435
|
* works identically for open and closed strands.
|
|
1202
1436
|
*
|
|
1437
|
+
* When the strand has a `StrandPartyKey` row (this party's own membership identity for
|
|
1438
|
+
* the strand — minted at publish for closed strands), it is deleted IN THE SAME
|
|
1439
|
+
* transaction, with its own `'remove'`-tagged signature and its own `Revocation`
|
|
1440
|
+
* tombstone, so a re-published strand always mints fresh identity and no crash window
|
|
1441
|
+
* can orphan the key row. The party-key delete happens ONLY alongside an actual strand
|
|
1442
|
+
* row delete: a `StrandPartyKey` row with no local `Strand` row is a JOINER's identity
|
|
1443
|
+
* (the joiner never holds the strand row) and is not this method's to destroy — that
|
|
1444
|
+
* is {@link deleteStrandPartyKey}'s.
|
|
1445
|
+
*
|
|
1203
1446
|
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
1204
1447
|
* when a row was actually removed.
|
|
1205
1448
|
*/
|
|
@@ -1208,7 +1451,141 @@ export class ControlDatabase {
|
|
|
1208
1451
|
ownerKey: string,
|
|
1209
1452
|
signMessage: (message: Uint8Array) => string
|
|
1210
1453
|
): Promise<boolean> {
|
|
1211
|
-
return this.lockedWithRetry(() => this.
|
|
1454
|
+
return this.lockedWithRetry(() => this.deleteStrandAndPartyKey(strandId, ownerKey, signMessage), {}, 'strand-delete');
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The locked body of {@link deleteStrand}: the `Strand` delete + tombstone, plus — when
|
|
1459
|
+
* one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
|
|
1460
|
+
* transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
|
|
1461
|
+
* per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
|
|
1462
|
+
* digests, mandatory same-transaction tombstones); kept separate rather than
|
|
1463
|
+
* generalizing the shared body because no other guarded table has a companion row.
|
|
1464
|
+
*
|
|
1465
|
+
* NOTE: that duplication is a drift risk, not a correctness one — a future tightening of
|
|
1466
|
+
* {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
|
|
1467
|
+
* will NOT reach this body. Grep for both when you change either; if a second guarded
|
|
1468
|
+
* table ever gains a companion row, generalize instead of copying this a third time.
|
|
1469
|
+
*
|
|
1470
|
+
* Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
|
|
1471
|
+
*/
|
|
1472
|
+
private async deleteStrandAndPartyKey(
|
|
1473
|
+
strandId: string,
|
|
1474
|
+
ownerKey: string,
|
|
1475
|
+
signMessage: (message: Uint8Array) => string
|
|
1476
|
+
): Promise<boolean> {
|
|
1477
|
+
this.ensureInitialized();
|
|
1478
|
+
// retry: false — these reads run inside the locked write body (see queryStampId's NOTE).
|
|
1479
|
+
const strandStamp = await this.queryStampId('Strand', strandId, false);
|
|
1480
|
+
if (strandStamp === null) {
|
|
1481
|
+
log('delete Strand: no row for %s (already absent)', strandId);
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
|
|
1485
|
+
|
|
1486
|
+
const strandSignature = signMessage(
|
|
1487
|
+
buildAuthorizationMessage('CadreControl.Strand', 'remove', [strandId, strandStamp]));
|
|
1488
|
+
const strandRevocationSignature = signMessage(
|
|
1489
|
+
buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['Strand', strandId, strandStamp]));
|
|
1490
|
+
const partyKeySignature = partyKeyStamp === null ? null : signMessage(
|
|
1491
|
+
buildAuthorizationMessage('CadreControl.StrandPartyKey', 'remove', [strandId, partyKeyStamp]));
|
|
1492
|
+
const partyKeyRevocationSignature = partyKeyStamp === null ? null : signMessage(
|
|
1493
|
+
buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['StrandPartyKey', strandId, partyKeyStamp]));
|
|
1494
|
+
|
|
1495
|
+
await this.inTransaction('delete Strand', async () => {
|
|
1496
|
+
await this.db!.exec(`
|
|
1497
|
+
delete from CadreControl.Strand
|
|
1498
|
+
with context OwnerKey = ?, Signature = ?
|
|
1499
|
+
where Id = ?
|
|
1500
|
+
`, [ownerKey, strandSignature, strandId]);
|
|
1501
|
+
await this.db!.exec(`
|
|
1502
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1503
|
+
with context OwnerKey = ?, Signature = ?
|
|
1504
|
+
values ('Strand', ?, ?, 0)
|
|
1505
|
+
`, [ownerKey, strandRevocationSignature, strandId, strandStamp]);
|
|
1506
|
+
if (partyKeyStamp !== null) {
|
|
1507
|
+
await this.db!.exec(`
|
|
1508
|
+
delete from CadreControl.StrandPartyKey
|
|
1509
|
+
with context OwnerKey = ?, Signature = ?
|
|
1510
|
+
where Id = ?
|
|
1511
|
+
`, [ownerKey, partyKeySignature, strandId]);
|
|
1512
|
+
await this.db!.exec(`
|
|
1513
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1514
|
+
with context OwnerKey = ?, Signature = ?
|
|
1515
|
+
values ('StrandPartyKey', ?, ?, 0)
|
|
1516
|
+
`, [ownerKey, partyKeyRevocationSignature, strandId, partyKeyStamp]);
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
log('Strand deleted: %s (stamp retired%s)', strandId,
|
|
1521
|
+
partyKeyStamp === null ? '' : '; party key deleted, stamp retired');
|
|
1522
|
+
this.notifyGuardedDelete({ tableName: 'Strand', rowKey: strandId, stampId: strandStamp });
|
|
1523
|
+
if (partyKeyStamp !== null) {
|
|
1524
|
+
this.notifyGuardedDelete({ tableName: 'StrandPartyKey', rowKey: strandId, stampId: partyKeyStamp });
|
|
1525
|
+
}
|
|
1526
|
+
return true;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Insert this party's own strand membership identity key (`StrandPartyKey` row) using
|
|
1531
|
+
* an owner signature.
|
|
1532
|
+
*
|
|
1533
|
+
* Mirrors {@link insertStrand}: the owner signs the canonical row-bound authorization
|
|
1534
|
+
* message over (Id, PrivateKey, StampId) — binding the key material means a captured
|
|
1535
|
+
* approval can only ever reproduce the exact key it approved — and the StampId is
|
|
1536
|
+
* persisted as a unique column for single-use anti-replay.
|
|
1537
|
+
*
|
|
1538
|
+
* @param strandId - The strand this key is the party's identity for.
|
|
1539
|
+
* @param privateKey - The party's ed25519 strand member private key, base64 protobuf
|
|
1540
|
+
* (same encoding as `Strand.MemberPrivateKey`; mint with `generateStrandMemberKey`).
|
|
1541
|
+
* @param ownerKey - Public key of the authorizing owner.
|
|
1542
|
+
* @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
|
|
1543
|
+
* with the owner's private key, returning a base64url signature.
|
|
1544
|
+
*/
|
|
1545
|
+
async insertStrandPartyKey(
|
|
1546
|
+
strandId: string,
|
|
1547
|
+
privateKey: string,
|
|
1548
|
+
ownerKey: string,
|
|
1549
|
+
signMessage: (message: Uint8Array) => string
|
|
1550
|
+
): Promise<void> {
|
|
1551
|
+
this.ensureInitialized();
|
|
1552
|
+
log('Inserting strand party key for strand: %s', strandId);
|
|
1553
|
+
|
|
1554
|
+
const peerId = this.config.libp2pNode.peerId.toString();
|
|
1555
|
+
const stampId = generateStampId(peerId);
|
|
1556
|
+
|
|
1557
|
+
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
1558
|
+
// Id, PrivateKey, StampId.
|
|
1559
|
+
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
|
|
1560
|
+
const signature = signMessage(message);
|
|
1561
|
+
|
|
1562
|
+
await this.execWrite(`
|
|
1563
|
+
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
1564
|
+
with context OwnerKey = ?, Signature = ?
|
|
1565
|
+
values (?, ?, ?)
|
|
1566
|
+
`, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
|
|
1567
|
+
|
|
1568
|
+
log('Strand party key inserted for strand: %s', strandId);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Owner-signed removal of one `StrandPartyKey` row — the party's own membership
|
|
1573
|
+
* identity for that strand, stored nowhere else, so this is as destructive as
|
|
1574
|
+
* {@link deleteStrand} on a closed strand. {@link deleteStrand} already removes the
|
|
1575
|
+
* founder's row alongside the `Strand` row in one transaction; this standalone form
|
|
1576
|
+
* exists for a party-key row with NO local `Strand` row (a joiner's identity — the
|
|
1577
|
+
* shape the formation tickets build on).
|
|
1578
|
+
*
|
|
1579
|
+
* Mirrors {@link deleteValidationKey}: `'remove'`-tagged digest over (Id, StampId),
|
|
1580
|
+
* `Revocation` tombstone in the same transaction. A no-op (no throw, no tombstone)
|
|
1581
|
+
* when the row does not exist — `false` then, `true` when a row was actually removed.
|
|
1582
|
+
*/
|
|
1583
|
+
deleteStrandPartyKey(
|
|
1584
|
+
strandId: string,
|
|
1585
|
+
ownerKey: string,
|
|
1586
|
+
signMessage: (message: Uint8Array) => string
|
|
1587
|
+
): Promise<boolean> {
|
|
1588
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('StrandPartyKey', strandId, ownerKey, signMessage), {}, 'strand-party-key-delete');
|
|
1212
1589
|
}
|
|
1213
1590
|
|
|
1214
1591
|
/**
|
|
@@ -1546,16 +1923,27 @@ export class ControlDatabase {
|
|
|
1546
1923
|
});
|
|
1547
1924
|
|
|
1548
1925
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
1549
|
-
|
|
1550
|
-
try {
|
|
1551
|
-
this.guardedDeleteListener({ tableName: table, rowKey: keyValue, stampId });
|
|
1552
|
-
} catch (error) {
|
|
1553
|
-
log('guarded-delete listener threw (committed delete unaffected): %o', error);
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1926
|
+
this.notifyGuardedDelete({ tableName: table, rowKey: keyValue, stampId });
|
|
1556
1927
|
return true;
|
|
1557
1928
|
}
|
|
1558
1929
|
|
|
1930
|
+
/**
|
|
1931
|
+
* Fire the guarded-delete listener for one committed tombstone, swallowing (and
|
|
1932
|
+
* logging) a listener throw — a committed delete never fails because bookkeeping did.
|
|
1933
|
+
* Shared by {@link deleteGuardedRow} and {@link deleteStrandAndPartyKey} (which files
|
|
1934
|
+
* up to two tombstones per transaction).
|
|
1935
|
+
*/
|
|
1936
|
+
private notifyGuardedDelete(revocation: RevokedRowRef): void {
|
|
1937
|
+
if (!this.guardedDeleteListener) {
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
try {
|
|
1941
|
+
this.guardedDeleteListener(revocation);
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
log('guarded-delete listener threw (committed delete unaffected): %o', error);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1559
1947
|
/**
|
|
1560
1948
|
* Delete one guarded row that an ALREADY-COMMITTED `Revocation` tombstone retires —
|
|
1561
1949
|
* the local catch-up for a node that converged on a removal's tombstone while still
|
|
@@ -1664,8 +2052,9 @@ export class ControlDatabase {
|
|
|
1664
2052
|
*
|
|
1665
2053
|
* Two rows are deliberately left alone:
|
|
1666
2054
|
*
|
|
1667
|
-
* - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `OwnerKey`)
|
|
1668
|
-
* exists on their `AuthorizedDelete`, so the delete would throw
|
|
2055
|
+
* - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `StrandPartyKey`, `OwnerKey`)
|
|
2056
|
+
* — no reap branch exists on their `AuthorizedDelete`, so the delete would throw
|
|
2057
|
+
* rather than no-op.
|
|
1669
2058
|
* - **This node's OWN `CadrePeer` / `DeviceToken` row** (`selfPeerId`). Reaping it would
|
|
1670
2059
|
* fight this node's own re-registration: `registerSelf` / `retouchSelfDeviceToken` run
|
|
1671
2060
|
* on the heartbeat and the growth drain, and after a self-reap their insert-if-absent
|
|
@@ -1685,8 +2074,11 @@ export class ControlDatabase {
|
|
|
1685
2074
|
* skip-self rule is exercisable without one.
|
|
1686
2075
|
*
|
|
1687
2076
|
* NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
|
|
1688
|
-
* one {@link queryStampId} point lookup per tombstone, and the empty
|
|
1689
|
-
*
|
|
2077
|
+
* one {@link queryStampId} point lookup per tombstone, and the empty early return makes
|
|
2078
|
+
* the common case (a party that has never revoked anyone; {@link queryRevocations} skips
|
|
2079
|
+
* the ledger marker) a single scan per pass. That scan consults the cohort on every pass
|
|
2080
|
+
* only while `Revocation` has never been written; the ledger marker
|
|
2081
|
+
* ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
|
|
1690
2082
|
* But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
|
|
1691
2083
|
* lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
|
|
1692
2084
|
* party; if the table ever grows, persist a node-local high-water mark of what has
|
|
@@ -1803,6 +2195,85 @@ export class ControlDatabase {
|
|
|
1803
2195
|
}, {}, 'revocation-reissue');
|
|
1804
2196
|
}
|
|
1805
2197
|
|
|
2198
|
+
/**
|
|
2199
|
+
* Owner-signed filing of the singleton `Revocation` ledger marker
|
|
2200
|
+
* ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
|
|
2201
|
+
*
|
|
2202
|
+
* Why it exists: while `Revocation` has never been written, this node does not hold its
|
|
2203
|
+
* block, and Optimystic consults a block's cohort on EVERY read of a block it does not
|
|
2204
|
+
* hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
|
|
2205
|
+
* reads this table. Once any row exists the block is held and re-checked at most once per
|
|
2206
|
+
* read-repair window, like every other populated table. The schema's table comment says
|
|
2207
|
+
* why the row can never read as a retirement.
|
|
2208
|
+
*
|
|
2209
|
+
* Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
|
|
2210
|
+
* OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
|
|
2211
|
+
* runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
|
|
2212
|
+
* the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
|
|
2213
|
+
* full composite primary key, which is served as a point lookup that can miss an existing
|
|
2214
|
+
* row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
|
|
2215
|
+
*
|
|
2216
|
+
* Two owners filing at once: the loser either sees the marker in its guard or is refused
|
|
2217
|
+
* on the primary key, and both answer `'already-open'`.
|
|
2218
|
+
*
|
|
2219
|
+
* The caller decides WHEN. A marker committed while the node is alone is local-only and
|
|
2220
|
+
* can fork the collection, so {@link CadreNode} files it only while connected; this
|
|
2221
|
+
* method does not look at connectivity.
|
|
2222
|
+
*
|
|
2223
|
+
* @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
|
|
2224
|
+
* @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
|
|
2225
|
+
* private key, returning a base64url signature.
|
|
2226
|
+
* @returns `'opened'` when this call filed the marker, `'already-open'` when it was
|
|
2227
|
+
* already there.
|
|
2228
|
+
*/
|
|
2229
|
+
async openRevocationLedger(
|
|
2230
|
+
ownerKey: string,
|
|
2231
|
+
signMessage: (message: Uint8Array) => string
|
|
2232
|
+
): Promise<RevocationLedgerOpenResult> {
|
|
2233
|
+
this.ensureInitialized();
|
|
2234
|
+
const { tableName, rowKey, stampId } = REVOCATION_LEDGER_MARKER;
|
|
2235
|
+
const signature = signMessage(buildAuthorizationMessage(
|
|
2236
|
+
'CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]
|
|
2237
|
+
));
|
|
2238
|
+
try {
|
|
2239
|
+
return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
|
|
2240
|
+
if (await this.revocationLedgerFiled()) {
|
|
2241
|
+
return 'already-open';
|
|
2242
|
+
}
|
|
2243
|
+
// Bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
2244
|
+
await this.db!.exec(`
|
|
2245
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId)
|
|
2246
|
+
with context OwnerKey = ?, Signature = ?
|
|
2247
|
+
values (?, ?, ?)
|
|
2248
|
+
`, [ownerKey, signature, tableName, rowKey, stampId]);
|
|
2249
|
+
log('Revocation ledger marker filed');
|
|
2250
|
+
return 'opened';
|
|
2251
|
+
}, {}, 'revocation-ledger-open');
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
if (!isRevocationLedgerConflict(error)) {
|
|
2254
|
+
throw error;
|
|
2255
|
+
}
|
|
2256
|
+
log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
|
|
2257
|
+
return 'already-open';
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
|
|
2263
|
+
* stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
|
|
2264
|
+
* Called only inside the locked write body, so never retried (see queryStampId's NOTE).
|
|
2265
|
+
*/
|
|
2266
|
+
private async revocationLedgerFiled(): Promise<boolean> {
|
|
2267
|
+
const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
|
|
2268
|
+
const rows = await this.readRows(
|
|
2269
|
+
'select StampId from CadreControl.Revocation where TableName = ?',
|
|
2270
|
+
[tableName],
|
|
2271
|
+
'revocation-ledger',
|
|
2272
|
+
false
|
|
2273
|
+
);
|
|
2274
|
+
return rows.some(row => row.StampId === stampId);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
1806
2277
|
/**
|
|
1807
2278
|
* Run `body` between `beginTransaction` and `commit`, rolling back on failure.
|
|
1808
2279
|
*
|
|
@@ -1864,6 +2335,20 @@ export class ControlDatabase {
|
|
|
1864
2335
|
this.guardedDeleteListener = listener;
|
|
1865
2336
|
}
|
|
1866
2337
|
|
|
2338
|
+
/**
|
|
2339
|
+
* Wire (or clear, with null) the single listener notified when {@link lockedWithRetry}
|
|
2340
|
+
* ABANDONS a control write. Same ownership contract as
|
|
2341
|
+
* {@link setMembershipChangeListener}: one CadreNode, wired in `start()` and cleared on
|
|
2342
|
+
* teardown, and a second call replaces the first rather than fanning out.
|
|
2343
|
+
*
|
|
2344
|
+
* Covers every write that reaches the funnel, foreground and background alike. A
|
|
2345
|
+
* foreground caller also sees the rethrown error; a background one often does not, which
|
|
2346
|
+
* is the whole reason this exists.
|
|
2347
|
+
*/
|
|
2348
|
+
setControlWriteAbandonedListener(listener: ControlWriteAbandonedListener | null): void {
|
|
2349
|
+
this.controlWriteAbandonedListener = listener;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
1867
2352
|
/**
|
|
1868
2353
|
* Run a `CadrePeer` row mutation and notify the membership listener once it has
|
|
1869
2354
|
* COMMITTED.
|
|
@@ -1949,11 +2434,27 @@ export class ControlDatabase {
|
|
|
1949
2434
|
async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
1950
2435
|
// Chain behind the current tail regardless of how it settled, then park a
|
|
1951
2436
|
// swallowed copy as the new tail so one failed write never poisons the queue.
|
|
1952
|
-
const
|
|
2437
|
+
const body = () => this.runWriteBody(fn);
|
|
2438
|
+
const run = this.writeQueue.then(body, body);
|
|
1953
2439
|
this.writeQueue = run.then(() => undefined, () => undefined);
|
|
1954
2440
|
return run;
|
|
1955
2441
|
}
|
|
1956
2442
|
|
|
2443
|
+
/**
|
|
2444
|
+
* Run one locked body, counted in {@link runningWriteBodies} from the moment it takes
|
|
2445
|
+
* the lock until it settles — so an unlocked read routes around it even while its
|
|
2446
|
+
* first statement is still waiting for the database's exec mutex, before
|
|
2447
|
+
* `getAutocommit()` can show it (see {@link readRowsOnce}).
|
|
2448
|
+
*/
|
|
2449
|
+
private async runWriteBody<T>(fn: () => Promise<T>): Promise<T> {
|
|
2450
|
+
this.runningWriteBodies++;
|
|
2451
|
+
try {
|
|
2452
|
+
return await fn();
|
|
2453
|
+
} finally {
|
|
2454
|
+
this.runningWriteBodies--;
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
1957
2458
|
/**
|
|
1958
2459
|
* {@link withWriteLock} plus the bounded transient-failure retry
|
|
1959
2460
|
* ({@link retryControlWrite}) — the wrapper the whole public write surface goes
|
|
@@ -1977,14 +2478,26 @@ export class ControlDatabase {
|
|
|
1977
2478
|
* `label` names the operation in this loop's debug lines and nothing else
|
|
1978
2479
|
* ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
|
|
1979
2480
|
* several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
|
|
1980
|
-
* attributed to a write — keep new call sites labelled.
|
|
2481
|
+
* attributed to a write — keep new call sites labelled. It is also the only thing that
|
|
2482
|
+
* attributes an abandonment reported through {@link setControlWriteAbandonedListener}.
|
|
2483
|
+
*
|
|
2484
|
+
* The abandonment observer lands LAST, after both the policy and the spec-injected
|
|
2485
|
+
* pacing, so this class's single listener is the one seam for it — a caller that
|
|
2486
|
+
* smuggled its own in through `policy` would be displaced here rather than fanning out.
|
|
1981
2487
|
*/
|
|
1982
2488
|
private lockedWithRetry<T>(fn: () => Promise<T>, policy: ControlWriteRetryOptions = {}, label?: string): Promise<T> {
|
|
1983
2489
|
// The label lands LAST so a call site's own name survives both the policy and the
|
|
1984
2490
|
// spec-injected pacing; it changes log attribution only, never behaviour.
|
|
1985
2491
|
return retryControlWrite(
|
|
1986
2492
|
() => this.withWriteLock(fn),
|
|
1987
|
-
{
|
|
2493
|
+
{
|
|
2494
|
+
...policy,
|
|
2495
|
+
...this.controlWriteRetryPacing,
|
|
2496
|
+
...(label !== undefined ? { label } : {}),
|
|
2497
|
+
// Read through the field, not captured: a listener wired (or cleared) while a write
|
|
2498
|
+
// is already in backoff still governs that write's abandonment.
|
|
2499
|
+
onAbandon: (abandonment) => this.controlWriteAbandonedListener?.(abandonment)
|
|
2500
|
+
}
|
|
1988
2501
|
);
|
|
1989
2502
|
}
|
|
1990
2503
|
|
|
@@ -2235,11 +2748,13 @@ export class ControlDatabase {
|
|
|
2235
2748
|
await this.inTransaction('redemption', async () => {
|
|
2236
2749
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
2237
2750
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
2238
|
-
// Hard-coded open + keyless
|
|
2751
|
+
// Hard-coded open + keyless + explicit null FounderOwnerKey: the
|
|
2752
|
+
// consent branch admits no other shape (no signature means no
|
|
2753
|
+
// trustworthy founder machine to record — see the schema's NOTE).
|
|
2239
2754
|
await this.db!.exec(`
|
|
2240
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
2755
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
|
|
2241
2756
|
with context OwnerKey = null, Signature = null
|
|
2242
|
-
values (?, 'o', null,
|
|
2757
|
+
values (?, 'o', null, ?, null)
|
|
2243
2758
|
`, [strandId, strandStampId]);
|
|
2244
2759
|
|
|
2245
2760
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
@@ -2459,22 +2974,19 @@ export class ControlDatabase {
|
|
|
2459
2974
|
/**
|
|
2460
2975
|
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
2461
2976
|
*
|
|
2462
|
-
*
|
|
2463
|
-
* the
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
*
|
|
2468
|
-
*
|
|
2469
|
-
*
|
|
2470
|
-
*
|
|
2471
|
-
*
|
|
2472
|
-
*
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2475
|
-
* `schemas/control.qsql`, and the engine defect in
|
|
2476
|
-
* `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
|
|
2477
|
-
* speed this up while that ticket is open.
|
|
2977
|
+
* Served by a seek through the `FormationUsageByToken` index rather than a full scan of
|
|
2978
|
+
* the table, which is append-only and grows for the life of the party.
|
|
2979
|
+
*
|
|
2980
|
+
* NOTE: this count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a
|
|
2981
|
+
* seat the invitation never paid for — and reading it through a secondary index makes the
|
|
2982
|
+
* cap depend on that index converging across machines. It did not, from 2026-08-04 to
|
|
2983
|
+
* 2026-08-25: a descent on a second machine returned only the rows that machine had
|
|
2984
|
+
* written, and the index was removed until the engine was fixed upstream (re-measured
|
|
2985
|
+
* 2026-09-17, `complete/restore-formation-usage-token-index`). The live guard is the
|
|
2986
|
+
* integration-tests scenario `strand-formation-concurrent-redemption`, which asserts both
|
|
2987
|
+
* machines' views of a raced redemption. If it fails on BOTH views again, index
|
|
2988
|
+
* convergence has regressed — fix the engine or take this read off the index, and do not
|
|
2989
|
+
* weaken that scenario's assertions to get a green run.
|
|
2478
2990
|
*
|
|
2479
2991
|
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
2480
2992
|
* locked write body — same per-call opt-out, and for the same reason, as
|