@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
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * The pending-join retry loop: carries on a join this party asked for through another party's
3
- * invitation (`CadreControl.PendingJoin`, one row per invitation) in the background, on every
4
- * owner machine of the party, until the join works, the invitation is used up or expires, or
5
- * the user dismisses it. The behaviour is described in `docs/strands.md` → "Joining while the
6
- * inviter is offline".
3
+ * invitation (`CadreControl.JoinRequest`, one row per invitation, with its outcome in
4
+ * `JoinSuccess` / `JoinFailure`) in the background, on every owner machine of the party, until
5
+ * the join works, the invitation is used up or expires, or the user dismisses it. The behaviour
6
+ * is described in `docs/strands.md` → "Joining while the inviter is offline".
7
7
  *
8
8
  * `CadreNode` owns every side effect and hands this module plain functions
9
- * ({@link PendingJoinRunnerDeps}): read the rows, run one attempt (its own `formStrand`), write
9
+ * ({@link PendingJoinRunnerDeps}): read the joins, run one attempt (its own `formStrand`), write
10
10
  * an outcome, say whether this machine is an owner, and the clock. This module owns the policy:
11
11
  * when to attempt, how to read an attempt's failure, and how to settle an outcome write that
12
12
  * another machine's write beat.
13
13
  *
14
- * Only owner machines run it, because every `PendingJoin` write is owner-signed. Letting a
14
+ * Only owner machines run it, because every pending-join write is owner-signed. Letting a
15
15
  * machine without an owner key finish a join is `blocked/decide-non-owner-machine-completes-a-pending-join`;
16
16
  * that changes {@link PendingJoinRunnerDeps.isOwner} and the write functions, not this loop.
17
17
  */
@@ -28,7 +28,7 @@ export const PENDING_JOIN_POLL_MS = 30000;
28
28
  /** Ceiling on the wait between two attempts of one join on one machine, before jitter. */
29
29
  export const PENDING_JOIN_MAX_BACKOFF_MS = 10 * 60000;
30
30
  /**
31
- * The longest a join is tried for. A row's `ExpiresAt` is the earlier of this and the
31
+ * The longest a join is tried for. A request's `ExpiresAt` is the earlier of this and the
32
32
  * invitation's own expiration, which the inviter chose.
33
33
  */
34
34
  export const MAX_PENDING_JOIN_MS = 30 * 24 * 3600000;
@@ -40,21 +40,21 @@ const RETRY_JITTER = 0.2;
40
40
  const MAX_OUTCOME_WRITE_ROUNDS = 3;
41
41
  /**
42
42
  * A `joined` outcome replaces anything still live, since a join that happened cannot be undone
43
- * by another machine's failure. A failure replaces a pending row of the same request: only a
44
- * re-issue of a row written alone writes one, unchanged, so it decides nothing. Any other live
45
- * row wins, and a fresh request is then decided again by the loop.
43
+ * by another machine's failure. A failure replaces a pending request of the same ask: only a
44
+ * re-issue of a request written alone writes one, unchanged, so it decides nothing. Any other
45
+ * live join wins, and a fresh request is then decided again by the loop.
46
46
  */
47
- function resolveLostWrite(live, next) {
47
+ function resolveLostWrite(live, expected, next) {
48
48
  if (live === null)
49
49
  return 'gone';
50
- if (live.Outcome === 'joined')
50
+ if (live.outcome?.kind === 'joined')
51
51
  return 'adopt';
52
- if (next.Outcome === 'joined')
52
+ if (next.kind === 'joined')
53
53
  return 'rewrite';
54
- return live.Outcome === null && live.RequestedAt === next.RequestedAt ? 'rewrite' : 'adopt';
54
+ return live.outcome === null && live.RequestedAt === expected.RequestedAt ? 'rewrite' : 'adopt';
55
55
  }
56
56
  /**
57
- * The pending row a `requestJoin` writes: tried until the invitation's own expiration or
57
+ * The request a `requestJoin` writes: tried until the invitation's own expiration or
58
58
  * {@link MAX_PENDING_JOIN_MS} from now, whichever comes first.
59
59
  */
60
60
  export function requestedPendingJoin(id, encodedInvitation, disclosure, invitationExpiresAt, now) {
@@ -64,23 +64,17 @@ export function requestedPendingJoin(id, encodedInvitation, disclosure, invitati
64
64
  Disclosure: canonicalJson(disclosure),
65
65
  RequestedAt: now,
66
66
  ExpiresAt: Math.min(invitationExpiresAt, now + MAX_PENDING_JOIN_MS),
67
- Outcome: null,
68
- OutcomeAt: null,
69
- StrandId: null,
70
- MembershipInvite: null,
71
- FailureCode: null,
72
- FailureReason: null,
73
67
  };
74
68
  }
75
- /** A row's stored disclosure, as the next attempt sends it. Owner-signed, so anything but an object is a bug. */
76
- export function parseStoredDisclosure(row) {
77
- const parsed = JSON.parse(row.Disclosure);
69
+ /** A request's stored disclosure, as the next attempt sends it. Owner-signed, so anything but an object is a bug. */
70
+ export function parseStoredDisclosure(join) {
71
+ const parsed = JSON.parse(join.Disclosure);
78
72
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
79
- throw new Error(`PendingJoin ${row.Id}: the stored disclosure is not a JSON object`);
73
+ throw new Error(`JoinRequest ${join.Id}: the stored disclosure is not a JSON object`);
80
74
  }
81
75
  return parsed;
82
76
  }
83
- /** A `joined` row's `MembershipInvite` column, or null when it is not the `{inviteKey, invitePrivateKey}` JSON the loop writes. */
77
+ /** A `JoinSuccess` row's `MembershipInvite` column, or null when it is not the `{inviteKey, invitePrivateKey}` JSON the loop writes. */
84
78
  export function parseStoredMembershipInvite(text) {
85
79
  let parsed;
86
80
  try {
@@ -95,61 +89,51 @@ export function parseStoredMembershipInvite(text) {
95
89
  return typeof inviteKey === 'string' && typeof invitePrivateKey === 'string' ? { inviteKey, invitePrivateKey } : null;
96
90
  }
97
91
  /**
98
- * The membership invitations `joined` rows carry that this process has not staged yet, so a
92
+ * The membership invitations joined requests carry that this process has not staged yet, so a
99
93
  * closed strand joined on one owner machine can be seated by whichever machine launches it
100
- * first. Rows older than {@link MEMBERSHIP_INVITE_TTL_MS} are skipped: their invitation is dead.
94
+ * first. Joins older than {@link MEMBERSHIP_INVITE_TTL_MS} are skipped: their invitation is dead.
101
95
  * `alreadyStaged` holds invite keys; a malformed column is skipped and logged.
102
96
  */
103
- export function membershipInvitesToStage(rows, alreadyStaged, now) {
97
+ export function membershipInvitesToStage(joins, alreadyStaged, now) {
104
98
  const toStage = [];
105
- for (const row of rows) {
106
- if (row.Outcome !== 'joined' || row.StrandId === null || row.MembershipInvite === null || row.OutcomeAt === null)
99
+ for (const join of joins) {
100
+ const { outcome } = join;
101
+ if (outcome?.kind !== 'joined' || outcome.MembershipInvite === null)
107
102
  continue;
108
- if (now >= row.OutcomeAt + MEMBERSHIP_INVITE_TTL_MS)
103
+ if (now >= outcome.RecordedAt + MEMBERSHIP_INVITE_TTL_MS)
109
104
  continue;
110
- const invite = parseStoredMembershipInvite(row.MembershipInvite);
105
+ const invite = parseStoredMembershipInvite(outcome.MembershipInvite);
111
106
  if (!invite) {
112
- log('pending join %s: its MembershipInvite column does not parse; not staged', row.Id);
107
+ log('pending join %s: its MembershipInvite column does not parse; not staged', join.Id);
113
108
  continue;
114
109
  }
115
110
  if (!alreadyStaged.has(invite.inviteKey)) {
116
- toStage.push({ rowId: row.Id, strandId: row.StrandId, invite });
111
+ toStage.push({ rowId: join.Id, strandId: outcome.StrandId, invite });
117
112
  }
118
113
  }
119
114
  return toStage;
120
115
  }
121
- /** The row's columns without its stamp, as a replacement is written. */
122
- function fieldsOf(row) {
123
- const { StampId: _stampId, ...fields } = row;
116
+ /** The join without its stamp, as a rewrite takes it. */
117
+ function fieldsOf(join) {
118
+ const { StampId: _stampId, ...fields } = join;
124
119
  return fields;
125
120
  }
126
- function joinedFields(row, result, now) {
121
+ function joinedOutcome(result, now) {
127
122
  const invite = result.membershipInvite;
128
123
  return {
129
- ...fieldsOf(row),
130
- Outcome: 'joined',
131
- OutcomeAt: now,
124
+ kind: 'joined',
125
+ RecordedAt: now,
132
126
  StrandId: result.strandId,
133
127
  MembershipInvite: invite ? JSON.stringify({ inviteKey: invite.inviteKey, invitePrivateKey: invite.invitePrivateKey }) : null,
134
- FailureCode: null,
135
- FailureReason: null,
136
128
  };
137
129
  }
138
- function failedFields(row, code, reason, now) {
139
- return {
140
- ...fieldsOf(row),
141
- Outcome: 'failed',
142
- OutcomeAt: now,
143
- StrandId: null,
144
- MembershipInvite: null,
145
- FailureCode: code,
146
- FailureReason: reason,
147
- };
130
+ function failedOutcome(code, reason, now) {
131
+ return { kind: 'failed', RecordedAt: now, Code: code, Reason: reason };
148
132
  }
149
133
  function errorText(error) {
150
134
  return error instanceof Error ? error.message : String(error);
151
135
  }
152
- /** A fraction in [0, 1) fixed by `key` (32-bit FNV-1a): the same machine staggers the same row the same way every time. */
136
+ /** A fraction in [0, 1) fixed by `key` (32-bit FNV-1a): the same machine staggers the same join the same way every time. */
153
137
  function stableFraction(key) {
154
138
  let hash = 0x811c9dc5;
155
139
  for (let i = 0; i < key.length; i++) {
@@ -162,7 +146,7 @@ export class PendingJoinRunner {
162
146
  constructor(deps) {
163
147
  this.deps = deps;
164
148
  this.tracked = new Map();
165
- /** Rows whose latest write by this machine committed with no control connection: id → the stamp written. */
149
+ /** Joins whose latest write by this machine committed with no control connection: id → the request stamp written. */
166
150
  this.writtenAlone = new Map();
167
151
  this.dueQueue = [];
168
152
  this.running = false;
@@ -187,8 +171,8 @@ export class PendingJoinRunner {
187
171
  * Stop scheduling. Clears every timer and starts no new attempt.
188
172
  *
189
173
  * NOTE: an attempt already in flight is not awaited, because `formStrand` takes no abort
190
- * signal. An approval that lands while the node stops can lose its local records; the row then
191
- * stays pending, and the next start's attempt answers `token-spent`, which fails the row after
174
+ * signal. An approval that lands while the node stops can lose its local records; the request
175
+ * then stays pending, and the next start's attempt answers `token-spent`, which fails it after
192
176
  * its confirming attempt. Honest and bounded; if it shows up in practice, give `formStrand` a
193
177
  * signal and abort it here.
194
178
  */
@@ -209,44 +193,49 @@ export class PendingJoinRunner {
209
193
  void this.pass();
210
194
  }
211
195
  /**
212
- * Try `row` on this machine at once, outside the background slots, and report the status
213
- * after the attempt. Joins an attempt already running for the row.
196
+ * Try `join` on this machine at once, outside the background slots, and report the status
197
+ * after the attempt. Joins an attempt already running for it.
214
198
  */
215
- async attemptNow(row) {
216
- const entry = this.adopt(row);
217
- if (entry.row.Outcome === null) {
199
+ async attemptNow(join) {
200
+ const entry = this.adopt(join);
201
+ if (entry.row.outcome === null) {
218
202
  await this.startAttempt(entry);
219
203
  }
220
204
  return this.describe(entry.row, entry);
221
205
  }
222
- /** This machine's view of `row`: the row's own state, with this machine's attempt state laid over a pending row. */
223
- statusOf(row) {
224
- return this.describe(row, this.tracked.get(row.Id));
206
+ /** This machine's view of `join`: its own state, with this machine's attempt state laid over a pending one. */
207
+ statusOf(join) {
208
+ return this.describe(join, this.tracked.get(join.Id));
225
209
  }
226
- /** Record a `PendingJoin` write this machine made outside the loop (a `requestJoin`), for {@link reissueWritesMadeAlone}. */
227
- noteWritten(row) {
210
+ /**
211
+ * Record a pending-join write this machine made, for {@link reissueWritesMadeAlone}. A write
212
+ * made alone marks the join. A request write made connected clears the mark, since it
213
+ * carried the request; an outcome write made connected does not, since the request it names
214
+ * may still be unknown to the other machines.
215
+ */
216
+ noteWritten(join, write) {
228
217
  if (this.deps.isAlone()) {
229
- this.writtenAlone.set(row.Id, row.StampId);
218
+ this.writtenAlone.set(join.Id, join.StampId);
230
219
  }
231
- else {
232
- this.writtenAlone.delete(row.Id);
220
+ else if (write === 'request') {
221
+ this.writtenAlone.delete(join.Id);
233
222
  }
234
223
  }
235
- /** Stop tracking a row the user dismissed. An attempt in flight finishes; its outcome write then finds the row gone. */
224
+ /** Stop tracking a join the user dismissed. An attempt in flight finishes; its outcome write then finds the request gone. */
236
225
  forgetRow(id) {
237
226
  const entry = this.tracked.get(id);
238
227
  if (entry)
239
228
  this.forget(entry);
240
229
  }
241
230
  /**
242
- * Re-write, with identical content under a fresh stamp, each row this machine last wrote while
231
+ * Re-write, with identical content under a fresh stamp, each join this machine last wrote while
243
232
  * it had no control connection, so the other machines receive it. Called on the control
244
- * connection growth edge. A row someone wrote since is left alone.
233
+ * connection growth edge. A join someone wrote since is left alone.
245
234
  *
246
- * NOTE: in memory only, so a row written alone by a process that stopped before it reconnected
247
- * reaches the party only with that row's next write. A sweep of every row this machine wrote,
235
+ * NOTE: in memory only, so a join written alone by a process that stopped before it reconnected
236
+ * reaches the party only with that join's next write. A sweep of every join this machine wrote,
248
237
  * on the first connection after start, would cover that, at the cost of one permanent
249
- * `Revocation` tombstone per row per start. Revisit if other machines are seen missing such rows.
238
+ * `Revocation` tombstone per join per start. Revisit if other machines are seen missing such joins.
250
239
  */
251
240
  async reissueWritesMadeAlone() {
252
241
  if (this.writtenAlone.size === 0)
@@ -262,15 +251,15 @@ export class PendingJoinRunner {
262
251
  this.writtenAlone.delete(id);
263
252
  continue;
264
253
  }
265
- const written = await this.deps.replace(stampId, fieldsOf(live));
266
- this.noteWritten(written);
254
+ const written = await this.deps.rewrite(stampId, fieldsOf(live));
255
+ this.noteWritten(written, 'request');
267
256
  const entry = this.tracked.get(id);
268
257
  if (entry?.row.StampId === stampId)
269
258
  entry.row = written;
270
- log('pending join %s: re-issued the row written while alone', id);
259
+ log('pending join %s: re-issued the join written while alone', id);
271
260
  }
272
261
  catch (error) {
273
- log('pending join %s: re-issuing the row written while alone failed; kept for the next connection: %s', id, errorText(error));
262
+ log('pending join %s: re-issuing the join written while alone failed; kept for the next connection: %s', id, errorText(error));
274
263
  }
275
264
  }
276
265
  }
@@ -317,23 +306,23 @@ export class PendingJoinRunner {
317
306
  this.pauseAttempts();
318
307
  return;
319
308
  }
320
- const rows = await this.deps.readRows();
309
+ const joins = await this.deps.readRows();
321
310
  if (!this.running)
322
311
  return;
323
- this.deps.observeRows(rows);
324
- const live = new Set(rows.map((row) => row.Id));
312
+ this.deps.observeRows(joins);
313
+ const live = new Set(joins.map((join) => join.Id));
325
314
  for (const entry of [...this.tracked.values()]) {
326
315
  if (!live.has(entry.row.Id) && !entry.attempt)
327
316
  this.forget(entry);
328
317
  }
329
- for (const row of rows) {
318
+ for (const join of joins) {
330
319
  if (!this.running)
331
320
  return;
332
321
  try {
333
- await this.consider(row);
322
+ await this.consider(join);
334
323
  }
335
324
  catch (error) {
336
- log('pending join %s: this pass could not handle it; the next one retries: %s', row.Id, errorText(error));
325
+ log('pending join %s: this pass could not handle it; the next one retries: %s', join.Id, errorText(error));
337
326
  }
338
327
  }
339
328
  }
@@ -347,12 +336,12 @@ export class PendingJoinRunner {
347
336
  entry.queued = false;
348
337
  this.dueQueue.length = 0;
349
338
  }
350
- /** Bring one read row into this machine's schedule. */
351
- async consider(row) {
352
- const entry = this.adopt(row);
339
+ /** Bring one read join into this machine's schedule. */
340
+ async consider(join) {
341
+ const entry = this.adopt(join);
353
342
  if (entry.attempt)
354
343
  return;
355
- if (entry.row.Outcome !== null) {
344
+ if (entry.row.outcome !== null) {
356
345
  this.emitIfChanged(entry);
357
346
  await this.ageOut(entry);
358
347
  return;
@@ -367,28 +356,28 @@ export class PendingJoinRunner {
367
356
  this.emitIfChanged(entry);
368
357
  }
369
358
  /**
370
- * The tracked entry for `row`, updated to it. A row asked for again (`RequestedAt` changed)
371
- * starts this machine's counting over; a finished row stops its schedule.
359
+ * The tracked entry for `join`, updated to it. A join asked for again (`RequestedAt` changed)
360
+ * starts this machine's counting over; a finished join stops its schedule.
372
361
  */
373
- adopt(row) {
374
- const entry = this.tracked.get(row.Id);
362
+ adopt(join) {
363
+ const entry = this.tracked.get(join.Id);
375
364
  if (!entry) {
376
- const fresh = { row, failures: 0, queued: false, trying: false, spentOnce: false };
377
- this.tracked.set(row.Id, fresh);
365
+ const fresh = { row: join, failures: 0, queued: false, trying: false, spentOnce: false };
366
+ this.tracked.set(join.Id, fresh);
378
367
  return fresh;
379
368
  }
380
- if (entry.row.StampId === row.StampId)
369
+ if (entry.row.StampId === join.StampId && entry.row.outcome?.kind === join.outcome?.kind)
381
370
  return entry;
382
- if (row.Outcome !== null || entry.row.RequestedAt !== row.RequestedAt) {
371
+ if (join.outcome !== null || entry.row.RequestedAt !== join.RequestedAt) {
383
372
  this.cancelTimer(entry);
384
373
  entry.failures = 0;
385
374
  entry.spentOnce = false;
386
375
  entry.lastError = undefined;
387
376
  entry.nextAttemptAt = undefined;
388
377
  }
389
- if (row.Outcome === 'joined')
378
+ if (join.outcome?.kind === 'joined')
390
379
  entry.joinedHere = undefined;
391
- entry.row = row;
380
+ entry.row = join;
392
381
  return entry;
393
382
  }
394
383
  scheduleAttempt(entry, delayMs) {
@@ -396,8 +385,8 @@ export class PendingJoinRunner {
396
385
  return;
397
386
  this.cancelTimer(entry);
398
387
  const now = this.now();
399
- // A row already past its expiry keeps the full delay: its due attempt is an outcome write,
400
- // and clamping to a past expiry would retry a failing write with no wait at all.
388
+ // A request already past its expiry keeps the full delay: its due attempt is an outcome
389
+ // write, and clamping to a past expiry would retry a failing write with no wait at all.
401
390
  const at = now < entry.row.ExpiresAt ? Math.min(now + delayMs, entry.row.ExpiresAt) : now + delayMs;
402
391
  entry.nextAttemptAt = at;
403
392
  entry.timer = this.scheduler.setTimeout(() => {
@@ -448,7 +437,7 @@ export class PendingJoinRunner {
448
437
  .catch((error) => {
449
438
  entry.trying = false;
450
439
  log('pending join %s: attempt failed on this machine: %s', entry.row.Id, errorText(error));
451
- if (entry.row.Outcome === null && this.tracked.get(entry.row.Id) === entry) {
440
+ if (entry.row.outcome === null && this.tracked.get(entry.row.Id) === entry) {
452
441
  this.retryLater(entry, { code: 'local', reason: errorText(error) });
453
442
  }
454
443
  })
@@ -459,7 +448,7 @@ export class PendingJoinRunner {
459
448
  return run;
460
449
  }
461
450
  async attemptOnce(entry) {
462
- if (entry.row.Outcome !== null)
451
+ if (entry.row.outcome !== null)
463
452
  return;
464
453
  if (entry.joinedHere) {
465
454
  await this.writeOutcome(entry, entry.joinedHere);
@@ -467,7 +456,7 @@ export class PendingJoinRunner {
467
456
  }
468
457
  if (this.now() >= entry.row.ExpiresAt) {
469
458
  const reason = `No attempt starts after ${new Date(entry.row.ExpiresAt).toISOString()}`;
470
- await this.writeOutcome(entry, failedFields(entry.row, 'expired', reason, this.now()));
459
+ await this.writeOutcome(entry, failedOutcome('expired', reason, this.now()));
471
460
  return;
472
461
  }
473
462
  entry.trying = true;
@@ -484,22 +473,22 @@ export class PendingJoinRunner {
484
473
  }
485
474
  entry.trying = false;
486
475
  log('pending join %s: approved, strand %s', entry.row.Id, result.strandId);
487
- entry.joinedHere = joinedFields(entry.row, result, this.now());
476
+ entry.joinedHere = joinedOutcome(result, this.now());
488
477
  await this.writeOutcome(entry, entry.joinedHere);
489
478
  }
490
- /** Read a failed attempt: retry, confirm a spent token, or fail the row. */
479
+ /** Read a failed attempt: retry, confirm a spent token, or fail the join. */
491
480
  async onAttemptFailed(entry, error) {
492
481
  const now = this.now();
493
482
  if (error instanceof FormationRejectedError && error.code === 'token-spent') {
494
483
  await this.onSpent(entry, error);
495
484
  }
496
485
  else if (error instanceof FormationPostApprovalError) {
497
- log('pending join %s: approved, then a step on this machine failed; failing the row', entry.row.Id);
498
- await this.writeOutcome(entry, failedFields(entry.row, 'local', error.message, now));
486
+ log('pending join %s: approved, then a step on this machine failed; failing the join', entry.row.Id);
487
+ await this.writeOutcome(entry, failedOutcome('local', error.message, now));
499
488
  }
500
489
  else if (error instanceof FormationRejectedError && !error.retryable) {
501
- log('pending join %s: refused (%s); failing the row', entry.row.Id, error.code);
502
- await this.writeOutcome(entry, failedFields(entry.row, error.code, error.reason, now));
490
+ log('pending join %s: refused (%s); failing the join', entry.row.Id, error.code);
491
+ await this.writeOutcome(entry, failedOutcome(error.code, error.reason, now));
503
492
  }
504
493
  else if (error instanceof FormationRejectedError) {
505
494
  this.retryLater(entry, { code: error.code, reason: error.reason });
@@ -508,15 +497,15 @@ export class PendingJoinRunner {
508
497
  this.retryLater(entry, { code: 'unreachable', reason: error.message });
509
498
  }
510
499
  else {
511
- // Retrying is safe: nothing is attempted after the row's expiry.
500
+ // Retrying is safe: nothing is attempted after the request's expiry.
512
501
  log('pending join %s: attempt threw an unexpected error; retrying: %s', entry.row.Id, errorText(error));
513
502
  this.retryLater(entry, { code: 'local', reason: errorText(error) });
514
503
  }
515
504
  }
516
505
  /**
517
506
  * A `token-spent` answer may mean another owner machine of this party won the same
518
- * invitation. Adopt its outcome if the row already shows one; otherwise confirm once, after
519
- * long enough for that machine's `joined` write to arrive, before failing the row.
507
+ * invitation. Adopt its outcome if the join already shows one; otherwise confirm once, after
508
+ * long enough for that machine's `joined` write to arrive, before failing the join.
520
509
  */
521
510
  async onSpent(entry, error) {
522
511
  const live = await this.deps.readRow(entry.row.Id);
@@ -525,13 +514,13 @@ export class PendingJoinRunner {
525
514
  return;
526
515
  }
527
516
  this.adopt(live);
528
- if (live.Outcome !== null) {
517
+ if (live.outcome !== null) {
529
518
  this.emitIfChanged(entry);
530
519
  return;
531
520
  }
532
521
  if (entry.spentOnce) {
533
- log('pending join %s: token spent again after the confirming wait; failing the row', entry.row.Id);
534
- await this.writeOutcome(entry, failedFields(entry.row, 'token-spent', error.reason, this.now()));
522
+ log('pending join %s: token spent again after the confirming wait; failing the join', entry.row.Id);
523
+ await this.writeOutcome(entry, failedOutcome('token-spent', error.reason, this.now()));
535
524
  return;
536
525
  }
537
526
  entry.spentOnce = true;
@@ -551,29 +540,30 @@ export class PendingJoinRunner {
551
540
  return Math.round(capped * (1 + (this.random() * 2 - 1) * RETRY_JITTER));
552
541
  }
553
542
  /**
554
- * Write `next` over the row this machine last read. When another write got there first,
555
- * re-read and settle by {@link resolveLostWrite}. A row the user dismissed meanwhile is not
556
- * recreated; a join this machine made is still remembered machine-locally, as every
557
- * `formStrand` is.
543
+ * Record `next` on the join this machine last read. When another write got there first, settle
544
+ * by {@link resolveLostWrite} from the live join the refusal carries. A join the user dismissed
545
+ * meanwhile is not recreated; a join this machine made is still remembered machine-locally, as
546
+ * every `formStrand` join is.
558
547
  */
559
548
  async writeOutcome(entry, next) {
560
549
  let expected = entry.row;
561
550
  for (let round = 0; round < MAX_OUTCOME_WRITE_ROUNDS; round++) {
551
+ let live;
562
552
  try {
563
- const written = await this.deps.replace(expected.StampId, next);
564
- this.noteWritten(written);
553
+ const written = await this.deps.record(expected, next);
554
+ this.noteWritten(written, 'outcome');
565
555
  this.settle(written);
566
- log('pending join %s: recorded %s%s', next.Id, next.Outcome, next.FailureCode ? ` (${next.FailureCode})` : '');
556
+ log('pending join %s: recorded %s', entry.row.Id, next.kind === 'failed' ? `failed (${next.Code})` : 'joined');
567
557
  return;
568
558
  }
569
559
  catch (error) {
570
560
  if (!(error instanceof PendingJoinChangedError))
571
561
  throw error;
562
+ live = error.live;
572
563
  }
573
- const live = await this.deps.readRow(next.Id);
574
- const resolution = resolveLostWrite(live, next);
564
+ const resolution = resolveLostWrite(live, expected, next);
575
565
  if (resolution === 'gone') {
576
- log('pending join %s: dismissed while this machine was recording %s; not recreated', next.Id, next.Outcome);
566
+ log('pending join %s: dismissed while this machine was recording %s; not recreated', entry.row.Id, next.kind);
577
567
  this.forget(entry);
578
568
  return;
579
569
  }
@@ -583,16 +573,16 @@ export class PendingJoinRunner {
583
573
  }
584
574
  expected = live;
585
575
  }
586
- log('pending join %s: another machine kept replacing the row; the next pass decides', next.Id);
576
+ log('pending join %s: another machine kept changing the join; the next pass decides', entry.row.Id);
587
577
  }
588
- settle(row) {
589
- const entry = this.adopt(row);
590
- if (row.Outcome !== null)
578
+ settle(join) {
579
+ const entry = this.adopt(join);
580
+ if (join.outcome !== null)
591
581
  entry.joinedHere = undefined;
592
582
  this.emitIfChanged(entry);
593
583
  }
594
584
  /**
595
- * Remove a finished row {@link MEMBERSHIP_INVITE_TTL_MS} after its outcome: the membership
585
+ * Remove a finished join {@link MEMBERSHIP_INVITE_TTL_MS} after its outcome: the membership
596
586
  * invitation it carries is dead by then, and an app that was not running has seen the strand
597
587
  * through `strand:discovered`. Only while connected, like every write nothing waits on.
598
588
  *
@@ -601,8 +591,8 @@ export class PendingJoinRunner {
601
591
  * user asks again; revisit if that is ever seen.
602
592
  */
603
593
  async ageOut(entry) {
604
- const { OutcomeAt } = entry.row;
605
- if (OutcomeAt === null || this.now() < OutcomeAt + MEMBERSHIP_INVITE_TTL_MS || this.deps.isAlone())
594
+ const recordedAt = entry.row.outcome?.RecordedAt;
595
+ if (recordedAt === undefined || this.now() < recordedAt + MEMBERSHIP_INVITE_TTL_MS || this.deps.isAlone())
606
596
  return;
607
597
  await this.deps.remove(entry.row.Id);
608
598
  log('pending join %s: removed, %d days after its outcome', entry.row.Id, MEMBERSHIP_INVITE_TTL_MS / (24 * 3600000));
@@ -623,20 +613,21 @@ export class PendingJoinRunner {
623
613
  entry.lastEmitted = key;
624
614
  this.deps.emit(status);
625
615
  }
626
- describe(row, entry) {
616
+ describe(join, entry) {
627
617
  const base = {
628
- id: row.Id,
629
- sAppId: this.deps.sAppIdOf(row),
630
- requestedAt: row.RequestedAt,
631
- expiresAt: row.ExpiresAt,
618
+ id: join.Id,
619
+ sAppId: this.deps.sAppIdOf(join),
620
+ requestedAt: join.RequestedAt,
621
+ expiresAt: join.ExpiresAt,
632
622
  };
633
- if (row.Outcome === 'joined') {
634
- return { ...base, state: 'joined', strandId: row.StrandId ?? undefined };
623
+ const { outcome } = join;
624
+ if (outcome?.kind === 'joined') {
625
+ return { ...base, state: 'joined', strandId: outcome.StrandId };
635
626
  }
636
- if (row.Outcome === 'failed') {
637
- return { ...base, state: 'failed', failure: { code: row.FailureCode ?? '', reason: row.FailureReason ?? '' } };
627
+ if (outcome?.kind === 'failed') {
628
+ return { ...base, state: 'failed', failure: { code: outcome.Code, reason: outcome.Reason } };
638
629
  }
639
- const local = entry && entry.row.Outcome === null && entry.row.RequestedAt === row.RequestedAt ? entry : undefined;
630
+ const local = entry && entry.row.outcome === null && entry.row.RequestedAt === join.RequestedAt ? entry : undefined;
640
631
  if (local?.trying) {
641
632
  return { ...base, state: 'trying' };
642
633
  }