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

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