@serfab/cadre-core 1.13.0 → 1.14.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 (120) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +523 -180
  5. package/dist/cadre-node.js +921 -293
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +190 -151
  42. package/dist/membership-connection-gater.js +235 -173
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-formation-protocol.js +1 -1
  78. package/dist/strand-formation-protocol.js.map +1 -1
  79. package/dist/strand-revocation-enforcer.d.ts +11 -11
  80. package/dist/strand-revocation-enforcer.js +11 -11
  81. package/dist/strand-wake-protocol.d.ts +13 -16
  82. package/dist/strand-wake-protocol.js +13 -20
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/trusted-owner-store.d.ts +35 -5
  85. package/dist/trusted-owner-store.js +50 -8
  86. package/dist/trusted-owner-store.js.map +1 -1
  87. package/dist/types.d.ts +302 -87
  88. package/dist/types.js.map +1 -1
  89. package/package.json +8 -7
  90. package/src/cadre-invite-protocol.ts +864 -0
  91. package/src/cadre-node.ts +9381 -8686
  92. package/src/claim-proof.ts +147 -0
  93. package/src/control-authorization.ts +203 -117
  94. package/src/control-cohort.ts +1 -1
  95. package/src/control-database.ts +1216 -218
  96. package/src/control-protocol-guard.ts +348 -0
  97. package/src/control-schema.ts +812 -1078
  98. package/src/control-stream.ts +26 -7
  99. package/src/delegate-admission.ts +1 -1
  100. package/src/device-token.ts +14 -10
  101. package/src/ed25519-key.ts +28 -1
  102. package/src/index.ts +84 -10
  103. package/src/link-budget.ts +16 -32
  104. package/src/membership-connection-gater.ts +637 -570
  105. package/src/node-claim-payload.ts +125 -0
  106. package/src/owner-anchor-sync.ts +171 -0
  107. package/src/peer-authorization.ts +258 -29
  108. package/src/peer-dial.ts +22 -1
  109. package/src/peer-join-backfill.ts +56 -11
  110. package/src/peer-record.ts +16 -11
  111. package/src/pending-join-runner.ts +729 -735
  112. package/src/push-fanout.ts +8 -2
  113. package/src/seed-bootstrap.ts +344 -321
  114. package/src/seed-trust-policy.ts +353 -140
  115. package/src/strand-addr-protocol.ts +26 -35
  116. package/src/strand-formation-protocol.ts +1 -1
  117. package/src/strand-revocation-enforcer.ts +11 -11
  118. package/src/strand-wake-protocol.ts +13 -23
  119. package/src/trusted-owner-store.ts +242 -171
  120. package/src/types.ts +2471 -2242
@@ -2,8 +2,8 @@ import { Database } from '@quereus/quereus';
2
2
  import type { SqlParameters } from '@quereus/quereus';
3
3
  import type { Libp2p } from '@libp2p/interface';
4
4
  import type { IRepo } from '@optimystic/db-core';
5
- import type { StrandRow, PendingJoinRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow } from './types.js';
6
- import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
5
+ import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, SeedPeer } from './types.js';
6
+ import type { ControlTable, RevocableTable, BoundControlDomain, ControlAction, CadreInviteSignedFields } from './control-authorization.js';
7
7
  import type { ControlRetryAbandonment } from './control-retry.js';
8
8
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
9
9
  /**
@@ -50,13 +50,15 @@ export declare class FormationAbortedError extends Error {
50
50
  });
51
51
  }
52
52
  /**
53
- * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
54
- * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
55
- * seat.
53
+ * The invitation's seat budget is already spent: the count of recorded usage rows for the
54
+ * invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
55
+ * is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
56
+ * redeemed against.
56
57
  *
57
58
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
58
59
  * the write, inside {@link ControlDatabase.redeemInvitation} /
59
- * {@link ControlDatabase.recordFormationUsage}, and off the unlocked pre-check
60
+ * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
61
+ * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
60
62
  * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
61
63
  * pass — and off nothing else. It exists because
62
64
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
@@ -73,6 +75,20 @@ export declare class InvitationExhaustedError extends Error {
73
75
  readonly totalUses: number;
74
76
  constructor(token: string, usesRecorded: number, totalUses: number);
75
77
  }
78
+ /**
79
+ * {@link ControlDatabase.seatCadreInvite} was refused by `CadreInvite.AuthorizedInsert`: the
80
+ * row's `IssuerKey` is not an owner on THIS node, or its `IssuerSig` does not verify. The
81
+ * common cause is the first — the issuing owner's `OwnerKey` row has not replicated here yet,
82
+ * or that owner has since been removed — which is why the redemption protocol maps this to a
83
+ * retryable code; a tampered row fails the same way and simply never succeeds.
84
+ */
85
+ export declare class CadreInviteIssuerUnknownError extends Error {
86
+ readonly inviteKey: string;
87
+ readonly issuerKey: string;
88
+ constructor(inviteKey: string, issuerKey: string, options?: {
89
+ cause?: unknown;
90
+ });
91
+ }
76
92
  /**
77
93
  * What a recorded `FormationUsage` row came out as.
78
94
  *
@@ -90,14 +106,14 @@ export interface FormationUsageResult {
90
106
  * The message is a SINGLE framed SHA-256 digest over the ordered field vector from
91
107
  * {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
92
108
  * encoding): two fixed literals — the domain tag naming the table rule and the action
93
- * tag — followed by the row fields in the schema's fixed order, with the single-use
94
- * StampId as the final field where the table has one:
109
+ * tag — then the party id, then the row fields in the schema's fixed order, with the
110
+ * single-use StampId as the final field where the table has one:
95
111
  *
96
- * message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
112
+ * message = sha256(encodeFields([domain, action, partyId, field_1, ..., StampId])) // raw digest bytes
97
113
  *
98
114
  * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
99
115
  * verify the identical bytes with one variadic call
100
- * (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
116
+ * (`verify(digest('CadreControl.X', 'add', party_id(), field_1, ...), context.Signature, A.Key, 'ed25519')`):
101
117
  * SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
102
118
  * base64url input encoding decodes back to those raw bytes — so signer and verifier
103
119
  * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
@@ -105,11 +121,33 @@ export interface FormationUsageResult {
105
121
  * type tags agree. Binding the row contents closes captured-stamp replay; the leading
106
122
  * domain/action tags scope the signature to ONE table rule, so an approval minted for
107
123
  * one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
108
- * longer double as an OwnerKey enrollment). Single source of truth: every signed writer
109
- * (and every test/harness signer) MUST build the message through this function with the
110
- * schema's tags and field order, or `verify` will reject the row.
124
+ * longer double as an OwnerKey enrollment); the party id scopes it to ONE party, so two
125
+ * parties sharing an owner key never accept each other's approvals. `partyId` is the
126
+ * party the signer is configured for, and the SQL side reads its own through the
127
+ * node-local `party_id()` ({@link registerPartyIdFunction}). Single source of truth:
128
+ * every signed writer (and every test/harness signer) MUST build the message through
129
+ * this function with the schema's tags and field order, or `verify` will reject the row.
130
+ */
131
+ export declare function buildAuthorizationMessage(domain: BoundControlDomain, action: ControlAction, partyId: string, rowFields: string[]): Uint8Array;
132
+ /**
133
+ * Register the zero-argument scalar `party_id()` on `db`: the party id this machine is
134
+ * configured for, which every party-bound `digest(...)` in the control schema puts third.
135
+ * Node-local by design — a `context` value would be the writer's choice (a cohort member
136
+ * that re-ran the statement would replay it as a literal), and a stored `Party` table would
137
+ * be replicated data the schema already treats as untrusted. A cohort member that re-runs a
138
+ * statement therefore evaluates its OWN party id, and the schema text stays identical for
139
+ * every party. Each `Database` carries its own registration, so a process serving two
140
+ * parties holds two.
141
+ *
142
+ * NOTE: flagged deterministic, so the planner may fold the call into a plan; plans are
143
+ * per `Database` today. If Quereus ever shares plans across `Database` instances, drop
144
+ * the DETERMINISTIC flag or one party's id would be folded into another's checks.
145
+ *
146
+ * `ControlDatabase.initialize` calls this before hydrating persisted schemas, because a
147
+ * warm restart parses the persisted CHECK text, which calls it. Exported for specs that
148
+ * build their own `Database` around a copy of a bound constraint.
111
149
  */
112
- export declare function buildAuthorizationMessage(domain: ControlDomain, action: ControlAction, rowFields: string[]): Uint8Array;
150
+ export declare function registerPartyIdFunction(db: Database, partyId: string): void;
113
151
  /**
114
152
  * The exact bytes an outside approver signs to authorize ONE redemption of a
115
153
  * `ValidationUrl`-bearing `FormationInvite` — the TS mirror of the `'vouch'` digest in
@@ -125,6 +163,10 @@ export declare function buildAuthorizationMessage(domain: ControlDomain, action:
125
163
  * that same nonce, then pass BOTH the nonce and the signature to
126
164
  * {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
127
165
  * signing one nonce and inserting another fails the CHECK.
166
+ *
167
+ * No party id, unlike every owner digest: the approver is sent only these five fields
168
+ * (`formation-approval.ts`), and the `token` it binds is a random secret seated in one
169
+ * party's `FormationInvite` table (the schema's `FormationUsage.Authorized` comment).
128
170
  */
129
171
  export declare function formationVouchMessage(fields: {
130
172
  token: string;
@@ -149,7 +191,9 @@ export declare function formationVouchMessage(fields: {
149
191
  * the strand when it signs (a bound invite's host strand arrives only in the result
150
192
  * frame; an unbound strand is minted by the responder). The responder cannot
151
193
  * substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
152
- * and an unbound redemption mints a fresh strand, so there is no victim to name.
194
+ * and an unbound redemption mints a fresh strand, so there is no victim to name. The
195
+ * party id is absent for the same reason (it too arrives only in the result frame) and
196
+ * the `token` scopes the digest to one party instead, as for {@link formationVouchMessage}.
153
197
  */
154
198
  export declare function formationConsentMessage(fields: {
155
199
  token: string;
@@ -157,6 +201,37 @@ export declare function formationConsentMessage(fields: {
157
201
  peerKey: string;
158
202
  disclosure: string;
159
203
  }): Uint8Array;
204
+ /**
205
+ * The exact bytes an owner signs to seat a `CadreInvite` row — the TS mirror of the `'add'`
206
+ * digest in `CadreInvite.AuthorizedInsert`, over the whole row in the schema's order with
207
+ * nullable columns as `''` (`cadreInviteRowFields`, shared with the base64url verifier
208
+ * `peer-authorization.ts`'s `cadreInviteAddDigest`). `expiresAt` must already be the stored
209
+ * canonical `datetime` string ({@link canonicalDatetime}), as {@link ControlDatabase.insertCadreInvite}
210
+ * produces it, or the signed segment will not match what the CHECK sees after coercion.
211
+ */
212
+ export declare function cadreInviteAddMessage(partyId: string, row: CadreInviteSignedFields): Uint8Array;
213
+ /** The fields both redemption signatures cover; see {@link cadreInviteRedeemMessage}. */
214
+ export interface CadreInviteRedemptionFields {
215
+ inviteKey: string;
216
+ /** The single-use nonce the redeeming device minted for this redemption (`generateStampId`). */
217
+ usageStampId: string;
218
+ /** The redeeming device's own ed25519 public key (base64url). */
219
+ peerKey: string;
220
+ }
221
+ /**
222
+ * The exact bytes the HOLDER of a cadre invitation signs, with the invitation's private key,
223
+ * to redeem it once — the TS mirror of the `'redeem'` digest `CadreInviteUsage.InvitePossessed`
224
+ * verifies against the invitation's public key. Binding the device and the nonce means a
225
+ * captured redemption can admit no other device and spend no other use.
226
+ */
227
+ export declare function cadreInviteRedeemMessage(partyId: string, fields: CadreInviteRedemptionFields): Uint8Array;
228
+ /**
229
+ * The exact bytes the DEVICE signs, with its own key, to consent to being admitted by one
230
+ * redemption — the TS mirror of the `'consent'` digest `CadreInviteUsage.PeerConsented`
231
+ * verifies against `PeerKey`. Same fields as {@link cadreInviteRedeemMessage} under a
232
+ * distinct action tag, so neither signature can stand in for the other.
233
+ */
234
+ export declare function cadreInviteConsentMessage(partyId: string, fields: CadreInviteRedemptionFields): Uint8Array;
160
235
  /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
161
236
  type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
162
237
  /**
@@ -176,8 +251,8 @@ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
176
251
  */
177
252
  export declare function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean;
178
253
  /**
179
- * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
180
- * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
254
+ * Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
255
+ * `JoinRequest` row for this invitation? The caller adopts the existing request instead.
181
256
  * Text-matched, failing closed, like {@link isStrandIdConflict}.
182
257
  *
183
258
  * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
@@ -186,22 +261,24 @@ export declare function isStrandIdConflict(error: unknown, table: StrandIdTable)
186
261
  */
187
262
  export declare function isPendingJoinConflict(error: unknown): boolean;
188
263
  /**
189
- * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
264
+ * The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
190
265
  * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
191
266
  * the bearer token itself.
192
267
  */
193
268
  export declare function pendingJoinId(token: string): string;
269
+ /** A `JoinRequest` row as written: every column but the stamp, which each write mints. */
270
+ export type JoinRequestFields = Omit<JoinRequestRow, 'StampId'>;
271
+ /** A pending join as re-written: its request fields and its outcome, under a stamp the write mints. */
272
+ export type PendingJoinFields = Omit<PendingJoin, 'StampId'>;
194
273
  /**
195
- * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
196
- * incarnation the caller read: another write replaced or removed it first (a sibling machine
197
- * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
198
- * re-reads and decides again. `liveStampId` is null when the row is gone.
274
+ * A pending-join write found that the live join is not the one the caller read: another write
275
+ * recorded an outcome, asked again or dismissed it first. Nothing was written; the caller
276
+ * decides again from `live`, which is null when the request is gone.
199
277
  */
200
278
  export declare class PendingJoinChangedError extends Error {
201
279
  readonly id: string;
202
- readonly expectedStampId: string;
203
- readonly liveStampId: string | null;
204
- constructor(id: string, expectedStampId: string, liveStampId: string | null);
280
+ readonly live: PendingJoin | null;
281
+ constructor(id: string, live: PendingJoin | null);
205
282
  }
206
283
  /**
207
284
  * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
@@ -214,6 +291,24 @@ export declare const REVOCATION_LEDGER_MARKER: {
214
291
  readonly rowKey: "ledger";
215
292
  readonly stampId: "opened";
216
293
  };
294
+ /**
295
+ * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
296
+ * redemption reply, carries.
297
+ *
298
+ * Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
299
+ * Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
300
+ * key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
301
+ * `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
302
+ * or unparsable id yields a non-owner rather than failing the whole projection.
303
+ *
304
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
305
+ * the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
306
+ * peers first — not a trust decision, and the receiver re-derives real trust from its own
307
+ * anchor. So a polluted table costs at most a wasted dial, while anchoring here would
308
+ * silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
309
+ * anything the receiver TRUSTS, move it to the anchor.
310
+ */
311
+ export declare function projectSeedPeers(rows: readonly CadrePeerRow[], ownerKeys: ReadonlySet<string>): SeedPeer[];
217
312
  /**
218
313
  * Guarded tables a node may reap locally once their tombstone has committed — the
219
314
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
@@ -222,15 +317,17 @@ export declare const REVOCATION_LEDGER_MARKER: {
222
317
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
223
318
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
224
319
  * secret is the joined strand's shared read key, which its founding party and every member
225
- * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
320
+ * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
226
321
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
227
322
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
228
- * party-bricking hazard.
323
+ * party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
324
+ * withdrawal over a row that stays), so there is nothing to reap.
229
325
  */
230
- export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand", "PendingJoin"];
326
+ export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand", "JoinRequest"];
231
327
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
232
328
  /**
233
- * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
329
+ * Notified after a membership write (a `CadreControl.CadrePeer` or `OwnerKey` row, see
330
+ * {@link ControlDatabase.mutateCadrePeer}) has COMMITTED.
234
331
  *
235
332
  * The one hook the party-membership snapshot a node admits control-DB traffic
236
333
  * against ({@link CadreNode.refreshMembershipGate}) hangs off, so that snapshot
@@ -240,19 +337,20 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
240
337
  */
241
338
  export type MembershipChangeListener = (reason: string) => Promise<void>;
242
339
  /**
243
- * Identity of the tombstone a committed guarded delete wrote — a
244
- * {@link RevocationRow} minus its `ReissuedAt` counter, which a fresh tombstone
245
- * always seats at 0 (`FreshTombstone`) and so carries no information.
340
+ * Identity of the tombstone a committed guarded delete wrote — the triple a
341
+ * {@link RevocationRow} is keyed and signed over. The counter is left out because a
342
+ * fresh tombstone always seats it at 0 (`FreshTombstone`), and the signer pair because
343
+ * the writer that files the row supplies it.
246
344
  */
247
- export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
345
+ export type RevokedRowRef = Pick<RevocationRow, 'tableName' | 'rowKey' | 'stampId'>;
248
346
  /**
249
347
  * Notified after a guarded-table delete — and the `CadreControl.Revocation`
250
348
  * tombstone it writes in the same transaction — has COMMITTED.
251
349
  *
252
350
  * The seam the write-while-alone re-replication queue hangs off
253
- * ({@link CadreNode.noteGuardedDelete}): one listener covers all four guarded
254
- * tables (`CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`) because every
255
- * owner delete funnels through {@link deleteGuardedRow}. Synchronous — the
351
+ * ({@link CadreNode.noteGuardedDelete}): one listener covers every guarded table
352
+ * because every owner delete funnels through {@link deleteGuardedRow} (or the
353
+ * two-table `deleteStrandAndPartyKey`). Synchronous — the
256
354
  * handler only records the tombstone's identity; it must not throw (the notifier
257
355
  * swallows and logs anyway — a committed delete never fails because bookkeeping did).
258
356
  */
@@ -271,7 +369,12 @@ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
271
369
  */
272
370
  export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
273
371
  export interface ControlDatabaseConfig {
274
- /** Party ID for the control network */
372
+ /**
373
+ * Party ID for the control network. Also the party every owner and peer signature over a
374
+ * control row binds ({@link buildAuthorizationMessage}), read on the SQL side through
375
+ * `party_id()` ({@link registerPartyIdFunction}). Must be non-empty: parties with an empty
376
+ * id would share approvals.
377
+ */
275
378
  partyId: string;
276
379
  /**
277
380
  * Optional path to the control schema file.
@@ -476,17 +579,19 @@ export declare class ControlDatabase {
476
579
  /** Single-row sibling of {@link queryJoinedStrands}: the joined strand with this id, or null. */
477
580
  queryJoinedStrand(strandId: string): Promise<StrandRow | null>;
478
581
  /**
479
- * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
480
- * pending or finished.
582
+ * Every join this party asked for and has not dismissed, pending or finished: each
583
+ * `CadreControl.JoinRequest` row with its `JoinSuccess` / `JoinFailure` row.
481
584
  *
482
- * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
483
- * drops them: such a row is either a replaced or dismissed incarnation this node still
484
- * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
485
- * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
585
+ * A request whose `StampId` is retired in `Revocation` is dropped, as {@link queryCadrePeers}
586
+ * drops such rows: it is a re-asked or dismissed incarnation this node still holds until the
587
+ * reap sweep removes it, or one re-seated by a replayed approval on a node that had not yet
588
+ * seen the tombstone. Either way it must not bring a dismissed join back.
486
589
  */
487
- queryPendingJoins(): Promise<PendingJoinRow[]>;
488
- /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
489
- queryPendingJoin(id: string): Promise<PendingJoinRow | null>;
590
+ queryPendingJoins(): Promise<PendingJoin[]>;
591
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the join with this id, or null. */
592
+ queryPendingJoin(id: string): Promise<PendingJoin | null>;
593
+ /** The raw read behind {@link queryPendingJoin}, with no retired-stamp filter. `retry: false` inside a locked write body. */
594
+ private readPendingJoin;
490
595
  /**
491
596
  * Count rows in a CadreControl table as seen by THIS database instance.
492
597
  *
@@ -506,6 +611,9 @@ export declare class ControlDatabase {
506
611
  /**
507
612
  * Check whether any owner key exists in the control database.
508
613
  * Used to decide whether a fresh-party genesis insert is required.
614
+ *
615
+ * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
616
+ * insert is refused by the schema whenever any physical row exists, so this must see one.
509
617
  */
510
618
  hasOwnerKey(): Promise<boolean>;
511
619
  /**
@@ -521,8 +629,41 @@ export declare class ControlDatabase {
521
629
  * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
522
630
  * also the owner-identity source for `queryPeers`, decoupling owner
523
631
  * status from the libp2p transport peer ID.
524
- */
525
- getOwnerKeys(): Promise<Set<string>>;
632
+ *
633
+ * Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded, as
634
+ * {@link queryCadrePeers} excludes them: `OwnerKey` is never reaped (`MinOneOwner` makes
635
+ * an automated reap able to empty the table), so a node that received the tombstone
636
+ * without the delete (a removal committed while the remover was alone, re-issued on growth)
637
+ * keeps the removed owner's physical row next to it. Without the filter a removed owner
638
+ * would still count as an owner in every membership snapshot and seed-trust read, still
639
+ * read as the issuer of a live invitation and still be preferred as a dial target. The
640
+ * write side filters for itself: every owner lookup in the schema disqualifies a row whose
641
+ * stamp is retired (`schemas/control.qsql` header), so a removed owner cannot sign here
642
+ * once its tombstone has arrived, row or no row.
643
+ *
644
+ * `retry: false` from a caller that reads under the write lock, for the reason on
645
+ * {@link queryCadrePeers}.
646
+ */
647
+ getOwnerKeys(retry?: boolean): Promise<Set<string>>;
648
+ /**
649
+ * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
650
+ * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
651
+ * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
652
+ * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
653
+ * reads through this. A row with no stamp (the schema permits it; no writer produces one)
654
+ * is dropped too: no proof can bind to it, so no reader could ever derive it.
655
+ *
656
+ * `retry: false` from the membership-gate refresh, which runs as the control database's
657
+ * membership listener with that database's write lock held — the reason on
658
+ * {@link queryCadrePeers}; both reads here take the flag.
659
+ *
660
+ * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
661
+ * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
662
+ * signers); omitted, they are read here. Before the ledger marker is filed every
663
+ * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
664
+ * membership refresh at one such read for the anchor.
665
+ */
666
+ queryOwnerKeyRows(retry?: boolean, retired?: ReadonlySet<string>): Promise<OwnerKeyRow[]>;
526
667
  /**
527
668
  * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
528
669
  * Includes the persisted voucher columns, which
@@ -560,6 +701,17 @@ export declare class ControlDatabase {
560
701
  * lock is the same defect.
561
702
  */
562
703
  queryCadrePeers(retry?: boolean): Promise<CadrePeerRow[]>;
704
+ /**
705
+ * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
706
+ * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
707
+ * the replicated `OwnerKey` table ({@link projectSeedPeers}).
708
+ *
709
+ * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
710
+ * member's addresses are never packed into a seed or handed to a redeeming device as a
711
+ * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
712
+ * its exits.
713
+ */
714
+ querySeedPeers(): Promise<SeedPeer[]>;
563
715
  /**
564
716
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
565
717
  * set a `ValidationUrl` redemption's approval signature must be signed by (see
@@ -689,19 +841,22 @@ export declare class ControlDatabase {
689
841
  */
690
842
  private readRevokedStampRows;
691
843
  /**
692
- * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
693
- * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
694
- * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
695
- * it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
696
- * the composite-primary-key point-lookup hazard (see the statement comment in
844
+ * Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
845
+ * `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
846
+ * sweep, which enumerates what this node holds before {@link reissueRevocations}
847
+ * re-broadcasts it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
848
+ * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
697
849
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
698
850
  *
699
851
  * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
700
852
  * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
701
853
  * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
702
854
  * admits no other row under `'Revocation'`.
855
+ *
856
+ * `retry: false` from a caller that reads under the write lock, for the reason on
857
+ * {@link queryCadrePeers}.
703
858
  */
704
- queryRevocations(): Promise<RevocationRow[]>;
859
+ queryRevocations(retry?: boolean): Promise<RevocationRow[]>;
705
860
  /**
706
861
  * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
707
862
  *
@@ -775,6 +930,48 @@ export declare class ControlDatabase {
775
930
  * Insert the initial owner key (bootstrap - no existing owners required)
776
931
  */
777
932
  insertOwnerKey(key: string): Promise<void>;
933
+ /**
934
+ * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
935
+ * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
936
+ * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
937
+ * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
938
+ * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
939
+ *
940
+ * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
941
+ * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
942
+ * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
943
+ *
944
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
945
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
946
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
947
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
948
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
949
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
950
+ *
951
+ * @returns `true` when this call seated the row, `false` when `key` is already a live owner
952
+ * here (nothing written).
953
+ * @throws when this node holds `key`'s row under a retired stamp (above), or when the
954
+ * schema refuses the signer.
955
+ */
956
+ insertOwnerKeyVouched(key: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
957
+ /**
958
+ * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
959
+ * stamp, notifying the membership listener once it has committed. The remove branch of
960
+ * `OwnerKey.Authorized` requires a signer other than the removed key, and `MinOneOwner`
961
+ * keeps the table from emptying; both are refused by name here first, read from this
962
+ * node's live owners ({@link getOwnerKeys}), so a caller gets a clear error rather than a
963
+ * constraint failure. The schema stays the authority: a concurrent removal elsewhere can
964
+ * make the local count wrong in either direction, and then `MinOneOwner` or the tombstone's
965
+ * primary key decides (see the NOTE on {@link deleteGuardedRow}).
966
+ *
967
+ * Through {@link mutateCadrePeer}, the membership hub, which notifies whenever the body
968
+ * resolves, the absent-key answer included.
969
+ *
970
+ * @returns `true` when the row was removed, `false` when `key` is not a live owner here
971
+ * (nothing written, no tombstone).
972
+ * @throws when `key` is `ownerKey`, or is the only live owner.
973
+ */
974
+ deleteOwnerKey(key: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
778
975
  /**
779
976
  * Insert a strand into the control database using an owner signature.
780
977
  *
@@ -887,45 +1084,62 @@ export declare class ControlDatabase {
887
1084
  */
888
1085
  deleteJoinedStrand(strandId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
889
1086
  /**
890
- * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
891
- * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
892
- * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
1087
+ * Record, party-wide, a join this party asked for: a `JoinRequest` row under a fresh stamp,
1088
+ * owner-signed over every column. Returns the pending join as written.
893
1089
  *
894
- * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
895
- * this invitation; {@link isPendingJoinConflict} identifies that case.
1090
+ * Fails with a `JoinRequest.Id` uniqueness violation when the party already holds a request
1091
+ * for this invitation; {@link isPendingJoinConflict} identifies that case.
896
1092
  */
897
- insertPendingJoin(row: Omit<PendingJoinRow, 'StampId'>, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoinRow>;
1093
+ insertJoinRequest(request: JoinRequestFields, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoin>;
898
1094
  /**
899
- * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
900
- * an outcome is written, since the table refuses updates. Returns the row as written.
1095
+ * Record the outcome of the pending join `expected`: its owner-signed `JoinSuccess` /
1096
+ * `JoinFailure` row, keyed by the request's stamp. A join recorded over a failure deletes the
1097
+ * failure row in the same transaction (the schema authorizes that delete by the join, and
1098
+ * leaves the one-outcome rule in that direction to this writer). Returns the pending join as
1099
+ * written.
901
1100
  *
902
- * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
903
- * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
904
- * between. The live row must still be the incarnation the caller read: when its `StampId` is
905
- * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
906
- * writes nothing. That check is local; a sibling machine's replacement or removal that lands
907
- * between it and the commit fails the transaction instead (its tombstone for the same stamp,
908
- * or its successor row, collides on a primary key), and the caller re-reads either way.
1101
+ * The live join must still be what the caller read: the same request incarnation with the
1102
+ * same outcome kind. Otherwise this throws {@link PendingJoinChangedError} and writes nothing.
1103
+ * That check is local; a sibling machine's write landing between it and the commit fails the
1104
+ * transaction instead (its outcome row collides on the primary key, or its tombstone retires
1105
+ * the stamp), and the caller re-reads either way.
1106
+ */
1107
+ recordJoinOutcome(expected: PendingJoin, outcome: JoinOutcome, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoin>;
1108
+ /**
1109
+ * Re-write the pending join `next` (request and outcome) under a fresh stamp, retiring the
1110
+ * live request incarnation `expectedStampId`: how a finished request is asked for again, and
1111
+ * how a join written while this machine was alone is re-broadcast. One locked transaction
1112
+ * deletes the outcome row and the request (owner-signed `'remove'` digest, with its
1113
+ * `Revocation` tombstone) and inserts the successors, so no reader ever sees the id missing in
1114
+ * between. Throws {@link PendingJoinChangedError} and writes nothing when the live request is
1115
+ * not `expectedStampId`.
909
1116
  *
910
1117
  * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
911
1118
  * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
912
- * that committed but reported failure throws {@link PendingJoinChangedError} whose
913
- * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
1119
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose live join
1120
+ * carries the returned stamp, so a caller that re-reads finds its write.
914
1121
  */
915
- replacePendingJoin(expectedStampId: string, next: Omit<PendingJoinRow, 'StampId'>, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoinRow>;
1122
+ rewritePendingJoin(expectedStampId: string, next: PendingJoinFields, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<PendingJoin>;
916
1123
  /**
917
- * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
918
- * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
919
- * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
920
- * `true` when a row was actually removed.
1124
+ * Owner-signed removal of one pending join, as {@link deleteJoinedStrand} removes its row:
1125
+ * `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same transaction,
1126
+ * and the request's outcome row with it. A no-op (no throw, no tombstone) when there is no
1127
+ * request — `false` then, `true` when one was actually removed.
921
1128
  */
922
1129
  deletePendingJoin(id: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
923
1130
  /**
924
- * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
925
- * verifies, returning the row as it will be written and the parameters of
926
- * {@link PENDING_JOIN_INSERT_SQL}.
1131
+ * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
1132
+ * verifies, returning the row as it will be written and its insert.
1133
+ */
1134
+ private signJoinRequestInsert;
1135
+ /**
1136
+ * Remove the outcome row of the request incarnation `requestStampId`, whichever table holds
1137
+ * it. Unsigned: the schema authorizes the delete by the tombstone retiring that stamp, filed
1138
+ * in the same transaction or already committed (a reap).
927
1139
  */
928
- private signPendingJoinInsert;
1140
+ private deleteJoinOutcomes;
1141
+ /** One unsigned outcome delete (see {@link deleteJoinOutcomes}; a failure's is also authorized by the join superseding it). */
1142
+ private deleteJoinOutcome;
929
1143
  /**
930
1144
  * Insert a validation key into the control database using an owner signature.
931
1145
  *
@@ -1033,6 +1247,11 @@ export declare class ControlDatabase {
1033
1247
  * change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
1034
1248
  * beats a per-method exception the next reader has to relearn.
1035
1249
  *
1250
+ * Also clears `VouchUsage`: a row carries exactly one proof (the owner branch of
1251
+ * `AuthorizedUpdate` requires it null), so re-vouching an invitation-admitted row moves it
1252
+ * onto the signature and its invitation is no longer consulted for it — the way an owner
1253
+ * retires a device's dependency on an invitation it may later withdraw.
1254
+ *
1036
1255
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
1037
1256
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
1038
1257
  * reader's node-local anchor. Benign today because the only caller — the write-while-alone
@@ -1089,15 +1308,9 @@ export declare class ControlDatabase {
1089
1308
  * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
1090
1309
  * stamp, in ONE transaction. The single body behind EVERY guarded delete —
1091
1310
  * {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
1092
- * {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
1093
- * of those is a thin named wrapper, so callers never pass a table name and this
1094
- * generic shape stays off the public surface.
1095
- *
1096
- * `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
1097
- * production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
1098
- * requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
1099
- * it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
1100
- * needs no change.
1311
+ * {@link deleteDeviceToken}, {@link deleteOwnerKey}; see any of them for the per-table
1312
+ * security rationale. Each of those is a thin named wrapper, so callers never pass a
1313
+ * table name and this generic shape stays off the public surface.
1101
1314
  *
1102
1315
  * The row's CURRENT stamp is read first and signed over, so the remove digest binds to
1103
1316
  * this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
@@ -1105,6 +1318,9 @@ export declare class ControlDatabase {
1105
1318
  * second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
1106
1319
  * which must not fire for a no-op).
1107
1320
  *
1321
+ * `alongside`, when given, runs inside the transaction before the removal, for a table whose
1322
+ * row has dependants the schema requires removed with it ({@link deletePendingJoin}).
1323
+ *
1108
1324
  * NOTE: the stamp read is outside the transaction. A concurrent writer that removes
1109
1325
  * the row in between makes the signature bind a stamp that is no longer live; the
1110
1326
  * delete then matches nothing and the tombstone insert collides with the other
@@ -1119,7 +1335,7 @@ export declare class ControlDatabase {
1119
1335
  /**
1120
1336
  * Delete the row incarnation `removal` names and file its tombstone — the statement pair
1121
1337
  * every owner removal of a guarded row runs ({@link deleteGuardedRow},
1122
- * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
1338
+ * {@link deleteStrandAndPartyKey}, {@link rewritePendingJoin}). The caller supplies the
1123
1339
  * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
1124
1340
  * tombstone commits with it.
1125
1341
  *
@@ -1130,6 +1346,21 @@ export declare class ControlDatabase {
1130
1346
  * come from closed literal unions — no caller-supplied string reaches the statement.
1131
1347
  */
1132
1348
  private execGuardedRemoval;
1349
+ /**
1350
+ * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
1351
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
1352
+ * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
1353
+ * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
1354
+ * lock and, where one is needed, the transaction.
1355
+ *
1356
+ * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
1357
+ * than leaning on the column default — the seat-at-zero rule is load-bearing for
1358
+ * {@link reissueRevocations}' monotonic bump, so state it at the write site.
1359
+ *
1360
+ * `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
1361
+ * equal), so a node that receives the row by replication can tell which owner filed it.
1362
+ */
1363
+ private execTombstone;
1133
1364
  /**
1134
1365
  * Fire the guarded-delete listener for one committed tombstone, swallowing (and
1135
1366
  * logging) a listener throw — a committed delete never fails because bookkeeping did.
@@ -1220,7 +1451,7 @@ export declare class ControlDatabase {
1220
1451
  * reads from this node (every other node already filters it by tombstone). A revoked
1221
1452
  * node therefore keeps its own copy of its own row; "a node that learns it has been
1222
1453
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
1223
- * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
1454
+ * `ValidationKey`, `JoinedStrand` and `JoinRequest` have no self notion and are not
1224
1455
  * special-cased.
1225
1456
  *
1226
1457
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
@@ -1362,15 +1593,18 @@ export declare class ControlDatabase {
1362
1593
  */
1363
1594
  setControlWriteAbandonedListener(listener: ControlWriteAbandonedListener | null): void;
1364
1595
  /**
1365
- * Run a `CadrePeer` row mutation and notify the membership listener once it has
1596
+ * Run a membership-relevant mutation and notify the membership listener once it has
1366
1597
  * COMMITTED.
1367
1598
  *
1368
- * EVERY `CadrePeer` writer goes through here, with one documented exception
1369
- * ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
1370
- * the party-membership snapshot refresh automatic rather than a caller obligation. A
1599
+ * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
1600
+ * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
1601
+ * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
1602
+ * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
1603
+ * which seats both. That is what makes the party-membership snapshot refresh automatic
1604
+ * rather than a caller obligation. The name predates the `OwnerKey` writers. A
1371
1605
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
1372
- * event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
1373
- * `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
1606
+ * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
1607
+ * builds, and services constructed outside `CadreNode` entirely, never get
1374
1608
  * callbacks wired), so this is the one point on the write path that cannot be bypassed.
1375
1609
  *
1376
1610
  * `body` owns its own transaction if it needs one and must COMMIT before returning, so
@@ -1769,6 +2003,189 @@ export declare class ControlDatabase {
1769
2003
  * unlimited-use invite anywhere in the scan short-circuits all of them.
1770
2004
  */
1771
2005
  hasOutstandingFormationInvite(nowMs?: number): Promise<boolean>;
2006
+ /**
2007
+ * Mint and insert an owner-signed `CadreInvite`: an invitation to join THIS cadre that any
2008
+ * member machine can later redeem on the owner's behalf ({@link redeemCadreInvite}).
2009
+ *
2010
+ * The owner signs the whole row ({@link cadreInviteAddMessage}) — `ExpiresAt` through
2011
+ * {@link canonicalDatetime} so the signed segment byte-matches the stored `datetime`, as
2012
+ * {@link insertFormationInvite} does — and the stored `IssuerKey`/`IssuerSig` are pinned to
2013
+ * that signer by `CadreInvite.AuthorizedInsert`. The signature is part of the row because
2014
+ * the row travels: the returned {@link CadreInviteRow} is what the invitation bundle
2015
+ * carries to the holder, and {@link seatCadreInvite} re-inserts it verbatim on a member
2016
+ * that has not received it by replication.
2017
+ *
2018
+ * `key` is the invitation's ed25519 PUBLIC key; the caller minted the pair and hands the
2019
+ * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
2020
+ * same shape).
2021
+ *
2022
+ * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
2023
+ * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
2024
+ * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
2025
+ * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
2026
+ * @param invite.totalUses - how many redemptions it allows; omit for unlimited
2027
+ * @returns the row as stored, `expiresAt` in its canonical form
2028
+ */
2029
+ insertCadreInvite(invite: {
2030
+ key: string;
2031
+ peerId?: string | null;
2032
+ grantsOwner: boolean;
2033
+ expiresAtMs?: number | null;
2034
+ totalUses?: number | null;
2035
+ }, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<CadreInviteRow>;
2036
+ /**
2037
+ * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
2038
+ * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
2039
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
2040
+ *
2041
+ * @returns `true` when this call inserted the row, `false` when it was already present.
2042
+ * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
2043
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
2044
+ * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
2045
+ */
2046
+ seatCadreInvite(row: CadreInviteRow): Promise<boolean>;
2047
+ /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
2048
+ private execCadreInviteInsert;
2049
+ /**
2050
+ * Read one `CadreInvite` row by key, or null when absent. Withdrawn rows are returned too —
2051
+ * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
2052
+ * question, and `CadreInviteUsage.Authorized`'s at redemption.
2053
+ *
2054
+ * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
2055
+ * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
2056
+ */
2057
+ queryCadreInvite(key: string, retry?: boolean): Promise<CadreInviteRow | null>;
2058
+ /**
2059
+ * Every `CadreInvite` row this node holds, withdrawn and expired ones included (see
2060
+ * {@link queryCadreInvite}). Read by the membership predicate when at least one `CadrePeer`
2061
+ * row is invitation-admitted; `retry: false` is forwarded from
2062
+ * {@link CadreNode.listAuthorizedMembers} on the membership-gate refresh path for
2063
+ * {@link queryCadrePeers}' reason.
2064
+ */
2065
+ queryCadreInvites(retry?: boolean): Promise<CadreInviteRow[]>;
2066
+ /**
2067
+ * Every `CadreInviteUsage` row this node holds — the redemption records the membership
2068
+ * predicate verifies invitation-admitted rows against. Same `retry` contract as
2069
+ * {@link queryCadreInvites}.
2070
+ */
2071
+ queryCadreInviteUsages(retry?: boolean): Promise<CadreInviteUsageRow[]>;
2072
+ /**
2073
+ * How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
2074
+ * through the `CadreInviteUsageByInvite` index. As {@link countFormationUsage}: a permissive
2075
+ * PRE-check, not the cap — the cap is `CadreInviteUsage.Authorized`'s own committed count.
2076
+ *
2077
+ * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
2078
+ */
2079
+ countCadreInviteUsage(key: string, retry?: boolean): Promise<number>;
2080
+ /**
2081
+ * Is any cadre invitation still redeemable — not withdrawn, unexpired, with uses below its
2082
+ * `TotalUses`, and issued by a current owner? The conditions `CadreInviteUsage.Authorized`
2083
+ * applies, read ahead of time so the control-network connection gate can answer "does this
2084
+ * node expect a stranger?" with no key to ask about, exactly as
2085
+ * {@link hasOutstandingFormationInvite} answers it for strand formation. The expiry
2086
+ * comparison is `expiresAtMs <= now`, so an invitation the redemption would refuse never
2087
+ * holds the gate open.
2088
+ *
2089
+ * Withdrawal is read through {@link queryRevokedStamps}, the per-table retired-stamp seam
2090
+ * every other revocation-aware reader uses (and the one an isolated node can still answer).
2091
+ */
2092
+ hasLiveCadreInvite(nowMs?: number): Promise<boolean>;
2093
+ /**
2094
+ * Is this one cadre invitation still redeemable here — the conditions
2095
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
2096
+ * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
2097
+ * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
2098
+ * judged by whichever owner it names as issuer. A permissive pre-check like
2099
+ * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
2100
+ * what enforce every condition.
2101
+ */
2102
+ isCadreInviteLive(invite: CadreInviteRow, nowMs?: number): Promise<boolean>;
2103
+ /**
2104
+ * Every `CadreInvite` row this node holds with its standing: how many redemptions are
2105
+ * recorded, whether it is withdrawn, and whether it is still redeemable — the same
2106
+ * conditions {@link hasLiveCadreInvite} applies, answered per row for an owner's listing
2107
+ * rather than short-circuited. One usage count per row, so this is the owner's
2108
+ * occasional read, not a gate's.
2109
+ */
2110
+ listCadreInviteStatuses(nowMs?: number): Promise<CadreInviteStatus[]>;
2111
+ /**
2112
+ * Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
2113
+ * stamp, with the row kept. The row stays because every device the invitation admitted
2114
+ * proves its membership through it (`CadrePeer.AuthorizedInsert`'s consent branch,
2115
+ * `verifyInvitationAdmission`); `CadreInvite.NoDelete` refuses a delete, and
2116
+ * `Revocation.RowIsGone` admits a `'CadreInvite'` tombstone while the row lives. Afterwards
2117
+ * `CadreInviteUsage.Authorized` refuses every further redemption, and `CadreInvite.NotRevoked`
2118
+ * refuses re-seating the row from the holder's signed copy on any node holding the
2119
+ * tombstone.
2120
+ *
2121
+ * Idempotent: a no-op (no throw, nothing written) when the row is absent locally or already
2122
+ * withdrawn — `false` then, `true` when this call filed the tombstone. The owner must hold the
2123
+ * row to withdraw it, since the tombstone names the row's stamp.
2124
+ *
2125
+ * Fires the guarded-delete listener like a removal does, so a withdrawal committed while the
2126
+ * node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
2127
+ */
2128
+ withdrawCadreInvite(key: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
2129
+ /**
2130
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
2131
+ * when the invitation grants ownership, as an owner — with NO owner signature. One
2132
+ * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
2133
+ * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
2134
+ * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
2135
+ * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
2136
+ * redemption and none can be held in reserve. The invitation must already be committed
2137
+ * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
2138
+ *
2139
+ * The two signatures are the holder's and the device's, minted on the device over
2140
+ * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
2141
+ * verifies them (by writing the row). `peerId` must be the identity multihash of `peerKey`:
2142
+ * the schema cannot derive it, so a writer asserts the pair and every reader re-derives it
2143
+ * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
2144
+ *
2145
+ * Idempotent for a retry after a dropped reply: when the device's row is already present
2146
+ * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
2147
+ * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
2148
+ * owner-granting invitation is completed: the usage row then names the existing peer stamp.
2149
+ * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
2150
+ * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
2151
+ *
2152
+ * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
2153
+ * every read passes `retry: false` and every stamp is minted per attempt, so a
2154
+ * {@link lockedWithRetry} re-run starts clean.
2155
+ *
2156
+ * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
2157
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
2158
+ */
2159
+ redeemCadreInvite(params: {
2160
+ inviteKey: string;
2161
+ peerId: string;
2162
+ /** The device's ed25519 public key (base64url); `peerId` is its identity multihash. */
2163
+ peerKey: string;
2164
+ /** The device's dialable addresses, if known; stored comma-joined as `authorizePeer` does. */
2165
+ multiaddrs?: string[];
2166
+ /** Single-use nonce minted by the device; both signatures cover it. */
2167
+ usageStampId: string;
2168
+ /** The holder's signature with the invitation private key over the `'redeem'` digest. */
2169
+ inviteSig: string;
2170
+ /** The device's signature with its own key over the `'consent'` digest. */
2171
+ peerSig: string;
2172
+ nowMs?: number;
2173
+ }): Promise<CadreInviteRedemptionResult>;
2174
+ /**
2175
+ * The rows a redemption would seat that are already present: the device's `CadrePeer`
2176
+ * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
2177
+ * reads ({@link queryStampId}), so a physically present row counts; inside the locked
2178
+ * body, so never retried.
2179
+ */
2180
+ private readSeatedAdmission;
2181
+ /**
2182
+ * Refuse a redemption that would consume a seat the cadre invitation does not have, by
2183
+ * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
2184
+ * {@link assertSeatRemains}, for the same reason: the schema's cap clause refuses as a
2185
+ * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
2186
+ * committed count inside the locked body, so on a same-node race the loser is refused here.
2187
+ */
2188
+ private assertCadreInviteSeatRemains;
1772
2189
  /**
1773
2190
  * Close the database and cleanup resources.
1774
2191
  *