@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.
package/dist/session.js CHANGED
@@ -3,8 +3,8 @@ 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 { decodeAuthorityMessage, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
7
- import { actionSigningBytes, encodeActionFrame, encodeJoinFrame, encodeResumeFrame, joinSigningBytes, resumeSigningBytes, } from "./sessionWire.js";
6
+ import { MAX_TRANSPORT_FRAME_BYTES, PredictionGateConflictError, SessionBoundaryError, admitAuthorityEvent, decodeAuthorityMessage, freshSessionBoundary, decodeEnvelope, encodeAckFrame, encodeEnvelope, encodeSeatAuthSuccessFrame, sessionErrorHint, sessionErrorName, } from "./sessionCodec.js";
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";
10
10
  const ACTION_IDENTITY_DOMAIN = textBytes("dopa_open::client::action_identity_v1");
@@ -26,8 +26,41 @@ export function playReportLines(report) {
26
26
  lines.push(`rejoins ${report.rejoins}`);
27
27
  if (report.said > 0 || report.saidRefused > 0)
28
28
  lines.push(`said ${report.said} refused ${report.saidRefused}`);
29
+ lines.push(`eliminated ${report.eliminated}`);
29
30
  return lines;
30
31
  }
32
+ /** How often a seat with nothing to decide reads the public table.
33
+ *
34
+ * A seat that is out of the hand, or out of the sitting, is sent views with no
35
+ * legal actions for as long as the others play, and the table used to be read
36
+ * only when there was a decision to make -- so a busted seat read nothing and
37
+ * printed nothing for the rest of the sitting. Slow, because the read is the
38
+ * same one the anonymous cap counts. */
39
+ export const IDLE_TABLE_READ_MS = 5_000;
40
+ /** Watches the public table for this seat losing its last chip.
41
+ *
42
+ * Out means no chips and not all in: a seat all in with nothing behind is
43
+ * still in the hand, and only once the hand has settled against it does its
44
+ * stack read zero with no bet out. `observe` answers true exactly once, the
45
+ * first time it sees that, so the line it drives is printed once however many
46
+ * hands the others go on to play. */
47
+ export function eliminationWatch(seat) {
48
+ let out = false;
49
+ return {
50
+ get eliminated() {
51
+ return out;
52
+ },
53
+ observe(table) {
54
+ if (out || !table)
55
+ return false;
56
+ const mine = table.seats.find((entry) => entry.seat === seat);
57
+ if (!mine || mine.stack !== 0 || mine.allIn)
58
+ return false;
59
+ out = true;
60
+ return true;
61
+ },
62
+ };
63
+ }
31
64
  /* A session-protocol refusal, with the tag kept on it.
32
65
  *
33
66
  * `postWire` and the message loop used to throw a plain Error whose text named
@@ -50,6 +83,30 @@ export class SessionRefusal extends Error {
50
83
  const UNKNOWN_SESSION = 6;
51
84
  const INVALID_RESUME_CURSOR = 7;
52
85
  const SESSION_SUPERSEDED = 10;
86
+ /** The shared binding's discovery read bound (`MAX_DISCOVERY_RESPONSE_BYTES`).
87
+ * A document larger than this is not a discovery document this client reads. */
88
+ const MAX_DISCOVERY_RESPONSE_BYTES = 4096;
89
+ /** How long a seat keeps asking for discovery from an origin that is not
90
+ * answering: long enough to outlast a deployment restarting behind it, short
91
+ * enough that a seat whose authority is truly gone still stops. */
92
+ export const DISCOVERY_RETRY_BOUND_MS = 90_000;
93
+ const DISCOVERY_RETRY_PAUSE_MS = 1_000;
94
+ /** Statuses an origin gives while it is coming back, rather than a verdict on
95
+ * whether it can host the session. */
96
+ function discoveryMayAnswerLater(status) {
97
+ return (status === 404 ||
98
+ status === 408 ||
99
+ status === 425 ||
100
+ status === 429 ||
101
+ status >= 500);
102
+ }
103
+ class SessionDiscoveryRefused extends Error {
104
+ status;
105
+ constructor(status) {
106
+ super(`session discovery failed (${status})`);
107
+ this.status = status;
108
+ }
109
+ }
53
110
  /** Whether this turn is still open at `nowMs`.
54
111
  *
55
112
  * A predicate rather than a comparison in the loop, for the same reason
@@ -93,7 +150,10 @@ export async function chooseSeatAction(legal, view, strategy, decide, context =
93
150
  legal,
94
151
  view,
95
152
  seat: own.receivingSeat,
96
- hole: own.holeCards,
153
+ hole: own.holeCards
154
+ ? [own.holeCards[0].label, own.holeCards[1].label]
155
+ : null,
156
+ holeCards: own.holeCards,
97
157
  executionId: context.executionId,
98
158
  table,
99
159
  seats: table?.seats ?? [],
@@ -145,9 +205,91 @@ export class SessionClient {
145
205
  this.clientNonce = clientNonce;
146
206
  this.fetchImpl = fetchImpl;
147
207
  }
208
+ /** How long discovery keeps asking an authority that is not answering.
209
+ *
210
+ * Public so a test can shorten it; nothing else should need to. */
211
+ discoveryRetry = {
212
+ boundMs: DISCOVERY_RETRY_BOUND_MS,
213
+ pauseMs: DISCOVERY_RETRY_PAUSE_MS,
214
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
215
+ };
148
216
  url(path) {
149
217
  return `${this.baseUrl.replace(/\/$/, "")}${path}`;
150
218
  }
219
+ /** Requires the authority's published discovery to host the current session
220
+ * contract, before any challenge, signature, or session request.
221
+ *
222
+ * Discovery is a compatibility predicate over published facts, never an
223
+ * authenticity claim: the join handshake is still the admission decision.
224
+ * The body is read under the same bound the shared binding enforces, so a
225
+ * hostile pre-join answer cannot stream into this process. A document that
226
+ * names no compatible contract is final -- an authority that cannot host
227
+ * the session does not become able to by asking again.
228
+ *
229
+ * An origin that did not answer with a document is another matter, and is
230
+ * asked again within `discoveryRetry`. A seat reconnects at whatever moment
231
+ * its stream dropped, and a deployment restarting behind that origin
232
+ * answers 404, 502 or nothing for a few seconds. Taken as final, one such
233
+ * answer ended a seat mid-sitting while its table played on without it, and
234
+ * the clock folded every turn it had left. */
235
+ async requireCompatibleDiscovery() {
236
+ const response = await this.answeredDiscovery();
237
+ const document = JSON.parse(await this.readBoundedDiscovery(response));
238
+ const wires = document.wire_versions;
239
+ const sessions = document.session_versions;
240
+ const ceiling = document.max_transport_frame_bytes;
241
+ if (!Array.isArray(wires) ||
242
+ !Array.isArray(sessions) ||
243
+ !wires.includes(1) ||
244
+ !sessions.includes(SESSION_VERSION) ||
245
+ typeof ceiling !== "number" ||
246
+ !Number.isFinite(ceiling) ||
247
+ ceiling < MAX_TRANSPORT_FRAME_BYTES)
248
+ throw new Error("authority discovery does not support participant session V3");
249
+ }
250
+ async answeredDiscovery() {
251
+ const { boundMs, pauseMs, sleep } = this.discoveryRetry;
252
+ const until = Date.now() + boundMs;
253
+ for (;;) {
254
+ let refusal;
255
+ try {
256
+ const response = await this.fetchImpl(this.url("/v1/session/discovery"));
257
+ if (response.ok)
258
+ return response;
259
+ if (!discoveryMayAnswerLater(response.status))
260
+ throw new SessionDiscoveryRefused(response.status);
261
+ refusal = `session discovery failed (${response.status})`;
262
+ await response.body?.cancel();
263
+ }
264
+ catch (error) {
265
+ if (error instanceof SessionDiscoveryRefused)
266
+ throw error;
267
+ refusal = `session discovery failed (${error instanceof Error ? error.message : String(error)})`;
268
+ }
269
+ if (Date.now() + pauseMs > until)
270
+ throw new Error(`${refusal}; still refused after ${boundMs} ms`);
271
+ await sleep(pauseMs);
272
+ }
273
+ }
274
+ async readBoundedDiscovery(response) {
275
+ const reader = response.body?.getReader();
276
+ if (!reader)
277
+ return "";
278
+ const chunks = [];
279
+ let total = 0;
280
+ for (;;) {
281
+ const { done, value } = await reader.read();
282
+ if (done)
283
+ break;
284
+ total += value.length;
285
+ if (total > MAX_DISCOVERY_RESPONSE_BYTES) {
286
+ await reader.cancel();
287
+ throw new Error("session discovery exceeds 4096 bytes");
288
+ }
289
+ chunks.push(value);
290
+ }
291
+ return Buffer.concat(chunks).toString("utf8");
292
+ }
151
293
  async challenge() {
152
294
  const response = await this.fetchImpl(this.url("/v1/session/challenge"), {
153
295
  method: "POST",
@@ -165,14 +307,15 @@ export class SessionClient {
165
307
  throw new Error(`challenge must be 74 bytes, got ${bytes.length}`);
166
308
  return new Uint8Array(bytes);
167
309
  }
168
- async postWire(path, wire, withToken) {
310
+ async postWire(path, wire, withToken, tokenOverride) {
169
311
  const headers = {
170
312
  "content-type": "application/json",
171
313
  };
172
314
  if (withToken) {
173
- if (!this.token)
315
+ const token = tokenOverride ?? this.token;
316
+ if (!token)
174
317
  throw new Error("session token missing");
175
- headers["session-token"] = this.token;
318
+ headers["session-token"] = token;
176
319
  }
177
320
  let lastError = "";
178
321
  let lastRefusal = null;
@@ -198,7 +341,11 @@ export class SessionClient {
198
341
  }
199
342
  }
200
343
  }
201
- catch {
344
+ catch (error) {
345
+ /* A selected version this build cannot speak is not a transport
346
+ flake: rethrow it rather than retrying the raw status. */
347
+ if (error instanceof UnsupportedSessionVersionError)
348
+ throw error;
202
349
  /* keep raw body */
203
350
  }
204
351
  if (!retryable)
@@ -212,10 +359,11 @@ export class SessionClient {
212
359
  : new Error(lastError);
213
360
  }
214
361
  async join() {
362
+ await this.requireCompatibleDiscovery();
215
363
  const challenge = await this.challenge();
216
364
  const request = {
217
365
  wireVersion: 1,
218
- supportedSessionVersions: [1],
366
+ supportedSessionVersions: [SESSION_VERSION],
219
367
  executionId: this.executionId,
220
368
  executionManifestDigest: this.executionManifestDigest,
221
369
  participantId: this.participantId,
@@ -236,6 +384,7 @@ export class SessionClient {
236
384
  throw new Error("join did not return a session token");
237
385
  this.token = body.token;
238
386
  }
387
+ this.eventsAfter = 0n;
239
388
  const batch = await this.pollEvents();
240
389
  if (!batch.messages.some((message) => message.type === "sessionJoined"))
241
390
  throw new Error("join did not deliver SessionJoined");
@@ -302,12 +451,17 @@ export class SessionClient {
302
451
  startSubmit(wire) {
303
452
  return this.postWire("/v1/session/actions", wire, true);
304
453
  }
305
- async acknowledge(context, cursor) {
306
- const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true);
454
+ async acknowledge(context, cursor, originToken) {
455
+ const response = await this.postWire("/v1/session/acknowledgements", encodeAckFrame(context, cursor), true, originToken);
307
456
  if (!response.ok)
308
457
  throw new Error(`ack failed (${response.status}): ${await response.text()}`);
309
458
  }
310
459
  async resume(context, cursor) {
460
+ /* A retained context from another session version is not resumable: its
461
+ cursor and token belong to a contract this build does not speak. This
462
+ runs first so no challenge, signature, or request is spent on it. */
463
+ requireSessionVersion(context.sessionVersion);
464
+ await this.requireCompatibleDiscovery();
311
465
  const challenge = await this.challenge();
312
466
  const request = {
313
467
  context,
@@ -323,6 +477,13 @@ export class SessionClient {
323
477
  const headerToken = response.headers.get("session-token");
324
478
  if (headerToken)
325
479
  this.token = headerToken;
480
+ /* A resumed binding delivers from a buffer of its own, indexed from zero;
481
+ `after` is an index into that buffer, not the stream position the last
482
+ binding reached. Kept, it asked a fresh buffer for everything past the
483
+ old index, so every poll came back empty while the authority's queue for
484
+ this seat filled and closed: a seat that resumed once, late in a
485
+ sitting, never saw another turn. */
486
+ this.eventsAfter = 0n;
326
487
  }
327
488
  async answerSeatAuth(challenge, coordinatorKey, timeAuthorityKey, pending) {
328
489
  const preimage = authorizeSeatChallenge({
@@ -340,10 +501,40 @@ export class SessionClient {
340
501
  throw new Error(`seat-authorization failed (${response.status}): ${await response.text()}`);
341
502
  }
342
503
  }
343
- export async function playSeat(args) {
504
+ /** A GET asked again while the origin answers as a restarting one does -- no
505
+ * answer, 408, 429 or a 5xx -- until `bound` has passed. Any other answer is
506
+ * returned as it came, so a refusal stays the caller's to read. */
507
+ export async function fetchWhileRestarting(fetchImpl, url, retry = {
508
+ boundMs: DISCOVERY_RETRY_BOUND_MS,
509
+ pauseMs: DISCOVERY_RETRY_PAUSE_MS,
510
+ }) {
511
+ const sleep = retry.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
512
+ const until = Date.now() + retry.boundMs;
513
+ for (;;) {
514
+ const lastChance = Date.now() + retry.pauseMs > until;
515
+ try {
516
+ const response = await fetchImpl(url);
517
+ const restarting = response.status === 408 ||
518
+ response.status === 429 ||
519
+ response.status >= 500;
520
+ if (!restarting || lastChance)
521
+ return response;
522
+ await response.body?.cancel();
523
+ }
524
+ catch (error) {
525
+ if (lastChance)
526
+ throw error;
527
+ }
528
+ await sleep(retry.pauseMs);
529
+ }
530
+ }
531
+ export async function openSeatSession(args) {
344
532
  const fetchImpl = args.fetchImpl ?? fetch;
345
533
  const product = args.productUrl.replace(/\/$/, "");
346
- const offer = await fetchImpl(`${product}/open/v1/playground/matches/${args.offerId}`);
534
+ /* Read again while the product is coming back: a seat reopens its session
535
+ after every dropped stream, and the product answering 502 for the seconds
536
+ it restarts ended the seat as surely as a refusal would have. */
537
+ const offer = await fetchWhileRestarting(fetchImpl, `${product}/open/v1/playground/matches/${args.offerId}`, args.restartRetry);
347
538
  if (!offer.ok)
348
539
  throw new Error(`offer read failed (${offer.status}): ${await offer.text()}`);
349
540
  const record = (await offer.json());
@@ -374,9 +565,29 @@ export async function playSeat(args) {
374
565
  throw new Error("the admission names no time-authority key; pass timeAuthorityKey");
375
566
  const executionHex = record.admission.execution_id.replace(/^0x/i, "");
376
567
  const executionId = `0x${executionHex}`;
377
- const nonce = new Uint8Array(randomBytes(32));
378
- nonce[0] = args.seat & 0xff;
568
+ const nonce = args.clientNonce ?? new Uint8Array(randomBytes(32));
569
+ if (!args.clientNonce)
570
+ nonce[0] = args.seat & 0xff;
379
571
  const client = new SessionClient(record.admission.session_base_url, args.agent, args.seat, args.agentId, fromHex(record.admission.execution_id), fromHex(record.admission.manifest_digest), nonce, fetchImpl);
572
+ return {
573
+ client,
574
+ product,
575
+ executionHex,
576
+ coordinatorKey,
577
+ timeAuthorityKey,
578
+ sessionBaseUrl: record.admission.session_base_url,
579
+ executionId,
580
+ };
581
+ }
582
+ export async function playSeat(args) {
583
+ const fetchImpl = args.fetchImpl ?? fetch;
584
+ const { client, product, executionHex, coordinatorKey, timeAuthorityKey, executionId, } = await openSeatSession(args);
585
+ const record = {
586
+ admission: {
587
+ execution_id: executionId,
588
+ session_base_url: client.baseUrl,
589
+ },
590
+ };
380
591
  const strategy = args.strategy ?? "fold-heavy";
381
592
  const decide = args.decide;
382
593
  const acted = new Set();
@@ -386,18 +597,33 @@ export async function playSeat(args) {
386
597
  let said = 0;
387
598
  let saidRefused = 0;
388
599
  let lastHandSeen = null;
600
+ const elimination = eliminationWatch(args.seat);
601
+ let lastIdleTableReadMs = 0;
389
602
  /* Who the other seats are, read once per agent for the whole sitting: a
390
603
  name does not change mid-match, and a read per turn would be a read per
391
604
  turn per seat. */
392
605
  const names = new Map();
606
+ /* The seat's own key on the table read. Without it a seat in a private room
607
+ is just another stranger at the door, and the decision below would be made
608
+ against a null table while the agent was sitting at it. */
609
+ const tableCapability = agentReadCapability(args.agent, args.agentId);
393
610
  let disconnectExerciseDone = false;
394
611
  let pending = null;
395
612
  let lastContext = null;
396
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();
397
619
  const inbox = [];
398
620
  let joined = await client.join();
399
621
  if (joined.context)
400
622
  lastContext = joined.context;
623
+ const joinedToken = client.token;
624
+ if (!joinedToken)
625
+ throw new Error("join did not return a session token");
626
+ let originToken = joinedToken;
401
627
  const handle = async (message) => {
402
628
  if (message.type === "error") {
403
629
  if (message.retryable)
@@ -413,11 +639,34 @@ export async function playSeat(args) {
413
639
  if (message.type === "actionPending" ||
414
640
  message.type === "actionAcknowledged")
415
641
  return null;
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;
653
+ await client.acknowledge(message.context, message.cursor, originToken);
654
+ lastCursor = message.cursor;
655
+ return null;
656
+ }
416
657
  if ("context" in message)
417
658
  lastContext = message.context;
418
659
  if (message.type === "sessionTerminal") {
419
- await client.acknowledge(message.context, message.cursor);
420
- 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
+ }
421
670
  return {
422
671
  outcome: "terminal",
423
672
  committedActions,
@@ -425,6 +674,7 @@ export async function playSeat(args) {
425
674
  rejoins,
426
675
  said,
427
676
  saidRefused,
677
+ eliminated: elimination.eliminated,
428
678
  terminalNonce: message.finalState.nonce.toString(),
429
679
  terminalCommitment: toHex0x(message.finalState.commitment),
430
680
  executionId: record.admission.execution_id,
@@ -433,33 +683,61 @@ export async function playSeat(args) {
433
683
  }
434
684
  let view = null;
435
685
  let cursor = null;
436
- if (message.type === "sessionJoined") {
686
+ if (message.type === "sessionJoined" ||
687
+ message.type === "participantView" ||
688
+ message.type === "actionCommitted" ||
689
+ message.type === "actionRejected" ||
690
+ message.type === "sessionResumed") {
437
691
  view = message.view;
438
692
  cursor = message.cursor;
439
693
  }
440
- else if (message.type === "participantView") {
441
- view = message.view;
442
- cursor = message.cursor;
443
- }
444
- else if (message.type === "actionCommitted") {
445
- pending = null;
446
- committedActions += 1;
447
- view = message.view;
448
- cursor = message.cursor;
449
- }
450
- else if (message.type === "actionRejected") {
451
- pending = null;
452
- view = message.view;
453
- cursor = message.cursor;
454
- }
455
- else if (message.type === "sessionResumed") {
456
- view = message.view;
457
- cursor = message.cursor;
458
- }
459
- if (view && cursor !== null && lastContext) {
460
- const nonceKey = view.state.nonce.toString();
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;
710
+ }
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() ?? "";
461
734
  const now = BigInt(Date.now());
462
- 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 &&
463
741
  !acted.has(nonceKey) &&
464
742
  view.participantDeadlineMs > now) {
465
743
  const legal = decodeLegalActions(view.legalActions);
@@ -467,7 +745,7 @@ export async function playSeat(args) {
467
745
  played a seat wrote this poll itself; the SDK owns the transport, so
468
746
  it owns this read too. Per turn, not per event: the table only
469
747
  matters at the moment there is a decision to make. */
470
- const table = await readPublicTable(fetchImpl, product, executionHex, names);
748
+ const table = await readPublicTable(fetchImpl, product, executionHex, names, tableCapability);
471
749
  /* And what has been said in this hand, which the skill promised the
472
750
  decision would see. Best-effort like the table: a seat that cannot
473
751
  read the talk still has its own cards and the legal set. */
@@ -579,13 +857,15 @@ export async function playSeat(args) {
579
857
  }
580
858
  }
581
859
  }
582
- await client.acknowledge(lastContext, cursor);
860
+ await client.acknowledge(lastContext, cursor, originToken);
583
861
  lastCursor = cursor;
584
862
  if (!disconnectExerciseDone &&
585
863
  args.disconnectAfterActions !== undefined &&
586
864
  committedActions >= args.disconnectAfterActions) {
587
865
  disconnectExerciseDone = true;
588
866
  await client.resume(lastContext, lastCursor);
867
+ originToken = client.token ?? originToken;
868
+ inbox.length = 0;
589
869
  reconnects += 1;
590
870
  }
591
871
  }
@@ -613,14 +893,27 @@ export async function playSeat(args) {
613
893
  rejoins,
614
894
  said,
615
895
  saidRefused,
896
+ eliminated: elimination.eliminated,
616
897
  executionId: record.admission.execution_id,
617
898
  sessionBaseUrl: record.admission.session_base_url,
618
899
  };
619
900
  await client.resume(lastContext, lastCursor);
901
+ originToken = client.token ?? originToken;
620
902
  reconnects += 1;
621
903
  }
622
904
  }
623
905
  catch (error) {
906
+ /* A version this build cannot speak is never answered by resuming or
907
+ rejoining: the same refusal would come back, and a rejoin could even
908
+ look successful while the seat cannot read the gated stream. */
909
+ if (error instanceof UnsupportedSessionVersionError)
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;
624
917
  if (!lastContext || reconnects >= 5)
625
918
  throw error;
626
919
  /* Three refusals, three answers. A gone session (`UnknownSession`, or a
@@ -639,20 +932,32 @@ export async function playSeat(args) {
639
932
  rejoins,
640
933
  said,
641
934
  saidRefused,
935
+ eliminated: elimination.eliminated,
642
936
  executionId: record.admission.execution_id,
643
937
  sessionBaseUrl: record.admission.session_base_url,
644
938
  };
939
+ /* Whatever the old binding delivered and this loop had not handled yet
940
+ is delivered again from the cursor by the new one. */
941
+ inbox.length = 0;
645
942
  if (answer === "rejoin") {
646
943
  joined = await client.join();
944
+ originToken = client.token ?? originToken;
647
945
  if (joined.context)
648
946
  lastContext = joined.context;
649
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;
650
954
  inbox.push(...joined.messages);
651
955
  rejoins += 1;
652
956
  reconnects += 1;
653
957
  continue;
654
958
  }
655
959
  await client.resume(lastContext, lastCursor);
960
+ originToken = client.token ?? originToken;
656
961
  reconnects += 1;
657
962
  }
658
963
  }
@@ -684,17 +989,96 @@ export function normaliseCardCode(code) {
684
989
  const suit = trimmed.slice(-1).toLowerCase();
685
990
  return `${rank}${suit}`;
686
991
  }
687
- /** The public table for one execution, as the spectator snapshot shows it.
992
+ /** What the terminal disclosure says one seat takes off the table, read off
993
+ * the spectator snapshot's status.
994
+ *
995
+ * Read rather than assumed: consent checks the seat's chips against it before
996
+ * it signs, and a number a client invented would be refused there. The
997
+ * disclosure is published a moment after the seat sees the terminal, so this
998
+ * waits for it, bounded well inside the consent window.
999
+ *
1000
+ * Signed as the seat where a capability is given. A private room's snapshot
1001
+ * answers only its seats and owning wallets, so an anonymous read of it never
1002
+ * finds an entitlement at all, and a room whose seats read that way never
1003
+ * settles. A playground or tournament sitting ignores the capability. */
1004
+ export async function readDisclosedEntitlement(fetchImpl, product, executionId, seat, capability, options = {}) {
1005
+ const attempts = options.attempts ?? 15;
1006
+ const pauseMs = options.pauseMs ?? 1_000;
1007
+ const target = `/open/v1/spectator/executions/${executionId}`;
1008
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1009
+ try {
1010
+ const header = capability ? await capability("GET", target) : null;
1011
+ const response = await fetchImpl(`${product.replace(/\/$/, "")}${target}`, header === null
1012
+ ? undefined
1013
+ : { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
1014
+ if (response.ok) {
1015
+ const body = (await response.json());
1016
+ const chips = body.status?.entitlements?.find((entitlement) => entitlement.seat === seat)?.chips;
1017
+ if (chips !== undefined)
1018
+ return BigInt(chips);
1019
+ }
1020
+ }
1021
+ catch {
1022
+ /* A read that did not answer is another attempt, not an end. */
1023
+ }
1024
+ await new Promise((resolve) => setTimeout(resolve, pauseMs));
1025
+ }
1026
+ return undefined;
1027
+ }
1028
+ /** Read a sitting's status off the public history surface.
688
1029
  *
689
- * Best-effort: a read that does not answer is null, never a throw -- the
690
- * seat's own view is what the turn depends on, and a decision is better made
691
- * without the board than not made at all.
1030
+ * Its own read rather than part of `readPublicTable`, because that answers
1031
+ * what is on the table right now and returns nothing at all once the table is
1032
+ * gone -- which is precisely the moment this question is being asked. */
1033
+ export async function readSittingStatus(fetchImpl, product, executionHex) {
1034
+ try {
1035
+ const response = await fetchImpl(`${product}/open/v1/history/matches/${executionHex}`);
1036
+ if (!response.ok)
1037
+ return { state: "unknown" };
1038
+ const wire = (await response.json());
1039
+ switch (wire.status) {
1040
+ case "completed":
1041
+ case "settling":
1042
+ case "failed":
1043
+ return { state: "over", detail: wire.status };
1044
+ case "live":
1045
+ return { state: "live" };
1046
+ /* `unavailable` is the product saying it does not know, and a status
1047
+ this build has never heard of is the same thing. Neither is a reason
1048
+ to tell a seat its sitting ended. */
1049
+ default:
1050
+ return { state: "unknown" };
1051
+ }
1052
+ }
1053
+ catch {
1054
+ return { state: "unknown" };
1055
+ }
1056
+ }
1057
+ /** The capability `readPublicTable` asks for, minted on this agent's own key.
692
1058
  *
693
- * `names` is filled as agents are met and read from after that, so who is
694
- * in a seat costs one custody read per agent per sitting. */
695
- export async function readPublicTable(fetchImpl, product, executionHex, names = new Map()) {
1059
+ * A private room's live view is owner-session-only unless the reader holds a
1060
+ * seat at that room, and the seat says so with the same signature every other
1061
+ * Product API call carries. A playground sitting ignores one, so a seat can
1062
+ * send it without having to know which kind of table it sat down at. */
1063
+ export function agentReadCapability(agent, agentId) {
1064
+ return async (method, requestTarget) => {
1065
+ const { header } = await mintAgentHttpCapability(agent, agentId, {
1066
+ method,
1067
+ requestTarget,
1068
+ body: new Uint8Array(),
1069
+ });
1070
+ return header;
1071
+ };
1072
+ }
1073
+ export async function readPublicTable(fetchImpl, product, executionHex, names = new Map(), capability) {
1074
+ /* Bound to the target the server reconstructs from the request, which is the
1075
+ origin-form path and not the absolute URL the fetch is given. */
1076
+ const target = `/open/v1/spectator/executions/${executionHex}`;
696
1077
  try {
697
- const response = await fetchImpl(`${product}/open/v1/spectator/executions/${executionHex}`);
1078
+ const header = capability ? await capability("GET", target) : null;
1079
+ const response = await fetchImpl(`${product}${target}`, header === null
1080
+ ? undefined
1081
+ : { headers: { [AGENT_HTTP_CAPABILITY_HEADER]: header } });
698
1082
  if (!response.ok)
699
1083
  return null;
700
1084
  const wire = (await response.json());