@dopamint-fun/open-sdk 0.2.0-dev.0 → 0.2.0-dev.10

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.
@@ -4,9 +4,11 @@
4
4
  * appends the signature. Authority messages are decoded just far enough for
5
5
  * the seat loop: views, commits, terminal, errors, and seat-auth challenges.
6
6
  */
7
- import { ByteReader, ByteWriter } from "./bytes.js";
7
+ import { ByteReader, ByteWriter, equalBytes } from "./bytes.js";
8
8
  import { encodeContext, requireSessionVersion, } from "./sessionWire.js";
9
9
  const AUTHORITY_EVENT_ACKNOWLEDGEMENT_TAG = 0x09;
10
+ /** The only wire contract this build frames and decodes. */
11
+ const CANONICAL_WIRE_VERSION = 1;
10
12
  export const SESSION_JOINED_TAG = 0x02;
11
13
  export const PARTICIPANT_VIEW_TAG = 0x03;
12
14
  export const ACTION_ACKNOWLEDGED_TAG = 0x05;
@@ -19,6 +21,14 @@ export const SESSION_ERROR_TAG = 0x0d;
19
21
  export const SEAT_AUTHORIZATION_CHALLENGE_TAG = 0x0f;
20
22
  export const SEAT_AUTHORIZATION_RESPONSE_TAG = 0x10;
21
23
  export const PREDICTION_GATE_RELEASED_TAG = 0x11;
24
+ export const PREDICTION_GATE_PREPARED_TAG = 0x12;
25
+ export const PREDICTION_GATE_OPENED_TAG = 0x13;
26
+ /* The released notice carries a version per terminal-digest binding: a
27
+ release with no protected-terminal digest is the byte-identical
28
+ version-three notice, and one bound to a digest is this notice with the
29
+ raw 32-byte digest after the overlay. `arena_session::wire` picks the
30
+ same tag from the same fact. */
31
+ export const PREDICTION_GATE_RELEASED_V4_TAG = 0x14;
22
32
  export const MAX_TRANSPORT_FRAME_BYTES = 1 << 20;
23
33
  export function encodeAckFrame(context, cursor) {
24
34
  const sequence = typeof cursor === "object" ? cursor.sequence : cursor;
@@ -51,6 +61,10 @@ export function decodeEnvelope(envelope) {
51
61
  throw new Error("session frame exceeds transport ceiling");
52
62
  return new Uint8Array(wire);
53
63
  }
64
+ /** ADR-0096's ceiling on added pacing for one next-action prediction window,
65
+ * as `arena_session::prediction_gate::MAX_PREDICTION_PACING_MS`. */
66
+ export const MAX_PREDICTION_PACING_MS = 25000n;
67
+ const U64_MAX = (1n << 64n) - 1n;
54
68
  /* The authority's session-protocol error tags, by name.
55
69
  *
56
70
  * `arena-session/src/wire.rs` numbers `SessionProtocolError` on the wire and
@@ -148,6 +162,13 @@ function readView(reader) {
148
162
  const legal = readPayload(reader, "legal action schema byte length", "legal action schema bytes");
149
163
  const participantDeadlineMs = reader.readU64("participant deadline milliseconds");
150
164
  const latestReceipt = readOption(reader, "latest receipt option", () => readReceipt(reader));
165
+ /* `ParticipantViewSnapshot::new` refuses a receipt whose resulting state is
166
+ not the state the view is on, and Rust decodes through that constructor.
167
+ Skipping it here accepts a view whose own commitment and receipt disagree
168
+ -- which is exactly what a mutated frame looks like, and what a seat would
169
+ then answer a turn on. */
170
+ if (latestReceipt && !equalStateRef(latestReceipt.resultingState, state))
171
+ throw new Error("view receipt does not result in the state the view is on");
151
172
  return {
152
173
  state,
153
174
  participantView: participantView.bytes,
@@ -158,6 +179,164 @@ function readView(reader) {
158
179
  latestReceipt,
159
180
  };
160
181
  }
182
+ /** `validate_authority_event`'s view rule: an event that carries a view names
183
+ * that view's own receipt in its cursor. The two are one fact, and a frame
184
+ * where they disagree describes a receipt floor no session ever held. */
185
+ function requireViewCursor(view, cursor) {
186
+ if (!equalReceiptRef(cursor.witnessedReceipt, view.latestReceipt))
187
+ throw new Error("the event cursor does not name the receipt its view is on");
188
+ }
189
+ /* ── The prediction-gate lifecycle values ────────────────────────────────
190
+ *
191
+ * Byte-for-byte the layouts in `arena_session::wire`:
192
+ * `encode_prediction_gate_preparation`, `_opening` and `_release`. Every
193
+ * structural refusal the Rust constructors make is made here, so a frame this
194
+ * side accepts is one the shared crate would also accept — a permissive
195
+ * client would hand a seat a window identity the authority never committed. */
196
+ export function equalStateRef(left, right) {
197
+ return (left.nonce === right.nonce && equalBytes(left.commitment, right.commitment));
198
+ }
199
+ export function equalReceiptRef(left, right) {
200
+ if (left === null || right === null)
201
+ return left === right;
202
+ return (equalBytes(left.digest, right.digest) &&
203
+ equalStateRef(left.resultingState, right.resultingState));
204
+ }
205
+ export function equalPredictionGatePreparation(left, right) {
206
+ return (left.window.windowId === right.window.windowId &&
207
+ left.window.marketId === right.window.marketId &&
208
+ left.window.contract === right.window.contract &&
209
+ left.actingSeat === right.actingSeat &&
210
+ equalStateRef(left.state, right.state) &&
211
+ equalReceiptRef(left.receipt, right.receipt) &&
212
+ left.originalDeadlineMs === right.originalDeadlineMs &&
213
+ left.preparedAtMs === right.preparedAtMs);
214
+ }
215
+ export function equalPredictionGateRelease(left, right) {
216
+ return (left.window.windowId === right.window.windowId &&
217
+ left.window.marketId === right.window.marketId &&
218
+ left.window.contract === right.window.contract &&
219
+ left.actingSeat === right.actingSeat &&
220
+ equalStateRef(left.state, right.state) &&
221
+ equalReceiptRef(left.receipt, right.receipt) &&
222
+ left.terminal === right.terminal &&
223
+ left.originalDeadlineMs === right.originalDeadlineMs &&
224
+ left.arrivalMs === right.arrivalMs &&
225
+ left.lockedAtMs === right.lockedAtMs &&
226
+ left.budgetMs === right.budgetMs);
227
+ }
228
+ function readPredictionWindow(reader) {
229
+ const windowId = reader.readU64("prediction window id");
230
+ const marketId = reader.readU64("prediction market id");
231
+ const tag = reader.readByte("prediction window contract");
232
+ if (tag !== 1)
233
+ throw new Error(`invalid prediction window contract tag ${tag}`);
234
+ if (windowId === 0n || marketId === 0n)
235
+ throw new Error("prediction window names neither a window nor a market");
236
+ return { windowId, marketId, contract: "pokerActionV1" };
237
+ }
238
+ function readPredictionGatePreparation(reader) {
239
+ const window = readPredictionWindow(reader);
240
+ const actingSeat = reader.readU16("prediction gate acting seat");
241
+ const state = readState(reader, "prediction gate state nonce", "prediction gate state commitment");
242
+ const receipt = readOption(reader, "prediction gate receipt option", () => readReceipt(reader));
243
+ const originalDeadlineMs = reader.readU64("original participant deadline milliseconds");
244
+ const preparedAtMs = reader.readU64("prediction gate prepared-at milliseconds");
245
+ return bindPredictionGatePreparation({
246
+ window,
247
+ actingSeat,
248
+ state,
249
+ receipt,
250
+ originalDeadlineMs,
251
+ preparedAtMs,
252
+ });
253
+ }
254
+ /** The structural refusals of `PredictionGatePreparation::new`, made wherever
255
+ * a preparation enters this process - the wire, or a retained seat state. */
256
+ export function bindPredictionGatePreparation(fields) {
257
+ if (fields.window.windowId === 0n || fields.window.marketId === 0n)
258
+ throw new Error("prediction window names neither a window nor a market");
259
+ if (fields.originalDeadlineMs === 0n)
260
+ throw new Error("prediction gate preparation has no original deadline");
261
+ if (fields.preparedAtMs === 0n)
262
+ throw new Error("prediction gate preparation has no persisted instant");
263
+ if (fields.receipt &&
264
+ !equalStateRef(fields.receipt.resultingState, fields.state))
265
+ throw new Error("prediction gate preparation receipt does not result in its target state");
266
+ return fields;
267
+ }
268
+ function readPredictionGateOpening(reader) {
269
+ const preparation = readPredictionGatePreparation(reader);
270
+ const openedAtMs = reader.readU64("prediction gate opened-at milliseconds");
271
+ const closesAtMs = reader.readU64("prediction gate closes-at milliseconds");
272
+ return bindPredictionGateOpening({ preparation, openedAtMs, closesAtMs });
273
+ }
274
+ /** The structural refusals of `PredictionGateOpening::new`. */
275
+ export function bindPredictionGateOpening(fields) {
276
+ bindPredictionGatePreparation(fields.preparation);
277
+ if (fields.openedAtMs === 0n)
278
+ throw new Error("prediction gate opening has no publication instant");
279
+ if (fields.openedAtMs < fields.preparation.preparedAtMs)
280
+ throw new Error("prediction gate opened before it was prepared");
281
+ if (fields.closesAtMs <= fields.openedAtMs)
282
+ throw new Error("prediction gate closes at or before it opened");
283
+ return fields;
284
+ }
285
+ /* `bound` is the notice version, not a field the sender may vary: the
286
+ version-three notice ends at the overlay and names no digest, and the
287
+ version-four notice ends with the raw digest and no option byte. Reading
288
+ an option here would read the cursor that follows. */
289
+ function readPredictionGateRelease(reader, bound) {
290
+ const window = readPredictionWindow(reader);
291
+ const actingSeat = reader.readU16("prediction gate acting seat");
292
+ const state = readState(reader, "prediction gate state nonce", "prediction gate state commitment");
293
+ const receipt = readOption(reader, "prediction gate receipt option", () => readReceipt(reader));
294
+ const terminalTag = reader.readByte("prediction gate terminal");
295
+ if (terminalTag !== 1 && terminalTag !== 2)
296
+ throw new Error(`invalid prediction gate terminal tag ${terminalTag}`);
297
+ const originalDeadlineMs = reader.readU64("original participant deadline milliseconds");
298
+ const arrivalMs = reader.readU64("prediction gate arrival milliseconds");
299
+ const lockedAtMs = reader.readU64("prediction gate locked-at milliseconds");
300
+ const budgetMs = reader.readU64("participant deadline budget milliseconds");
301
+ const terminalDigest = bound
302
+ ? reader.readFixed(32, "prediction gate terminal digest")
303
+ : null;
304
+ return bindPredictionGateRelease({
305
+ window,
306
+ actingSeat,
307
+ state,
308
+ receipt,
309
+ terminal: terminalTag === 1 ? "locked" : "cancelled",
310
+ originalDeadlineMs,
311
+ arrivalMs,
312
+ lockedAtMs,
313
+ budgetMs,
314
+ terminalDigest,
315
+ });
316
+ }
317
+ /** The structural refusals of `PredictionGateRelease::new`, with the bounded
318
+ * overlay derived here rather than trusted from a sender or a stored file:
319
+ * the lock instant capped at the pacing ceiling, plus the budget. */
320
+ export function bindPredictionGateRelease(fields) {
321
+ if (fields.window.windowId === 0n || fields.window.marketId === 0n)
322
+ throw new Error("prediction window names neither a window nor a market");
323
+ if (fields.arrivalMs === 0n)
324
+ throw new Error("prediction gate release has no arrival instant");
325
+ if (fields.lockedAtMs < fields.arrivalMs)
326
+ throw new Error("prediction gate closed before the overlay arrived");
327
+ if (fields.budgetMs === 0n)
328
+ throw new Error("prediction gate release has no deadline budget");
329
+ if (fields.receipt &&
330
+ !equalStateRef(fields.receipt.resultingState, fields.state))
331
+ throw new Error("prediction gate release receipt does not result in its target state");
332
+ const uncapped = fields.arrivalMs + MAX_PREDICTION_PACING_MS;
333
+ const ceiling = uncapped > U64_MAX ? U64_MAX : uncapped;
334
+ const lockCappedMs = fields.lockedAtMs < ceiling ? fields.lockedAtMs : ceiling;
335
+ const participantDeadlineMs = lockCappedMs + fields.budgetMs;
336
+ if (participantDeadlineMs > U64_MAX)
337
+ throw new Error("prediction gate overlay deadline exceeds u64");
338
+ return { ...fields, lockCappedMs, participantDeadlineMs };
339
+ }
161
340
  function readPolicy(reader) {
162
341
  return {
163
342
  maxParticipantViewBytes: reader.readU64("maximum participant view bytes"),
@@ -186,12 +365,28 @@ function skipAuthorization(reader) {
186
365
  throw new Error(`invalid authorization principal tag ${principalTag}`);
187
366
  }
188
367
  }
368
+ /* `ActionRejection::retry_disposition` by wire tag: every rejection has one
369
+ canonical answer, and `validate_authority_event` refuses a frame that names
370
+ another. Without this a "retry later" byte on an unauthorised key reads as
371
+ a transient failure and a seat resubmits into a permanent refusal. */
372
+ const REJECTION_RETRY = [
373
+ /* ProtocolRejected */ 0, /* StaleState */ 2, /* UnauthorizedSeatOrKey */ 0,
374
+ /* DeadlineExpired */ 2, /* ActionIdentityConflict */ 0,
375
+ /* ConflictingAlreadyCommitted */ 2, /* UnsupportedSchema */ 0,
376
+ /* MalformedState */ 0, /* MalformedInput */ 0, /* WrongOrigin */ 0,
377
+ /* WrongActor */ 0, /* TerminalState */ 0, /* DeadlineMismatch */ 2,
378
+ /* InvalidTimeEvidence */ 0,
379
+ ];
380
+ /** The rejection and the retry disposition that must accompany it. */
189
381
  function readActionRejection(reader) {
190
382
  const tag = reader.readByte("action rejection tag");
383
+ if (tag >= REJECTION_RETRY.length)
384
+ throw new Error(`invalid action rejection tag ${tag}`);
191
385
  if (tag === 0 || tag === 6)
192
386
  reader.readU16("action rejection extra");
193
- if (tag > 13)
194
- throw new Error(`invalid action rejection tag ${tag}`);
387
+ const retry = reader.readByte("retry disposition");
388
+ if (retry !== REJECTION_RETRY[tag])
389
+ throw new Error(`action rejection ${tag} names retry disposition ${retry}, not ${REJECTION_RETRY[tag]}`);
195
390
  }
196
391
  function readSeatAuthChallenge(reader, wireVersion) {
197
392
  const context = readContext(reader, wireVersion);
@@ -212,6 +407,10 @@ function readSeatAuthChallenge(reader, wireVersion) {
212
407
  export function decodeAuthorityMessage(bytes) {
213
408
  const reader = new ByteReader(bytes);
214
409
  const wireVersion = reader.readU16("wire version");
410
+ /* The envelope names the wire contract it was framed under, and this build
411
+ speaks exactly one. Rust refuses any other value at the same point. */
412
+ if (wireVersion !== CANONICAL_WIRE_VERSION)
413
+ throw new Error(`unsupported wire version ${wireVersion}, expected ${CANONICAL_WIRE_VERSION}`);
215
414
  const tag = reader.readByte("message tag");
216
415
  if (tag === SESSION_ERROR_TAG) {
217
416
  // A present selected version is the authority's answer for this session,
@@ -247,8 +446,12 @@ export function decodeAuthorityMessage(bytes) {
247
446
  switch (tag) {
248
447
  case SESSION_JOINED_TAG: {
249
448
  const policy = readPolicy(reader);
250
- const view = readView(reader);
449
+ /* Option-tagged: a seat held behind a committed gate is admitted with
450
+ no private view, and the tag is the only thing that says so. */
451
+ const view = readOption(reader, "joined view option", () => readView(reader));
251
452
  const cursor = readCursor(reader);
453
+ if (view)
454
+ requireViewCursor(view, cursor);
252
455
  message = {
253
456
  type: "sessionJoined",
254
457
  context,
@@ -262,6 +465,7 @@ export function decodeAuthorityMessage(bytes) {
262
465
  case PARTICIPANT_VIEW_TAG: {
263
466
  const view = readView(reader);
264
467
  const cursor = readCursor(reader);
468
+ requireViewCursor(view, cursor);
265
469
  message = { type: "participantView", context, sequence, view, cursor };
266
470
  break;
267
471
  }
@@ -281,9 +485,9 @@ export function decodeAuthorityMessage(bytes) {
281
485
  case ACTION_REJECTED_TAG: {
282
486
  const actionId = reader.readFixed(32, "action id");
283
487
  readActionRejection(reader);
284
- reader.readByte("retry disposition");
285
488
  const view = readView(reader);
286
489
  const cursor = readCursor(reader);
490
+ requireViewCursor(view, cursor);
287
491
  message = {
288
492
  type: "actionRejected",
289
493
  context,
@@ -299,6 +503,11 @@ export function decodeAuthorityMessage(bytes) {
299
503
  const receipt = readReceipt(reader);
300
504
  const view = readView(reader);
301
505
  const cursor = readCursor(reader);
506
+ /* A commit is the receipt its own view is on: the pair is one committed
507
+ fact, not two the recipient has to reconcile. */
508
+ if (!equalReceiptRef(view.latestReceipt, receipt))
509
+ throw new Error("the commit receipt is not the receipt its view is on");
510
+ requireViewCursor(view, cursor);
302
511
  message = {
303
512
  type: "actionCommitted",
304
513
  context,
@@ -311,10 +520,19 @@ export function decodeAuthorityMessage(bytes) {
311
520
  break;
312
521
  }
313
522
  case SESSION_RESUMED_TAG: {
314
- reader.readU64("replay from sequence");
315
- const view = readView(reader);
523
+ const replayFrom = reader.readU64("replay from sequence");
524
+ const view = readOption(reader, "resumed view option", () => readView(reader));
316
525
  const cursor = readCursor(reader);
317
- message = { type: "sessionResumed", context, sequence, view, cursor };
526
+ if (view)
527
+ requireViewCursor(view, cursor);
528
+ message = {
529
+ type: "sessionResumed",
530
+ context,
531
+ sequence,
532
+ replayFrom,
533
+ view,
534
+ cursor,
535
+ };
318
536
  break;
319
537
  }
320
538
  case SESSION_TERMINAL_TAG: {
@@ -322,6 +540,11 @@ export function decodeAuthorityMessage(bytes) {
322
540
  const finalState = readState(reader, "final state nonce", "final state commitment");
323
541
  const finalReceipt = readOption(reader, "final receipt option", () => readReceipt(reader));
324
542
  const cursor = readCursor(reader);
543
+ if (finalReceipt &&
544
+ !equalStateRef(finalReceipt.resultingState, finalState))
545
+ throw new Error("the terminal receipt does not result in its final state");
546
+ if (!equalReceiptRef(cursor.witnessedReceipt, finalReceipt))
547
+ throw new Error("the terminal cursor does not name the final receipt");
325
548
  message = {
326
549
  type: "sessionTerminal",
327
550
  context,
@@ -333,23 +556,39 @@ export function decodeAuthorityMessage(bytes) {
333
556
  };
334
557
  break;
335
558
  }
336
- case PREDICTION_GATE_RELEASED_TAG: {
337
- reader.readU64("prediction window id");
338
- reader.readU64("prediction market id");
339
- reader.readByte("prediction window contract");
340
- reader.readU16("prediction gate acting seat");
341
- readState(reader, "prediction gate state nonce", "prediction gate state commitment");
342
- readOption(reader, "prediction gate receipt option", () => readReceipt(reader));
343
- reader.readByte("prediction gate terminal");
344
- reader.readU64("original participant deadline milliseconds");
345
- reader.readU64("prediction gate arrival milliseconds");
346
- reader.readU64("prediction gate locked-at milliseconds");
347
- reader.readU64("participant deadline budget milliseconds");
559
+ case PREDICTION_GATE_PREPARED_TAG: {
560
+ const preparation = readPredictionGatePreparation(reader);
561
+ const cursor = readCursor(reader);
562
+ message = {
563
+ type: "predictionGatePrepared",
564
+ context,
565
+ sequence,
566
+ preparation,
567
+ cursor,
568
+ };
569
+ break;
570
+ }
571
+ case PREDICTION_GATE_OPENED_TAG: {
572
+ const opening = readPredictionGateOpening(reader);
573
+ const cursor = readCursor(reader);
574
+ message = {
575
+ type: "predictionGateOpened",
576
+ context,
577
+ sequence,
578
+ opening,
579
+ cursor,
580
+ };
581
+ break;
582
+ }
583
+ case PREDICTION_GATE_RELEASED_TAG:
584
+ case PREDICTION_GATE_RELEASED_V4_TAG: {
585
+ const release = readPredictionGateRelease(reader, tag === PREDICTION_GATE_RELEASED_V4_TAG);
348
586
  const cursor = readCursor(reader);
349
587
  message = {
350
588
  type: "predictionGateReleased",
351
589
  context,
352
590
  sequence,
591
+ release,
353
592
  cursor,
354
593
  };
355
594
  break;
@@ -402,3 +641,402 @@ export function takePrincipalProof(bytes) {
402
641
  rest: bytes.slice(reader.consumed()),
403
642
  };
404
643
  }
644
+ /* ── The named gate lifecycle, as a recipient folds it ───────────────────
645
+ *
646
+ * One fold, shared by both consumer loops. The rules are
647
+ * `arena_session::semantics::{validate_gate_preparation, validate_gate_opening,
648
+ * validate_gate_release}`: a preparation opens a revision's gate and never
649
+ * replaces a retained one, an opening must carry the exact stored preparation,
650
+ * and a release closes a prepared gate only by cancelling it. Writing these
651
+ * twice - once in `playSeat` and once in `openTurn` - is how the two doors
652
+ * would come to disagree about which phase a seat is in. */
653
+ /** A refused phase transition, named as the shared crate names it. */
654
+ export class PredictionGateConflictError extends Error {
655
+ conflict;
656
+ constructor(conflict, detail) {
657
+ super(`${conflict}: ${detail}`);
658
+ this.conflict = conflict;
659
+ this.name = "PredictionGateConflictError";
660
+ }
661
+ }
662
+ /** The lifecycle step an authority message carries, or null where it carries
663
+ * none. The fold takes the value, not the envelope. */
664
+ export function predictionGateMessage(message) {
665
+ switch (message.type) {
666
+ case "predictionGatePrepared":
667
+ return { kind: "prepared", preparation: message.preparation };
668
+ case "predictionGateOpened":
669
+ return { kind: "opened", opening: message.opening };
670
+ case "predictionGateReleased":
671
+ return { kind: "released", release: message.release };
672
+ default:
673
+ return null;
674
+ }
675
+ }
676
+ /** The committed preparation behind any phase, or null once released - a
677
+ * release carries the identity but not the persisted preparation instant. */
678
+ export function predictionGatePreparationOf(status) {
679
+ switch (status.phase) {
680
+ case "prepared":
681
+ return status.preparation;
682
+ case "open":
683
+ return status.opening.preparation;
684
+ case "released":
685
+ return null;
686
+ }
687
+ }
688
+ /** The receipt the gate targets. A recipient behind it still receives every
689
+ * notice, because each notice cursor names that recipient's own floor. */
690
+ export function predictionGateTargetReceipt(status) {
691
+ switch (status.phase) {
692
+ case "prepared":
693
+ return status.preparation.receipt;
694
+ case "open":
695
+ return status.opening.preparation.receipt;
696
+ case "released":
697
+ return status.release.receipt;
698
+ }
699
+ }
700
+ /** Whether a newly named preparation supersedes the retained facts.
701
+ *
702
+ * Only a released gate can be superseded: a held gate's turn has not closed,
703
+ * so a second preparation contradicts it. Once released, the next selected
704
+ * turn always targets a later committed state and therefore its own receipt.
705
+ * A seat that is itself withheld for that next turn never witnesses the view
706
+ * that would otherwise retire the closed gate - which is exactly the second
707
+ * window of a hand, held on the seat that was a bystander for the first. A
708
+ * preparation naming the same revision is the same-target conflict, not a
709
+ * supersession. */
710
+ export function predictionGateSupersededByPreparation(status, nextTurn) {
711
+ if (status.phase !== "released")
712
+ return false;
713
+ return (nextTurn.state.nonce > status.release.state.nonce &&
714
+ !equalReceiptRef(nextTurn.receipt, status.release.receipt));
715
+ }
716
+ export function applyPredictionGateStatus(current, message) {
717
+ switch (message.kind) {
718
+ case "prepared":
719
+ if (current !== null &&
720
+ !predictionGateSupersededByPreparation(current, message.preparation))
721
+ throw new PredictionGateConflictError("PredictionGatePhaseConflict", "a preparation arrived while this revision still retains a gate");
722
+ return { phase: "prepared", preparation: message.preparation };
723
+ case "opened":
724
+ if (current?.phase !== "prepared" ||
725
+ !equalPredictionGatePreparation(current.preparation, message.opening.preparation))
726
+ throw new PredictionGateConflictError("PredictionGatePhaseConflict", "an opening must carry the exact durably prepared window");
727
+ return { phase: "open", opening: message.opening };
728
+ case "released": {
729
+ const release = message.release;
730
+ if (current === null)
731
+ throw new PredictionGateConflictError("PredictionGatePhaseConflict", "a release closes no retained gate");
732
+ if (current.phase === "released") {
733
+ /* A replayed release is the same overlay again; a different one would
734
+ be a second budget for one revision. */
735
+ if (!equalPredictionGateRelease(current.release, release))
736
+ throw new PredictionGateConflictError("PredictionGateReleaseConflict", "a second release for one revision");
737
+ return current;
738
+ }
739
+ const preparation = current.phase === "prepared"
740
+ ? current.preparation
741
+ : current.opening.preparation;
742
+ const identical = release.window.windowId === preparation.window.windowId &&
743
+ release.window.marketId === preparation.window.marketId &&
744
+ release.window.contract === preparation.window.contract &&
745
+ release.actingSeat === preparation.actingSeat &&
746
+ equalStateRef(release.state, preparation.state) &&
747
+ equalReceiptRef(release.receipt, preparation.receipt) &&
748
+ release.originalDeadlineMs === preparation.originalDeadlineMs;
749
+ if (!identical)
750
+ throw new PredictionGateConflictError("PredictionGatePhaseConflict", "the release names another window than the retained gate");
751
+ if (current.phase === "prepared" && release.terminal !== "cancelled")
752
+ throw new PredictionGateConflictError("PredictionGatePhaseConflict", "a gate that never opened can only close by cancellation");
753
+ return { phase: "released", release };
754
+ }
755
+ }
756
+ }
757
+ /** A refused event, named as `SessionSemanticError` names it. */
758
+ export class SessionBoundaryError extends Error {
759
+ reason;
760
+ constructor(reason, detail) {
761
+ super(`${reason}: ${detail}`);
762
+ this.reason = reason;
763
+ this.name = "SessionBoundaryError";
764
+ }
765
+ }
766
+ /** The boundary a seat that has accepted nothing holds. */
767
+ export function freshSessionBoundary() {
768
+ return {
769
+ context: null,
770
+ sequence: 0n,
771
+ receiptFloor: null,
772
+ view: null,
773
+ predictionGate: null,
774
+ awaitingGatePrefix: false,
775
+ };
776
+ }
777
+ function equalContext(left, right) {
778
+ return (left.wireVersion === right.wireVersion &&
779
+ left.sessionVersion === right.sessionVersion &&
780
+ equalBytes(left.sessionId, right.sessionId) &&
781
+ equalBytes(left.executionId, right.executionId) &&
782
+ equalBytes(left.executionManifestDigest, right.executionManifestDigest) &&
783
+ equalBytes(left.protocolId, right.protocolId) &&
784
+ left.protocolVersion === right.protocolVersion &&
785
+ equalBytes(left.participantId, right.participantId) &&
786
+ left.seat === right.seat);
787
+ }
788
+ /** While the gate holds this session's acting seat, the withheld private view
789
+ * cannot arrive - whether the event carrying it is new or a re-delivery. A
790
+ * bystander's own view of the same receipt is ordinary, so only the acting
791
+ * seat is refused. */
792
+ function refuseHeldActingView(accepted, view) {
793
+ const gate = accepted.predictionGate;
794
+ if (gate === null || gate.phase === "released")
795
+ return;
796
+ if (!equalReceiptRef(predictionGateTargetReceipt(gate), view.latestReceipt))
797
+ return;
798
+ if (predictionGatePreparationOf(gate)?.actingSeat !== accepted.context?.seat)
799
+ return;
800
+ throw new SessionBoundaryError("PredictionGatePhaseConflict", "a held acting seat cannot be shown the gated view");
801
+ }
802
+ /** A view this seat may be shown, and what it does to the retained gate.
803
+ *
804
+ * The refusals are the two a held seat depends on: the withheld private view
805
+ * cannot arrive while this seat's own turn is gated, and a later deadline on
806
+ * an unchanged receipt is legal only under the admitted overlay. */
807
+ function admitView(accepted, view) {
808
+ const gate = accepted.predictionGate;
809
+ refuseHeldActingView(accepted, view);
810
+ if (accepted.view &&
811
+ equalReceiptRef(view.latestReceipt, accepted.receiptFloor) &&
812
+ view.participantDeadlineMs !== accepted.view.participantDeadlineMs) {
813
+ const release = gate?.phase === "released" ? gate.release : null;
814
+ const admitted = release !== null &&
815
+ equalReceiptRef(release.receipt, view.latestReceipt) &&
816
+ equalStateRef(release.state, view.state) &&
817
+ release.participantDeadlineMs === view.participantDeadlineMs &&
818
+ release.originalDeadlineMs === accepted.view.participantDeadlineMs;
819
+ if (!admitted)
820
+ throw new SessionBoundaryError("DeadlineChangesWithoutTransition", `${accepted.view.participantDeadlineMs} to ${view.participantDeadlineMs} with no admitted overlay`);
821
+ }
822
+ if (!view.latestReceipt)
823
+ return { receiptFloor: accepted.receiptFloor, predictionGate: gate };
824
+ /* A view on a later receipt than the gate targeted leaves the gate facts
825
+ obsolete; one on the gated receipt keeps the release identity, so a second
826
+ release for the same revision is still refused. */
827
+ const superseded = !equalReceiptRef(accepted.receiptFloor, view.latestReceipt) &&
828
+ gate !== null &&
829
+ !equalReceiptRef(predictionGateTargetReceipt(gate), view.latestReceipt);
830
+ return {
831
+ receiptFloor: view.latestReceipt,
832
+ predictionGate: superseded ? null : gate,
833
+ };
834
+ }
835
+ /** Where a preparation may target: the receipt this recipient has witnessed,
836
+ * or the immediate next committed one for an actor still behind it. */
837
+ function admitGatePreparation(accepted, preparation) {
838
+ const view = accepted.view;
839
+ if (view) {
840
+ if (equalReceiptRef(preparation.receipt, view.latestReceipt)) {
841
+ if (!equalStateRef(view.state, preparation.state))
842
+ throw new SessionBoundaryError("CommittedStateWithoutReceipt", "the preparation targets another state on the witnessed receipt");
843
+ if (view.participantDeadlineMs !== preparation.originalDeadlineMs)
844
+ throw new SessionBoundaryError("DeadlineChangesWithoutTransition", "the preparation names another original deadline than the open turn");
845
+ return;
846
+ }
847
+ if (preparation.state.nonce !== view.state.nonce + 1n)
848
+ throw new SessionBoundaryError("NonSequentialCommittedState", `the preparation targets nonce ${preparation.state.nonce} after ${view.state.nonce}`);
849
+ return;
850
+ }
851
+ if (equalReceiptRef(preparation.receipt, accepted.receiptFloor))
852
+ return;
853
+ const floorNonce = accepted.receiptFloor?.resultingState.nonce;
854
+ if (floorNonce !== undefined && floorNonce + 1n === preparation.state.nonce)
855
+ return;
856
+ throw new SessionBoundaryError("PredictionGatePhaseConflict", "the preparation targets neither the retained floor nor the receipt after it");
857
+ }
858
+ /** A re-delivered event moves nothing, but it is not unexamined.
859
+ *
860
+ * `ParticipantSessionState::apply_event` compares a re-delivery against the
861
+ * event it recorded at that sequence and refuses any disagreement. This
862
+ * boundary keeps facts rather than the event log, so the comparison is
863
+ * against those facts: the session it names, the receipt floor it claims,
864
+ * and the withheld view it must not carry. Skipping the comparison is how a
865
+ * foreign-context event or the gated view of a held seat used to arrive
866
+ * unchecked at a consumer that then acted on it. */
867
+ function admitReplay(accepted, message) {
868
+ const conflict = (detail) => {
869
+ throw new SessionBoundaryError("SequenceConflict", `${detail} at re-delivered event ${message.sequence}`);
870
+ };
871
+ if (message.type === "sessionJoined" && message.sequence !== 1n)
872
+ conflict("a join outside sequence 1");
873
+ /* Nothing follows a terminal, so the only terminal a boundary can be handed
874
+ again is its own last event - the one a resume at the terminal cursor
875
+ re-delivers. An older sequence claiming to be terminal contradicts every
876
+ event this seat applied after it. */
877
+ if (message.type === "sessionTerminal" &&
878
+ message.sequence !== accepted.sequence)
879
+ conflict("a terminal before the boundary's own last event");
880
+ const view = "view" in message ? message.view : null;
881
+ if (view)
882
+ refuseHeldActingView(accepted, view);
883
+ const named = message.cursor.witnessedReceipt;
884
+ if (!named)
885
+ return;
886
+ const floor = accepted.receiptFloor;
887
+ /* History cannot name a receipt this seat has not witnessed: every event at
888
+ or below the boundary projected a floor the boundary already passed. */
889
+ if (!floor)
890
+ conflict("a receipt where the boundary has witnessed none");
891
+ else if (named.resultingState.nonce > floor.resultingState.nonce)
892
+ conflict(`receipt nonce ${named.resultingState.nonce} past the floor`);
893
+ else if (named.resultingState.nonce === floor.resultingState.nonce &&
894
+ !equalReceiptRef(named, floor))
895
+ conflict("another receipt for the floor's own state");
896
+ }
897
+ export function admitAuthorityEvent(accepted, message) {
898
+ /* Acknowledgements, pendings, errors and challenges carry no cursor: they
899
+ move no boundary and are not acknowledged. */
900
+ if (!("cursor" in message))
901
+ return { disposition: "boundaryless", boundary: accepted };
902
+ const { context, sequence, cursor } = message;
903
+ if (cursor.sequence !== sequence)
904
+ throw new SessionBoundaryError("EventCursorMismatch", `event ${sequence} carries cursor ${cursor.sequence}`);
905
+ /* A resume names the boundary it replayed from, which must precede it. */
906
+ if (message.type === "sessionResumed" &&
907
+ message.replayFrom >= message.sequence)
908
+ throw new SessionBoundaryError("ReplayFromAhead", `the resume replayed from ${message.replayFrom} at event ${message.sequence}`);
909
+ /* A join is an admission only against a boundary that has applied nothing.
910
+ A consumer that joins again hands this seam a fresh boundary, because a
911
+ join is a new session and nothing retained for the old one describes it;
912
+ a `SessionJoined` arriving on an established boundary is therefore never
913
+ an admission, and cannot be used to reset the phase a held seat holds or
914
+ the floor it witnessed. */
915
+ const admission = message.type === "sessionJoined" && accepted.sequence === 0n;
916
+ if (admission) {
917
+ if (sequence !== 1n)
918
+ throw new SessionBoundaryError("ExpectedSequenceOne", `the join opens the session at sequence 1, not ${sequence}`);
919
+ /* A consumer that has already been handed a context - by the very join
920
+ this event reports - is admitted into that session and no other. */
921
+ if (accepted.context && !equalContext(accepted.context, context))
922
+ throw new SessionBoundaryError("ContextMismatch", "the join names another session than the one this boundary opened");
923
+ }
924
+ else {
925
+ /* Before anything is applied the protocol admits exactly one shape: the
926
+ join at sequence 1. Anything else would process a view or a notice
927
+ against no session at all. */
928
+ if (accepted.sequence === 0n || !accepted.context)
929
+ throw new SessionBoundaryError("ExpectedJoin", `a session opens with its own join, not ${message.type}`);
930
+ if (!equalContext(accepted.context, context))
931
+ throw new SessionBoundaryError("ContextMismatch", "the event names another session than the accepted one");
932
+ if (sequence <= accepted.sequence) {
933
+ admitReplay(accepted, message);
934
+ return { disposition: "replay", boundary: accepted };
935
+ }
936
+ if (sequence !== accepted.sequence + 1n)
937
+ throw new SessionBoundaryError("EventGap", `expected ${accepted.sequence + 1n}, got ${sequence}`);
938
+ if (message.type === "sessionJoined")
939
+ throw new SessionBoundaryError("UnexpectedJoin", "an established session cannot be joined again");
940
+ }
941
+ /* A fresh join is a new session: nothing retained for the previous one
942
+ describes it, so obsolete context state is dropped rather than carried. */
943
+ const base = admission
944
+ ? { ...freshSessionBoundary(), context }
945
+ : { ...accepted, context };
946
+ const gateMessage = predictionGateMessage(message);
947
+ const viewlessResume = message.type === "sessionResumed" && message.view === null;
948
+ if (base.awaitingGatePrefix &&
949
+ gateMessage?.kind !== "prepared" &&
950
+ !viewlessResume)
951
+ /* The primed prefix: after a viewless admission the next durable event is
952
+ the matching preparation. A view, an opening or a release before it
953
+ would either disclose the withheld view or skip a held phase. */
954
+ throw new SessionBoundaryError("PredictionGatePhaseConflict", "the named prepared prefix must arrive before anything else");
955
+ let receiptFloor = base.receiptFloor;
956
+ let view = base.view;
957
+ let predictionGate = base.predictionGate;
958
+ let awaitingGatePrefix = base.awaitingGatePrefix;
959
+ let projected;
960
+ switch (message.type) {
961
+ case "sessionJoined":
962
+ case "sessionResumed":
963
+ if (message.view) {
964
+ ({ receiptFloor, predictionGate } = admitView(base, message.view));
965
+ view = message.view;
966
+ projected = message.view.latestReceipt;
967
+ }
968
+ else if (message.type === "sessionJoined") {
969
+ /* A held seat's admission cursor names the receipt floor; only a later
970
+ real view may advance it. */
971
+ receiptFloor = cursor.witnessedReceipt;
972
+ view = null;
973
+ awaitingGatePrefix = true;
974
+ projected = cursor.witnessedReceipt;
975
+ }
976
+ else {
977
+ /* A viewless reattach repeats the retained floor and still owes the
978
+ prefix it has not been shown. */
979
+ awaitingGatePrefix = awaitingGatePrefix || predictionGate === null;
980
+ projected = receiptFloor;
981
+ }
982
+ break;
983
+ case "participantView":
984
+ ({ receiptFloor, predictionGate } = admitView(base, message.view));
985
+ view = message.view;
986
+ projected = message.view.latestReceipt;
987
+ break;
988
+ case "actionRejected":
989
+ if (!equalReceiptRef(message.view.latestReceipt, receiptFloor))
990
+ throw new SessionBoundaryError("RejectedEventAdvancesReceipt", "a rejection cannot advance the receipt it was rejected on");
991
+ ({ receiptFloor, predictionGate } = admitView(base, message.view));
992
+ view = message.view;
993
+ projected = receiptFloor;
994
+ break;
995
+ case "actionCommitted":
996
+ if (equalReceiptRef(message.receipt, receiptFloor))
997
+ throw new SessionBoundaryError("CommitDoesNotAdvanceReceipt", "a commit must advance the witnessed receipt");
998
+ ({ predictionGate } = admitView(base, message.view));
999
+ receiptFloor = message.receipt;
1000
+ view = message.view;
1001
+ projected = message.receipt;
1002
+ break;
1003
+ case "sessionTerminal":
1004
+ if (message.finalReceipt)
1005
+ receiptFloor = message.finalReceipt;
1006
+ projected = message.finalReceipt;
1007
+ break;
1008
+ default: {
1009
+ /* The three named notices. They never move the floor, never carry a
1010
+ view, and never schedule a decision. */
1011
+ const step = gateMessage;
1012
+ if (!step)
1013
+ throw new SessionBoundaryError("InvalidMessageTag", `${message.type} carries a cursor but no boundary`);
1014
+ if (step.kind === "prepared")
1015
+ admitGatePreparation(base, step.preparation);
1016
+ if (step.kind === "released" && view) {
1017
+ const release = step.release;
1018
+ if (equalReceiptRef(release.receipt, view.latestReceipt) &&
1019
+ view.participantDeadlineMs !== release.originalDeadlineMs)
1020
+ throw new SessionBoundaryError("DeadlineChangesWithoutTransition", "the release names another original deadline than the open turn");
1021
+ }
1022
+ predictionGate = applyPredictionGateStatus(predictionGate, step);
1023
+ if (step.kind === "prepared")
1024
+ awaitingGatePrefix = false;
1025
+ projected = receiptFloor;
1026
+ break;
1027
+ }
1028
+ }
1029
+ if (!equalReceiptRef(cursor.witnessedReceipt, projected))
1030
+ throw new SessionBoundaryError("ReceiptHeadMismatch", "the cursor does not name the receipt this event projects");
1031
+ return {
1032
+ disposition: "applied",
1033
+ boundary: {
1034
+ context,
1035
+ sequence,
1036
+ receiptFloor,
1037
+ view,
1038
+ predictionGate,
1039
+ awaitingGatePrefix,
1040
+ },
1041
+ };
1042
+ }