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

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";
8
- import { encodeContext } from "./sessionWire.js";
7
+ import { ByteReader, ByteWriter, equalBytes } from "./bytes.js";
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;
@@ -18,6 +20,9 @@ export const SESSION_TERMINAL_TAG = 0x0c;
18
20
  export const SESSION_ERROR_TAG = 0x0d;
19
21
  export const SEAT_AUTHORIZATION_CHALLENGE_TAG = 0x0f;
20
22
  export const SEAT_AUTHORIZATION_RESPONSE_TAG = 0x10;
23
+ export const PREDICTION_GATE_RELEASED_TAG = 0x11;
24
+ export const PREDICTION_GATE_PREPARED_TAG = 0x12;
25
+ export const PREDICTION_GATE_OPENED_TAG = 0x13;
21
26
  export const MAX_TRANSPORT_FRAME_BYTES = 1 << 20;
22
27
  export function encodeAckFrame(context, cursor) {
23
28
  const sequence = typeof cursor === "object" ? cursor.sequence : cursor;
@@ -50,6 +55,10 @@ export function decodeEnvelope(envelope) {
50
55
  throw new Error("session frame exceeds transport ceiling");
51
56
  return new Uint8Array(wire);
52
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;
53
62
  /* The authority's session-protocol error tags, by name.
54
63
  *
55
64
  * `arena-session/src/wire.rs` numbers `SessionProtocolError` on the wire and
@@ -97,9 +106,11 @@ export function sessionErrorHint(tag) {
97
106
  }
98
107
  }
99
108
  function readContext(reader, wireVersion) {
109
+ const sessionVersion = reader.readU16("session version");
110
+ requireSessionVersion(sessionVersion);
100
111
  return {
101
112
  wireVersion,
102
- sessionVersion: reader.readU16("session version"),
113
+ sessionVersion,
103
114
  sessionId: reader.readFixed(32, "session id"),
104
115
  executionId: reader.readFixed(32, "execution id"),
105
116
  executionManifestDigest: reader.readFixed(32, "execution manifest digest"),
@@ -145,6 +156,13 @@ function readView(reader) {
145
156
  const legal = readPayload(reader, "legal action schema byte length", "legal action schema bytes");
146
157
  const participantDeadlineMs = reader.readU64("participant deadline milliseconds");
147
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");
148
166
  return {
149
167
  state,
150
168
  participantView: participantView.bytes,
@@ -155,6 +173,156 @@ function readView(reader) {
155
173
  latestReceipt,
156
174
  };
157
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
+ }
158
326
  function readPolicy(reader) {
159
327
  return {
160
328
  maxParticipantViewBytes: reader.readU64("maximum participant view bytes"),
@@ -183,12 +351,28 @@ function skipAuthorization(reader) {
183
351
  throw new Error(`invalid authorization principal tag ${principalTag}`);
184
352
  }
185
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. */
186
367
  function readActionRejection(reader) {
187
368
  const tag = reader.readByte("action rejection tag");
369
+ if (tag >= REJECTION_RETRY.length)
370
+ throw new Error(`invalid action rejection tag ${tag}`);
188
371
  if (tag === 0 || tag === 6)
189
372
  reader.readU16("action rejection extra");
190
- if (tag > 13)
191
- 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]}`);
192
376
  }
193
377
  function readSeatAuthChallenge(reader, wireVersion) {
194
378
  const context = readContext(reader, wireVersion);
@@ -209,9 +393,20 @@ function readSeatAuthChallenge(reader, wireVersion) {
209
393
  export function decodeAuthorityMessage(bytes) {
210
394
  const reader = new ByteReader(bytes);
211
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}`);
212
400
  const tag = reader.readByte("message tag");
213
401
  if (tag === SESSION_ERROR_TAG) {
214
- readOption(reader, "session version option", () => reader.readU16("session version"));
402
+ // A present selected version is the authority's answer for this session,
403
+ // so it has to be one this build can speak; the unsupported-version
404
+ // payload read below is a raw diagnostic and stays readable without one.
405
+ readOption(reader, "session version option", () => {
406
+ const version = reader.readU16("session version");
407
+ requireSessionVersion(version);
408
+ return version;
409
+ });
215
410
  const errorTag = reader.readByte("session protocol error tag");
216
411
  if (errorTag === 0 || errorTag === 1)
217
412
  reader.readU16("unsupported version");
@@ -237,8 +432,12 @@ export function decodeAuthorityMessage(bytes) {
237
432
  switch (tag) {
238
433
  case SESSION_JOINED_TAG: {
239
434
  const policy = readPolicy(reader);
240
- 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));
241
438
  const cursor = readCursor(reader);
439
+ if (view)
440
+ requireViewCursor(view, cursor);
242
441
  message = {
243
442
  type: "sessionJoined",
244
443
  context,
@@ -252,6 +451,7 @@ export function decodeAuthorityMessage(bytes) {
252
451
  case PARTICIPANT_VIEW_TAG: {
253
452
  const view = readView(reader);
254
453
  const cursor = readCursor(reader);
454
+ requireViewCursor(view, cursor);
255
455
  message = { type: "participantView", context, sequence, view, cursor };
256
456
  break;
257
457
  }
@@ -271,9 +471,9 @@ export function decodeAuthorityMessage(bytes) {
271
471
  case ACTION_REJECTED_TAG: {
272
472
  const actionId = reader.readFixed(32, "action id");
273
473
  readActionRejection(reader);
274
- reader.readByte("retry disposition");
275
474
  const view = readView(reader);
276
475
  const cursor = readCursor(reader);
476
+ requireViewCursor(view, cursor);
277
477
  message = {
278
478
  type: "actionRejected",
279
479
  context,
@@ -289,6 +489,11 @@ export function decodeAuthorityMessage(bytes) {
289
489
  const receipt = readReceipt(reader);
290
490
  const view = readView(reader);
291
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);
292
497
  message = {
293
498
  type: "actionCommitted",
294
499
  context,
@@ -301,10 +506,19 @@ export function decodeAuthorityMessage(bytes) {
301
506
  break;
302
507
  }
303
508
  case SESSION_RESUMED_TAG: {
304
- reader.readU64("replay from sequence");
305
- const view = readView(reader);
509
+ const replayFrom = reader.readU64("replay from sequence");
510
+ const view = readOption(reader, "resumed view option", () => readView(reader));
306
511
  const cursor = readCursor(reader);
307
- 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
+ };
308
522
  break;
309
523
  }
310
524
  case SESSION_TERMINAL_TAG: {
@@ -312,6 +526,11 @@ export function decodeAuthorityMessage(bytes) {
312
526
  const finalState = readState(reader, "final state nonce", "final state commitment");
313
527
  const finalReceipt = readOption(reader, "final receipt option", () => readReceipt(reader));
314
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");
315
534
  message = {
316
535
  type: "sessionTerminal",
317
536
  context,
@@ -323,6 +542,42 @@ export function decodeAuthorityMessage(bytes) {
323
542
  };
324
543
  break;
325
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
+ }
569
+ case PREDICTION_GATE_RELEASED_TAG: {
570
+ const release = readPredictionGateRelease(reader);
571
+ const cursor = readCursor(reader);
572
+ message = {
573
+ type: "predictionGateReleased",
574
+ context,
575
+ sequence,
576
+ release,
577
+ cursor,
578
+ };
579
+ break;
580
+ }
326
581
  default:
327
582
  throw new Error(`unknown authority message tag ${tag}`);
328
583
  }
@@ -371,3 +626,402 @@ export function takePrincipalProof(bytes) {
371
626
  rest: bytes.slice(reader.consumed()),
372
627
  };
373
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
+ }