@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.
@@ -1,8 +1,7 @@
1
1
  import type { AgentKeypair } from "./keypair.js";
2
2
  import type { TexasAction, TexasLegalActions } from "./texas.js";
3
- import type { AuthorityMessage } from "./sessionCodec.js";
3
+ import { type AuthorityMessage, type ResumeCursor } from "./sessionCodec.js";
4
4
  import type { SessionContext } from "./sessionWire.js";
5
- import type { ResumeCursor } from "./sessionCodec.js";
6
5
  import { type OpenTableSeatView, type OpenTableTalkLine, type OpenTableView, type SeatSession, type OpenSittingStatus } from "./session.js";
7
6
  import { type SeatSessionState } from "./seatState.js";
8
7
  export interface SeatTurnArgs {
@@ -11,6 +10,19 @@ export interface SeatTurnArgs {
11
10
  agentId: Uint8Array;
12
11
  fetchImpl?: typeof fetch;
13
12
  }
13
+ /** Persist the accepted boundary before it is acknowledged.
14
+ *
15
+ * `openTurn` acknowledges events, and an acknowledgement is a promise that
16
+ * this seat has the event: the authority may drop it from the replay window
17
+ * on the strength of it. Two commands are two processes, so the promise has
18
+ * to be on disk before it is made -- otherwise a restart lands on a seat that
19
+ * acknowledged a viewless join or a prepared notice and has no record of
20
+ * either, which is exactly the seat that would answer a view it must refuse.
21
+ *
22
+ * Supplied by the CLI from `saveSeatState`. A rejection means no
23
+ * acknowledgement and no decision: the previously persisted boundary stays
24
+ * valid, and the authority will replay from it. */
25
+ export type SeatStateCheckpoint = (state: SeatSessionState) => void | Promise<void>;
14
26
  /** What this seat is looking at, in the shape a reader can act on. */
15
27
  export interface SeatTurnPosition {
16
28
  seat: number;
@@ -121,6 +133,7 @@ export declare function attachFailureOutcome(error: unknown, sitting: OpenSittin
121
133
  * an answer, not a failure. */
122
134
  export declare function openTurn(args: SeatTurnArgs & {
123
135
  waitMs?: number;
136
+ checkpoint: SeatStateCheckpoint;
124
137
  }): Promise<SeatTurnOutcome>;
125
138
  export interface SubmitTurnResult {
126
139
  committed: boolean;
package/dist/seatTurn.js CHANGED
@@ -2,8 +2,9 @@ import { randomBytes } from "node:crypto";
2
2
  import { toHex0x } from "./bytes.js";
3
3
  import { acceptAndAwaitAdmission, readOffer } from "./offer.js";
4
4
  import { decodeLegalActions, decodeParticipantView, encodeAction, } from "./texas.js";
5
+ import { admitAuthorityEvent, equalReceiptRef, freshSessionBoundary, predictionGatePreparationOf, predictionGateTargetReceipt, } from "./sessionCodec.js";
5
6
  import { agentReadCapability, openSeatSession, readPublicTable, readTableTalk, turnIsStillOpen, SessionRefusal, afterRefusal, readSittingStatus, } from "./session.js";
6
- import { decodeContext, decodeCursor, decodeView, encodeContext, encodeCursor, encodeView, } from "./seatState.js";
7
+ import { decodeContext, decodeCursor, decodeGate, decodeView, encodeContext, encodeCursor, encodeGate, encodeView, } from "./seatState.js";
7
8
  /** Whether `view` is a turn this seat has already answered.
8
9
  *
9
10
  * `act` does not move the cursor, so the next `turn` resumes from before the
@@ -87,6 +88,12 @@ export async function attach(session, state, reopen) {
87
88
  clientNonce: toHex0x(nonce),
88
89
  context: joined.context ? encodeContext(joined.context) : null,
89
90
  token: opened.client.token ?? null,
91
+ /* A fresh admission resets the gate boundary with everything else:
92
+ the phase belonged to the session that just ended. */
93
+ cursor: { sequence: "0", witnessedReceipt: null },
94
+ view: null,
95
+ predictionGate: null,
96
+ awaitingGatePrefix: false,
90
97
  },
91
98
  context: joined.context ?? null,
92
99
  cursor: { sequence: 0n, witnessedReceipt: null },
@@ -124,13 +131,21 @@ const holeFromView = (view) => {
124
131
  return null;
125
132
  }
126
133
  };
127
- function persisted(session, state, context, cursor, view) {
134
+ /** The file as it stands after one accepted event: the session it belongs to,
135
+ * the boundary that was accepted, and the position to answer if there is one.
136
+ * The gate phase travels with the cursor because they are one promise - a
137
+ * cursor that outran its phase is a seat that forgot it was held. */
138
+ function persisted(session, state, context, cursor, view, boundary) {
128
139
  return {
129
140
  ...state,
130
141
  context: context ? encodeContext(context) : state.context,
131
142
  token: session.client.token ?? state.token,
132
143
  cursor: encodeCursor(cursor),
133
144
  view: view ? encodeView(view) : state.view,
145
+ predictionGate: boundary.predictionGate
146
+ ? encodeGate(boundary.predictionGate)
147
+ : null,
148
+ awaitingGatePrefix: boundary.awaitingGatePrefix,
134
149
  };
135
150
  }
136
151
  /** What `turn` answers when it could not open a session.
@@ -201,18 +216,66 @@ export async function openTurn(args) {
201
216
  session = attached.session;
202
217
  args = { ...args, state: attached.state };
203
218
  let { context, cursor, messages } = attached;
219
+ /* What this seat has already accepted, restored from the file: the sequence
220
+ and receipt floor its cursor names, the gate phase it holds, and whether a
221
+ viewless admission still owes its prepared prefix. The view is not
222
+ restored - a restart drops it exactly as
223
+ `ParticipantSessionState::restore` does, and the committed floor carries
224
+ what the boundary still needs. */
225
+ let boundary = {
226
+ ...freshSessionBoundary(),
227
+ context,
228
+ sequence: cursor.sequence,
229
+ receiptFloor: cursor.witnessedReceipt,
230
+ predictionGate: args.state.predictionGate
231
+ ? decodeGate(args.state.predictionGate)
232
+ : null,
233
+ awaitingGatePrefix: args.state.awaitingGatePrefix,
234
+ };
204
235
  const until = Date.now() + (args.waitMs ?? 0);
236
+ /* Accept, persist, acknowledge - in that order, for every event this door
237
+ takes. The acknowledgement tells the authority this seat holds the event,
238
+ and it may drop the event from the replay window on the strength of it; a
239
+ promise made before the file is on disk is one a restart cannot keep.
240
+
241
+ A refusal from `admitAuthorityEvent` throws out of here with nothing
242
+ written and nothing acknowledged, which is what leaves the previously
243
+ persisted boundary resumable. A re-delivery answers `null`: it was
244
+ persisted and acknowledged by the process that first applied it, and
245
+ writing its cursor again would walk this seat's boundary backwards. */
246
+ const accept = async (message, view) => {
247
+ const admitted = admitAuthorityEvent(boundary, message);
248
+ if (admitted.disposition !== "applied")
249
+ return null;
250
+ const originToken = session.client.token;
251
+ if (!originToken)
252
+ throw new Error("session token missing");
253
+ /* A fresh join is a new session: the view the previous one printed
254
+ describes a turn that no longer exists. */
255
+ const previous = message.type === "sessionJoined"
256
+ ? { ...args.state, view: null }
257
+ : args.state;
258
+ const state = persisted(session, previous, message.context, message.cursor, view, admitted.boundary);
259
+ await args.checkpoint(state);
260
+ boundary = admitted.boundary;
261
+ context = message.context;
262
+ cursor = message.cursor;
263
+ args = { ...args, state };
264
+ await session.client.acknowledge(message.context, message.cursor, originToken);
265
+ return state;
266
+ };
205
267
  for (;;) {
206
268
  for (const message of messages) {
207
269
  if (message.type === "error" && !message.retryable)
208
270
  throw new SessionRefusal(message);
209
- if ("context" in message)
210
- context = message.context;
271
+ /* Acknowledgements, pendings, errors and challenges carry no boundary. */
272
+ if (!("cursor" in message))
273
+ continue;
211
274
  if (message.type === "sessionTerminal") {
212
- const originToken = session.client.token;
213
- if (!originToken)
214
- throw new Error("session token missing");
215
- await session.client.acknowledge(message.context, message.cursor, originToken);
275
+ const state = await accept(message, null);
276
+ /* A re-delivered terminal is this file's own last event: a resume at
277
+ the terminal cursor repeats it, so there is nothing to write or
278
+ promise again - and the sitting is still over. */
216
279
  return {
217
280
  kind: "terminal",
218
281
  /* Carried out rather than dropped: `consent` takes exactly these two,
@@ -220,21 +283,21 @@ export async function openTurn(args) {
220
283
  two-command tour had no settle at all. */
221
284
  terminalNonce: message.finalState.nonce.toString(),
222
285
  terminalCommitment: toHex0x(message.finalState.commitment),
223
- state: persisted(session, args.state, context, message.cursor, null),
286
+ state: state ?? args.state,
224
287
  };
225
288
  }
226
- if (message.type === "predictionGateReleased") {
227
- const originToken = session.client.token;
228
- if (!originToken)
229
- throw new Error("session token missing");
230
- await session.client.acknowledge(message.context, message.cursor, originToken);
231
- cursor = message.cursor;
232
- continue;
233
- }
289
+ /* A named notice and a viewless admission are acknowledged and waited
290
+ on: no private view arrived, so there is nothing here to answer and
291
+ nothing to decide. The phase they carry is persisted first. */
234
292
  const view = "view" in message ? message.view : null;
235
- if (!view || !("cursor" in message))
293
+ const state = await accept(message, view);
294
+ /* A re-delivery is not a turn: the view it carries was answered or
295
+ waited out by the process that applied it, and nothing was written
296
+ here to hand back. */
297
+ if (!state)
298
+ continue;
299
+ if (!view)
236
300
  continue;
237
- cursor = message.cursor;
238
301
  /* A view with no legal actions is the table moving without this seat.
239
302
  One whose deadline has passed is this seat's turn already lost, and
240
303
  answering it would sign for a turn that is closed. And one at or before
@@ -275,11 +338,11 @@ export async function openTurn(args) {
275
338
  deadlineMs: view.participantDeadlineMs.toString(),
276
339
  msRemaining: Number(view.participantDeadlineMs) - Date.now(),
277
340
  },
278
- state: persisted(session, args.state, context, cursor, view),
341
+ state,
279
342
  };
280
343
  }
281
344
  if (Date.now() >= until) {
282
- const waiting = persisted(session, args.state, context, cursor, null);
345
+ const waiting = persisted(session, args.state, context, cursor, null, boundary);
283
346
  /* Asked only here, where the loop is about to say "waiting" anyway: a
284
347
  seat with no chips is not waiting for a turn, it is out, and a caller
285
348
  polling `waiting` at the default `--wait 0` would spin at full rate for
@@ -309,6 +372,18 @@ export async function submitTurn(args) {
309
372
  const view = decodeView(args.state.view);
310
373
  if (!turnIsStillOpen(view, Date.now()))
311
374
  throw new Error("the deadline for that turn has passed; run `turn` again for the next one");
375
+ /* A retained gate that still holds this seat's own turn means the authority
376
+ has not released it: answering the stored position would be inferring on
377
+ a turn nobody may act in yet. `turn` refuses to hand such a view out, so
378
+ this only fires on a stored position the gate later reclaimed. */
379
+ const retained = args.state.predictionGate
380
+ ? decodeGate(args.state.predictionGate)
381
+ : null;
382
+ if (retained &&
383
+ retained.phase !== "released" &&
384
+ equalReceiptRef(predictionGateTargetReceipt(retained), view.latestReceipt) &&
385
+ predictionGatePreparationOf(retained)?.actingSeat === args.state.seat)
386
+ throw new Error("this turn is held behind a prediction gate; run `turn` again once it releases");
312
387
  const openWith = (clientNonce) => openSeatSession({
313
388
  productUrl: args.state.productUrl,
314
389
  offerId: args.state.offerId,
@@ -438,5 +513,7 @@ export function newSeatState(productUrl, offerId, seat) {
438
513
  token: null,
439
514
  cursor: { sequence: "0", witnessedReceipt: null },
440
515
  view: null,
516
+ predictionGate: null,
517
+ awaitingGatePrefix: false,
441
518
  };
442
519
  }
package/dist/session.js CHANGED
@@ -3,7 +3,7 @@ import { blake2b256 } from "./crypto.js";
3
3
  import { equalBytes, fromHex, textBytes, toHex0x } from "./bytes.js";
4
4
  import { AGENT_HTTP_CAPABILITY_HEADER, mintAgentHttpCapability, } from "./agentHttp.js";
5
5
  import { signRaw } from "./keypair.js";
6
- import { MAX_TRANSPORT_FRAME_BYTES, decodeAuthorityMessage, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
6
+ import { MAX_TRANSPORT_FRAME_BYTES, PredictionGateConflictError, SessionBoundaryError, admitAuthorityEvent, decodeAuthorityMessage, freshSessionBoundary, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
7
7
  import { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, requireSessionVersion, resumeSigningBytes, SESSION_VERSION, UnsupportedSessionVersionError, } from "./sessionWire.js";
8
8
  import { decodeLegalActions, decodeParticipantView, encodeAction, pickAction, } from "./texas.js";
9
9
  import { authorizeSeatChallenge } from "./seatAuth.js";
@@ -245,7 +245,7 @@ export class SessionClient {
245
245
  typeof ceiling !== "number" ||
246
246
  !Number.isFinite(ceiling) ||
247
247
  ceiling < MAX_TRANSPORT_FRAME_BYTES)
248
- throw new Error("authority discovery does not support participant session V2");
248
+ throw new Error("authority discovery does not support participant session V3");
249
249
  }
250
250
  async answeredDiscovery() {
251
251
  const { boundMs, pauseMs, sleep } = this.discoveryRetry;
@@ -611,6 +611,11 @@ export async function playSeat(args) {
611
611
  let pending = null;
612
612
  let lastContext = null;
613
613
  let lastCursor = { sequence: 0n, witnessedReceipt: null };
614
+ /* What this seat has accepted: the event sequence, the receipt floor, the
615
+ view it was last shown, and the named gate phase it holds. Every event
616
+ goes through it before anything is acknowledged, so the two doors -- this
617
+ loop and `openTurn` -- refuse the same streams for the same reasons. */
618
+ let accepted = freshSessionBoundary();
614
619
  const inbox = [];
615
620
  let joined = await client.join();
616
621
  if (joined.context)
@@ -634,7 +639,17 @@ export async function playSeat(args) {
634
639
  if (message.type === "actionPending" ||
635
640
  message.type === "actionAcknowledged")
636
641
  return null;
637
- if (message.type === "predictionGateReleased") {
642
+ if (message.type === "predictionGatePrepared" ||
643
+ message.type === "predictionGateOpened" ||
644
+ message.type === "predictionGateReleased") {
645
+ /* A named notice moves no receipt and carries no view: it is accepted,
646
+ acknowledged, and waited on. Only the real view that follows a
647
+ release is a turn to decide. */
648
+ const admitted = admitAuthorityEvent(accepted, message);
649
+ if (admitted.disposition !== "applied")
650
+ return null;
651
+ accepted = admitted.boundary;
652
+ lastContext = message.context;
638
653
  await client.acknowledge(message.context, message.cursor, originToken);
639
654
  lastCursor = message.cursor;
640
655
  return null;
@@ -642,8 +657,16 @@ export async function playSeat(args) {
642
657
  if ("context" in message)
643
658
  lastContext = message.context;
644
659
  if (message.type === "sessionTerminal") {
645
- await client.acknowledge(message.context, message.cursor, originToken);
646
- lastCursor = message.cursor;
660
+ const admitted = admitAuthorityEvent(accepted, message);
661
+ /* A re-delivered terminal is this boundary's own last event - a resume
662
+ at the terminal cursor repeats it. There is nothing to apply and its
663
+ acknowledgement is spent, but the sitting is over either way and the
664
+ caller still needs what `consent` takes. */
665
+ if (admitted.disposition === "applied") {
666
+ accepted = admitted.boundary;
667
+ await client.acknowledge(message.context, message.cursor, originToken);
668
+ lastCursor = message.cursor;
669
+ }
647
670
  return {
648
671
  outcome: "terminal",
649
672
  committedActions,
@@ -660,52 +683,61 @@ export async function playSeat(args) {
660
683
  }
661
684
  let view = null;
662
685
  let cursor = null;
663
- if (message.type === "sessionJoined") {
664
- view = message.view;
665
- cursor = message.cursor;
666
- }
667
- else if (message.type === "participantView") {
668
- view = message.view;
669
- cursor = message.cursor;
670
- }
671
- else if (message.type === "actionCommitted") {
672
- pending = null;
673
- committedActions += 1;
674
- view = message.view;
675
- cursor = message.cursor;
676
- }
677
- else if (message.type === "actionRejected") {
678
- pending = null;
679
- view = message.view;
680
- cursor = message.cursor;
681
- }
682
- else if (message.type === "sessionResumed") {
686
+ if (message.type === "sessionJoined" ||
687
+ message.type === "participantView" ||
688
+ message.type === "actionCommitted" ||
689
+ message.type === "actionRejected" ||
690
+ message.type === "sessionResumed") {
683
691
  view = message.view;
684
692
  cursor = message.cursor;
685
693
  }
686
- if (view &&
687
- view.legalActions.length === 0 &&
688
- !elimination.eliminated &&
689
- Date.now() - lastIdleTableReadMs >= IDLE_TABLE_READ_MS) {
690
- /* Nothing to decide: somebody else's turn, a hand this seat folded, or
691
- a sitting this seat is out of. Read now and then, so the hand lines go
692
- on and a seat that has lost its last chip says so once. */
693
- lastIdleTableReadMs = Date.now();
694
- const idle = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
695
- if (idle && idle.handNumber !== lastHandSeen) {
696
- lastHandSeen = idle.handNumber;
697
- args.onHand?.({
698
- number: idle.handNumber,
699
- stack: idle.seats.find((entry) => entry.seat === args.seat)?.stack ?? null,
700
- });
694
+ if (cursor !== null && lastContext) {
695
+ /* Accepted before anything else happens: a refusal here leaves the
696
+ retained boundary untouched and nothing acknowledged. */
697
+ const admitted = admitAuthorityEvent(accepted, message);
698
+ /* A re-delivery is the suffix a resume repeats. It was applied and
699
+ acknowledged once, so nothing here moves for it: no view, no pending
700
+ resolution, no commit count, no table read, no decision, no
701
+ acknowledgement. */
702
+ if (admitted.disposition !== "applied")
703
+ return null;
704
+ accepted = admitted.boundary;
705
+ /* Resolved only for the applied event, so a replayed commit cannot
706
+ count twice or drop a proposal that is still in flight. */
707
+ if (message.type === "actionCommitted") {
708
+ pending = null;
709
+ committedActions += 1;
701
710
  }
702
- if (elimination.observe(idle))
703
- args.onEliminated?.();
704
- }
705
- if (view && cursor !== null && lastContext) {
706
- const nonceKey = view.state.nonce.toString();
711
+ else if (message.type === "actionRejected")
712
+ pending = null;
713
+ if (view &&
714
+ view.legalActions.length === 0 &&
715
+ !elimination.eliminated &&
716
+ Date.now() - lastIdleTableReadMs >= IDLE_TABLE_READ_MS) {
717
+ /* Nothing to decide: somebody else's turn, a hand this seat folded, or
718
+ a sitting this seat is out of. Read now and then, so the hand lines go
719
+ on and a seat that has lost its last chip says so once. */
720
+ lastIdleTableReadMs = Date.now();
721
+ const idle = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
722
+ if (idle && idle.handNumber !== lastHandSeen) {
723
+ lastHandSeen = idle.handNumber;
724
+ args.onHand?.({
725
+ number: idle.handNumber,
726
+ stack: idle.seats.find((entry) => entry.seat === args.seat)?.stack ??
727
+ null,
728
+ });
729
+ }
730
+ if (elimination.observe(idle))
731
+ args.onEliminated?.();
732
+ }
733
+ const nonceKey = view?.state.nonce.toString() ?? "";
707
734
  const now = BigInt(Date.now());
708
- if (view.legalActions.length > 0 &&
735
+ if (
736
+ /* A viewless admission is acknowledged and waited on: a held seat has
737
+ nothing to answer, and nothing to infer on, until its gate releases
738
+ and the real view arrives. */
739
+ view !== null &&
740
+ view.legalActions.length > 0 &&
709
741
  !acted.has(nonceKey) &&
710
742
  view.participantDeadlineMs > now) {
711
743
  const legal = decodeLegalActions(view.legalActions);
@@ -876,6 +908,12 @@ export async function playSeat(args) {
876
908
  look successful while the seat cannot read the gated stream. */
877
909
  if (error instanceof UnsupportedSessionVersionError)
878
910
  throw error;
911
+ /* Nor is a refused projection: the event the authority sent is one this
912
+ contract forbids, and resuming would fetch the same bytes again while
913
+ the loop reported it as flaky transport. */
914
+ if (error instanceof SessionBoundaryError ||
915
+ error instanceof PredictionGateConflictError)
916
+ throw error;
879
917
  if (!lastContext || reconnects >= 5)
880
918
  throw error;
881
919
  /* Three refusals, three answers. A gone session (`UnknownSession`, or a
@@ -907,6 +945,12 @@ export async function playSeat(args) {
907
945
  if (joined.context)
908
946
  lastContext = joined.context;
909
947
  lastCursor = { sequence: 0n, witnessedReceipt: null };
948
+ /* A rejoin is a new session: nothing the old one retained - its phase,
949
+ its floor, its sequence - describes this one, and only a boundary
950
+ this loop opened itself may be reset. The stale inbox belongs to
951
+ the session that is gone. */
952
+ accepted = freshSessionBoundary();
953
+ inbox.length = 0;
910
954
  inbox.push(...joined.messages);
911
955
  rejoins += 1;
912
956
  reconnects += 1;
@@ -11,6 +11,8 @@ export declare const SESSION_ERROR_TAG = 13;
11
11
  export declare const SEAT_AUTHORIZATION_CHALLENGE_TAG = 15;
12
12
  export declare const SEAT_AUTHORIZATION_RESPONSE_TAG = 16;
13
13
  export declare const PREDICTION_GATE_RELEASED_TAG = 17;
14
+ export declare const PREDICTION_GATE_PREPARED_TAG = 18;
15
+ export declare const PREDICTION_GATE_OPENED_TAG = 19;
14
16
  export declare const MAX_TRANSPORT_FRAME_BYTES: number;
15
17
  export interface WireEnvelope {
16
18
  message: string;
@@ -57,12 +59,80 @@ export interface SeatAuthChallenge {
57
59
  coordinatorProof: Uint8Array;
58
60
  coordinatorPublicKey: Uint8Array;
59
61
  }
62
+ /** ADR-0096's ceiling on added pacing for one next-action prediction window,
63
+ * as `arena_session::prediction_gate::MAX_PREDICTION_PACING_MS`. */
64
+ export declare const MAX_PREDICTION_PACING_MS = 25000n;
65
+ /** The in-play window a gate names. The session reuses this identity; it
66
+ * never mints a second one. */
67
+ export interface PredictionWindowRef {
68
+ windowId: bigint;
69
+ marketId: bigint;
70
+ /** The only contract kind a gate can hold a seat behind (wire tag 1). */
71
+ contract: "pokerActionV1";
72
+ }
73
+ /** A durably committed preparation: the frozen window identity, the target
74
+ * the acting seat is held on, its original committed deadline, and the
75
+ * persisted instant publication started. */
76
+ export interface PredictionGatePreparation {
77
+ window: PredictionWindowRef;
78
+ actingSeat: number;
79
+ state: StateRef;
80
+ receipt: ReceiptRef | null;
81
+ originalDeadlineMs: bigint;
82
+ preparedAtMs: bigint;
83
+ }
84
+ /** Acknowledged publication. `closesAtMs` is the service-committed close
85
+ * instant, never the action deadline. */
86
+ export interface PredictionGateOpening {
87
+ preparation: PredictionGatePreparation;
88
+ openedAtMs: bigint;
89
+ closesAtMs: bigint;
90
+ }
91
+ /** The terminal overlay: how the gate closed, and the bounded same-receipt
92
+ * deadline it admits. `lockCappedMs` and `participantDeadlineMs` are derived
93
+ * the way the shared crate derives them, so a recipient never has to repeat
94
+ * the arithmetic to know which later deadline is legal. */
95
+ export interface PredictionGateRelease {
96
+ window: PredictionWindowRef;
97
+ actingSeat: number;
98
+ state: StateRef;
99
+ receipt: ReceiptRef | null;
100
+ terminal: "locked" | "cancelled";
101
+ originalDeadlineMs: bigint;
102
+ arrivalMs: bigint;
103
+ lockedAtMs: bigint;
104
+ lockCappedMs: bigint;
105
+ budgetMs: bigint;
106
+ participantDeadlineMs: bigint;
107
+ }
108
+ /** The gate facts a participant retains for one revision. */
109
+ export type PredictionGateStatus = {
110
+ phase: "prepared";
111
+ preparation: PredictionGatePreparation;
112
+ } | {
113
+ phase: "open";
114
+ opening: PredictionGateOpening;
115
+ } | {
116
+ phase: "released";
117
+ release: PredictionGateRelease;
118
+ };
119
+ /** One named lifecycle step, as a value: what the fold below applies. */
120
+ export type PredictionGateMessage = {
121
+ kind: "prepared";
122
+ preparation: PredictionGatePreparation;
123
+ } | {
124
+ kind: "opened";
125
+ opening: PredictionGateOpening;
126
+ } | {
127
+ kind: "released";
128
+ release: PredictionGateRelease;
129
+ };
60
130
  export type AuthorityMessage = {
61
131
  type: "sessionJoined";
62
132
  context: SessionContext;
63
133
  sequence: bigint;
64
134
  policy: SessionPolicy;
65
- view: ViewSnapshot;
135
+ view: ViewSnapshot | null;
66
136
  cursor: ResumeCursor;
67
137
  } | {
68
138
  type: "participantView";
@@ -98,7 +168,10 @@ export type AuthorityMessage = {
98
168
  type: "sessionResumed";
99
169
  context: SessionContext;
100
170
  sequence: bigint;
101
- view: ViewSnapshot;
171
+ /** The boundary the authority replayed from. It must precede the event
172
+ itself, or the resume names a suffix it cannot have replayed. */
173
+ replayFrom: bigint;
174
+ view: ViewSnapshot | null;
102
175
  cursor: ResumeCursor;
103
176
  } | {
104
177
  type: "sessionTerminal";
@@ -108,10 +181,23 @@ export type AuthorityMessage = {
108
181
  finalState: StateRef;
109
182
  cursor: ResumeCursor;
110
183
  finalReceipt: ReceiptRef | null;
184
+ } | {
185
+ type: "predictionGatePrepared";
186
+ context: SessionContext;
187
+ sequence: bigint;
188
+ preparation: PredictionGatePreparation;
189
+ cursor: ResumeCursor;
190
+ } | {
191
+ type: "predictionGateOpened";
192
+ context: SessionContext;
193
+ sequence: bigint;
194
+ opening: PredictionGateOpening;
195
+ cursor: ResumeCursor;
111
196
  } | {
112
197
  type: "predictionGateReleased";
113
198
  context: SessionContext;
114
199
  sequence: bigint;
200
+ release: PredictionGateRelease;
115
201
  cursor: ResumeCursor;
116
202
  } | {
117
203
  type: "error";
@@ -131,6 +217,19 @@ export declare function sessionErrorName(tag: number): string;
131
217
  * join supersedes the older binding, and the older client's next message
132
218
  * finds its session gone. Both used to read as transport flakiness. */
133
219
  export declare function sessionErrorHint(tag: number): string | null;
220
+ export declare function equalStateRef(left: StateRef, right: StateRef): boolean;
221
+ export declare function equalReceiptRef(left: ReceiptRef | null, right: ReceiptRef | null): boolean;
222
+ export declare function equalPredictionGatePreparation(left: PredictionGatePreparation, right: PredictionGatePreparation): boolean;
223
+ export declare function equalPredictionGateRelease(left: PredictionGateRelease, right: PredictionGateRelease): boolean;
224
+ /** The structural refusals of `PredictionGatePreparation::new`, made wherever
225
+ * a preparation enters this process - the wire, or a retained seat state. */
226
+ export declare function bindPredictionGatePreparation(fields: PredictionGatePreparation): PredictionGatePreparation;
227
+ /** The structural refusals of `PredictionGateOpening::new`. */
228
+ export declare function bindPredictionGateOpening(fields: PredictionGateOpening): PredictionGateOpening;
229
+ /** The structural refusals of `PredictionGateRelease::new`, with the bounded
230
+ * overlay derived here rather than trusted from a sender or a stored file:
231
+ * the lock instant capped at the pacing ceiling, plus the budget. */
232
+ export declare function bindPredictionGateRelease(fields: Omit<PredictionGateRelease, "lockCappedMs" | "participantDeadlineMs">): PredictionGateRelease;
134
233
  export declare function decodeAuthorityMessage(bytes: Uint8Array): AuthorityMessage;
135
234
  export declare function encodeSeatAuthSuccessFrame(context: SessionContext, actionId: Uint8Array, signature: Uint8Array): Uint8Array;
136
235
  export declare function extractProtocolInput(payload: Uint8Array): Uint8Array;
@@ -143,3 +242,60 @@ export declare function takePrincipalProof(bytes: Uint8Array): {
143
242
  signature: Uint8Array;
144
243
  rest: Uint8Array;
145
244
  };
245
+ /** A refused phase transition, named as the shared crate names it. */
246
+ export declare class PredictionGateConflictError extends Error {
247
+ readonly conflict: "PredictionGatePhaseConflict" | "PredictionGateReleaseConflict";
248
+ constructor(conflict: "PredictionGatePhaseConflict" | "PredictionGateReleaseConflict", detail: string);
249
+ }
250
+ /** The lifecycle step an authority message carries, or null where it carries
251
+ * none. The fold takes the value, not the envelope. */
252
+ export declare function predictionGateMessage(message: AuthorityMessage): PredictionGateMessage | null;
253
+ /** The committed preparation behind any phase, or null once released - a
254
+ * release carries the identity but not the persisted preparation instant. */
255
+ export declare function predictionGatePreparationOf(status: PredictionGateStatus): PredictionGatePreparation | null;
256
+ /** The receipt the gate targets. A recipient behind it still receives every
257
+ * notice, because each notice cursor names that recipient's own floor. */
258
+ export declare function predictionGateTargetReceipt(status: PredictionGateStatus): ReceiptRef | null;
259
+ /** Whether a newly named preparation supersedes the retained facts.
260
+ *
261
+ * Only a released gate can be superseded: a held gate's turn has not closed,
262
+ * so a second preparation contradicts it. Once released, the next selected
263
+ * turn always targets a later committed state and therefore its own receipt.
264
+ * A seat that is itself withheld for that next turn never witnesses the view
265
+ * that would otherwise retire the closed gate - which is exactly the second
266
+ * window of a hand, held on the seat that was a bystander for the first. A
267
+ * preparation naming the same revision is the same-target conflict, not a
268
+ * supersession. */
269
+ export declare function predictionGateSupersededByPreparation(status: PredictionGateStatus, nextTurn: PredictionGatePreparation): boolean;
270
+ export declare function applyPredictionGateStatus(current: PredictionGateStatus | null, message: PredictionGateMessage): PredictionGateStatus;
271
+ export interface AcceptedSessionBoundary {
272
+ context: SessionContext | null;
273
+ /** The last accepted authority event sequence; zero before the first. */
274
+ sequence: bigint;
275
+ receiptFloor: ReceiptRef | null;
276
+ /** The view last disclosed to this seat, or null while it is withheld. */
277
+ view: ViewSnapshot | null;
278
+ predictionGate: PredictionGateStatus | null;
279
+ /** Whether a viewless admission still owes its named prepared prefix. */
280
+ awaitingGatePrefix: boolean;
281
+ }
282
+ /** A refused event, named as `SessionSemanticError` names it. */
283
+ export declare class SessionBoundaryError extends Error {
284
+ readonly reason: string;
285
+ constructor(reason: string, detail: string);
286
+ }
287
+ /** The boundary a seat that has accepted nothing holds. */
288
+ export declare function freshSessionBoundary(): AcceptedSessionBoundary;
289
+ /** What the seam did with an event.
290
+ *
291
+ * `applied` is the only disposition that moved the boundary, and so the only
292
+ * one a consumer may act on: a re-delivery was applied and acknowledged once
293
+ * already, and acting on it a second time would decide a turn that is over,
294
+ * count a commit twice, or walk the acknowledged cursor backwards. */
295
+ export type AuthorityEventDisposition = "applied" | "replay" | "boundaryless";
296
+ export interface AdmittedAuthorityEvent {
297
+ disposition: AuthorityEventDisposition;
298
+ /** The boundary to retain. Unchanged unless the event was applied. */
299
+ boundary: AcceptedSessionBoundary;
300
+ }
301
+ export declare function admitAuthorityEvent(accepted: AcceptedSessionBoundary, message: AuthorityMessage): AdmittedAuthorityEvent;