@irtio/protocol 0.5.1 → 0.6.0

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +1095 -9
  2. package/dist/index.js +889 -39
  3. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -5,8 +5,16 @@ import { ByteReader, RpcMap, ServerRpcs, AnySchema, RpcDesc, InstanceOf, Schema,
5
5
  * Frame envelope: one type byte, then an opaque payload. `encodeFrame`/`decodeFrame` only
6
6
  * handle the envelope — payload codecs live in `session.ts`, `rpc.ts`, `msg.ts`.
7
7
  */
8
- /** Wire protocol version (u8), bumped on any breaking change to framing or payload shapes. */
9
- declare const PROTOCOL_VERSION = 1;
8
+ /**
9
+ * Wire protocol version (u8), bumped on any breaking change to framing or payload shapes.
10
+ *
11
+ * 2: `WELCOME`'s tick slot carries the room's tick rate in ticks per second, where version 1
12
+ * carried the interval in whole milliseconds. The slot kept its width and type, so a version 1
13
+ * client would read `60` as a 60 ms interval and quietly run a four-times-too-long interpolation
14
+ * delay and a wrong physics timestep. The bump turns that into `E_PROTOCOL_VERSION` at join,
15
+ * which is a client bundle that needs redeploying rather than an afternoon of debugging.
16
+ */
17
+ declare const PROTOCOL_VERSION = 2;
10
18
  declare const FrameType: {
11
19
  readonly HELLO: 1;
12
20
  readonly WELCOME: 2;
@@ -26,6 +34,18 @@ declare const FrameType: {
26
34
  * the close to time out into `'timeout'`. Additive — protocol version stays 1.
27
35
  */
28
36
  readonly LEAVE: 12;
37
+ /**
38
+ * Server → client, payload the new schema's canonical JSON as UTF-8 (D50, gap-and-resync).
39
+ * Sent during a `migrate`-strategy deploy whose schema change is purely additive, after the old
40
+ * worker stops sending and before the resync WELCOME, to a session that asked for it. The
41
+ * client rebuilds its codec from the descriptor and keeps its socket; before this, any schema
42
+ * change closed it with `E_SCHEMA_MISMATCH`.
43
+ *
44
+ * Gated, not versioned. `decodeFrame` throws on an unknown type, so a client that predates this
45
+ * frame must never receive one: the server sends it only to a session whose HELLO set
46
+ * `HELLO_SCHEMA_SWAP_BIT`. Additive — protocol version stays 1, the same way LEAVE=12 did.
47
+ */
48
+ readonly SCHEMA: 13;
29
49
  };
30
50
  type FrameType = (typeof FrameType)[keyof typeof FrameType];
31
51
  interface Frame {
@@ -66,7 +86,7 @@ declare const ErrorCode: {
66
86
  };
67
87
  readonly E_ROOM_NOT_FOUND: {
68
88
  readonly code: 5;
69
- readonly message: "room {roomId} not found room ids are 1-32 of A-Z a-z 0-9 - _";
89
+ readonly message: "room {roomId} not found. Room ids are 1-32 of A-Z a-z 0-9 - _, optionally behind a lowercase <type>: prefix";
70
90
  };
71
91
  readonly E_ROOM_FULL: {
72
92
  readonly code: 6;
@@ -156,7 +176,96 @@ declare const ErrorCode: {
156
176
  readonly code: 27;
157
177
  readonly message: "auth token algorithm {alg} is not allowed";
158
178
  };
179
+ /**
180
+ * M4 (D48): the project is at a usage cap and this join is new work.
181
+ *
182
+ * The template carries `{detail}` rather than a fixed sentence because the actionable half
183
+ * differs by tier and by meter, and a refusal that does not say what to do next is the thing
184
+ * `errors.md`'s fix-line convention exists to prevent. Control composes the detail; the box
185
+ * relays it. Non-protocol surfaces (the store gateway's write refusal, control's HTTP refusals
186
+ * for deploys and uploads) reuse the same `E_USAGE_CAP` string code in their own shapes, so one
187
+ * grep finds every place a cap can be met.
188
+ */
189
+ readonly E_USAGE_CAP: {
190
+ readonly code: 28;
191
+ readonly message: "{detail}";
192
+ };
193
+ /**
194
+ * M4 part 5 (D53): the tenant cannot verify platform identity assertions because it holds no
195
+ * platform public key yet.
196
+ *
197
+ * Deliberately not `E_AUTH` and not `E_TOKEN_INVALID`. Those say "your credential is bad", and
198
+ * this is the opposite — the credential may be perfect and the box is not yet in a position to
199
+ * say so. Keys are delivered to a tenant the way cap state is (a push from control right after
200
+ * the VM comes up), so the honest reading of this code is "retry in a moment", which is what
201
+ * the fix line in the errors reference says.
202
+ */
203
+ readonly E_ASSERTION_UNVERIFIABLE: {
204
+ readonly code: 29;
205
+ readonly message: "this server cannot verify identity assertions yet";
206
+ };
207
+ /**
208
+ * M5 part 3.5: a room type is already running as many rooms as it declared it would.
209
+ *
210
+ * Deliberately its own code rather than `E_ROOM_FULL`, which is about clients in a room, and
211
+ * emphatically not `E_INTERNAL`. This refusal is what makes declared sizing honest: the tenant's
212
+ * VM memory was computed as the declared per-room heap times this number, so room N+1 is a room
213
+ * the machine was never built to hold. The message names the type, the limit and the field to
214
+ * change, because all three are things the developer controls.
215
+ */
216
+ readonly E_TYPE_AT_CAPACITY: {
217
+ readonly code: 30;
218
+ readonly message: "{detail}";
219
+ };
220
+ /**
221
+ * M5 part 3.5 (Part C): the project has used its egress allowance and forwarding has stopped.
222
+ *
223
+ * Its own code, and its own WebSocket close code ({@link CLOSE_EGRESS_WALL}), for one reason: a
224
+ * developer watching sockets close has to be able to tell a wall from a bug. `E_USAGE_CAP` is
225
+ * the polite refusal of *new* work at a cap control evaluated up to a minute ago; this is the
226
+ * box stopping traffic on its own, locally, the moment its leased budget ran out.
227
+ */
228
+ readonly E_EGRESS_WALL: {
229
+ readonly code: 31;
230
+ readonly message: "{detail}";
231
+ };
232
+ /**
233
+ * M5 part 3.5 (Part C): a free-tier shape limit — the relay client cap, or the concurrent
234
+ * awake-rooms cap. The message names the limit and the tier, because the fix is a card.
235
+ */
236
+ readonly E_TIER_LIMIT: {
237
+ readonly code: 32;
238
+ readonly message: "{detail}";
239
+ };
240
+ /**
241
+ * M5 part 7 (D67-i): the project's region has no server with room for another tenant VM.
242
+ *
243
+ * Sent by the router, fatal, with close code 1013 (RFC 6455 "try again later"). Before this
244
+ * code a full region looked like a slow start: the control plane re-ran placement on every
245
+ * lookup poll for thirty seconds and the client then saw `E_STARTING` followed by `E_INTERNAL`.
246
+ * The message names the region and the two fixes because both are an operator's, never the
247
+ * player's: the client does not retry this join on its own.
248
+ */
249
+ readonly E_PLACEMENT: {
250
+ readonly code: 33;
251
+ readonly message: "no capacity in region {region}; add a server or raise maxVms";
252
+ };
159
253
  };
254
+ /**
255
+ * The WebSocket close code the router uses for {@link ErrorCode.E_PLACEMENT}: RFC 6455's 1013,
256
+ * "try again later". Distinct from the 1008 policy close and the 1011 internal-error close the
257
+ * router uses for everything else, so a watcher of close codes can tell a full region from a bug.
258
+ */
259
+ declare const CLOSE_TRY_AGAIN_LATER = 1013;
260
+ /**
261
+ * The WebSocket close code for the egress wall (M5 part 3.5, Part C).
262
+ *
263
+ * In the application range (4000-4999) and distinct from the `1008` policy close, which is the
264
+ * whole point: when the wall trips, forwarding stops and sockets close, and a developer has to be
265
+ * able to tell that apart from a crash, a slow consumer or an origin refusal without reading a log
266
+ * they may not have. 4290 echoes HTTP 429 for the same reason a person would guess it does.
267
+ */
268
+ declare const CLOSE_EGRESS_WALL = 4290;
160
269
  type ErrorCodeName = keyof typeof ErrorCode;
161
270
  interface ErrorCatalogueEntry {
162
271
  readonly name: ErrorCodeName;
@@ -192,6 +301,24 @@ type Credential = {
192
301
  readonly kind: 'jwt';
193
302
  readonly key: string;
194
303
  readonly token: string;
304
+ }
305
+ /**
306
+ * D53: the project key AND a platform identity **assertion** — audience-bound to this project,
307
+ * expiring in minutes, carrying a per-project pseudonymous subject, and signed asymmetrically
308
+ * so the guest can verify it but not mint it. Wire tag 3.
309
+ *
310
+ * A separate tag rather than a second flavour of `jwt` on purpose. The two are verified by
311
+ * different code with different key material and different trust: a BYO JWT is signed by the
312
+ * tenant's own secret (which the guest also holds), an assertion is signed by the platform
313
+ * (which the guest holds only the public half of). Sharing a tag would mean one verifier
314
+ * deciding between them from a header claim, which is the algorithm-confusion hole with extra
315
+ * steps. The player's actual identity credential is never on this wire at all — it is
316
+ * exchanged with control over HTTPS before the socket opens.
317
+ */
318
+ | {
319
+ readonly kind: 'assertion';
320
+ readonly key: string;
321
+ readonly token: string;
195
322
  };
196
323
  interface Hello {
197
324
  readonly protocolVersion: number;
@@ -205,7 +332,23 @@ interface Hello {
205
332
  readonly role?: string | undefined;
206
333
  /** Display name requested at join. */
207
334
  readonly name?: string | undefined;
335
+ /**
336
+ * D50: this client can rebuild its codec from a `SCHEMA` frame, so an additive deploy may swap
337
+ * its schema in place instead of closing the socket with `E_SCHEMA_MISMATCH`. Carried as a bit
338
+ * in the existing optional-presence mask rather than as an appended field, because `decodeHello`
339
+ * ends in `assertEof` and every deployed supervisor would reject trailing bytes.
340
+ *
341
+ * A capability, not a request: the server still decides, and still drains on a breaking change.
342
+ * Absent (the default) means today's behaviour exactly, and means the server must never send
343
+ * this session frame 13 — `decodeFrame` throws on an unknown type.
344
+ */
345
+ readonly schemaSwap?: boolean | undefined;
208
346
  }
347
+ /**
348
+ * D50 capability bit. Bits 0-2 each announce an optional *value* that follows the mask; this one
349
+ * carries no payload, it is a pure flag, so it adds nothing to the encoded length. Bits 4-7 free.
350
+ */
351
+ declare const HELLO_SCHEMA_SWAP_BIT: number;
209
352
  declare function encodeHello(h: Hello): Uint8Array;
210
353
  declare function decodeHello(bytes: Uint8Array): Hello;
211
354
  interface Welcome {
@@ -223,12 +366,24 @@ interface Welcome {
223
366
  */
224
367
  readonly roomId: string;
225
368
  /**
226
- * The room's tick interval in milliseconds (`round(1000 / tickRate)`), or `0` when the sender
227
- * does not know it (a relay room, or a pre-week-8 server). The client's interpolation delay
228
- * defaults to `max(50, 2 × tickIntervalMs)` (D20). Appended after `roomId` in week 8, same
229
- * additive style as `roomId` itself.
369
+ * The room's tick rate in ticks per second, or `0` when the sender does not know it (a relay
370
+ * room). The client derives everything it needs in milliseconds from this: the interpolation
371
+ * delay defaults to `max(50, 2000 / tickRate)` (D20), and a predicted physics world steps at
372
+ * `1 / tickRate` seconds.
373
+ *
374
+ * This slot carried `tickIntervalMs`, `round(1000 / tickRate)`, until `PROTOCOL_VERSION` 2.
375
+ * Rounding to whole milliseconds is lossy at exactly the rates games use: 60 Hz arrived as 17
376
+ * and 30 Hz as 33, so a client that inherited its timestep stepped its local world 2% slower
377
+ * than the server stepped its own, and paid for the drift in corrections. The rate is the
378
+ * number the room actually configured, and every derived millisecond figure is now exact.
230
379
  */
231
- readonly tickIntervalMs: number;
380
+ readonly tickRate: number;
381
+ /**
382
+ * The room's configured client cap, or `0` when the sender does not know it (a relay room,
383
+ * or a server predating this field). Appended after `tickRate`, same additive style as
384
+ * `tickRate` itself.
385
+ */
386
+ readonly maxClients: number;
232
387
  }
233
388
  declare function encodeWelcome(w0: Welcome): Uint8Array;
234
389
  declare function decodeWelcome(bytes: Uint8Array): Welcome;
@@ -282,6 +437,20 @@ declare function readCorrectAppliedTick(r: ByteReader): number | undefined;
282
437
  declare function encodeDeltaFrame(codecBytes: Uint8Array): Uint8Array;
283
438
  declare function encodeWriteFrame(codecBytes: Uint8Array): Uint8Array;
284
439
  declare function encodeCorrectFrame(codecBytes: Uint8Array, clientTick: number, appliedTick?: number): Uint8Array;
440
+ /**
441
+ * The `SCHEMA` frame payload: the new schema's canonical JSON as UTF-8, exactly the string
442
+ * `Schema.canonical` produces and `Deployment.schemaJson` already stores. No length prefix and no
443
+ * envelope — the frame body *is* the descriptor, so `encodeSchemaFrame`/`readSchemaPayload` are a
444
+ * `TextEncoder`/`TextDecoder` pair and nothing more.
445
+ *
446
+ * There is deliberately no new codec here. The largest schema in the repo is 4.3 KB raw and 557
447
+ * bytes gzipped (M4 part 1 report §8.1), which is not worth a wire format; and reusing the exact
448
+ * bytes the control plane already stores means the descriptor a client rebuilds from is provably
449
+ * the one that was deployed, not a re-serialisation of it.
450
+ */
451
+ declare function schemaPayload(canonical: string): Uint8Array;
452
+ declare function readSchemaPayload(payload: Uint8Array): string;
453
+ declare function encodeSchemaFrame(canonical: string): Uint8Array;
285
454
 
286
455
  interface Call {
287
456
  readonly reqId: number;
@@ -328,6 +497,146 @@ declare function rpcByIdOf(schema: AnySchema, id: number): RpcDesc;
328
497
  */
329
498
  type ClientCallable<R extends RpcMap> = ServerRpcs<R> & typeof builtinRpcs;
330
499
 
500
+ /**
501
+ * The bandwidth ledger (D65): cumulative bytes per direction, per row, for one room or one
502
+ * client session.
503
+ *
504
+ * The row set below is the product contract — the docs list it, the overlay renders it, and the
505
+ * CLI prints it — so it is a small closed vocabulary rather than free-form tags. Rates are never
506
+ * stored here: a reader diffs two snapshots over a window and divides. Cumulative counters
507
+ * survive a missed poll; a stored rate does not.
508
+ *
509
+ * ## Conservation
510
+ *
511
+ * For every frame handed to `attribute`, the rows it adds sum to exactly the frame's length,
512
+ * including the envelope byte. That is structural, not aspirational: the attribution is staged
513
+ * into a scratch list, and whatever the walker could not account for (a delta encoded against
514
+ * another schema, a truncated frame, a frame type from a newer peer) is committed to
515
+ * `overhead/unattributed` rather than dropped. A profiler that quietly loses bytes would be
516
+ * worse than no profiler, because its shares would look right.
517
+ */
518
+
519
+ /**
520
+ * The closed kind vocabulary. `field` is the steady state; every other kind exists because
521
+ * lumping it into `field` would hide the thing a reader came to find.
522
+ */
523
+ type ProfileKind = 'field' | 'presence' | 'correction' | 'write' | 'churn' | 'rpc' | 'message' | 'voice' | 'join' | 'overhead' | 'control';
524
+ declare const PROFILE_KINDS: readonly ProfileKind[];
525
+ interface ProfileRow {
526
+ readonly kind: ProfileKind;
527
+ /** `collection.field`, an rpc name, or one of the fixed keys the kind table documents. */
528
+ readonly key: string;
529
+ /** Cumulative bytes sent to the peer. */
530
+ readonly out: number;
531
+ /** Cumulative bytes received from the peer. */
532
+ readonly in: number;
533
+ }
534
+ /** A ledger, flattened for transport (the worker `stats` reply, `state.json`, the overlay). */
535
+ interface ProfileSnapshot {
536
+ readonly rows: readonly ProfileRow[];
537
+ readonly bytesIn: number;
538
+ readonly bytesOut: number;
539
+ readonly framesIn: number;
540
+ readonly framesOut: number;
541
+ /**
542
+ * How many frames the walker actually read. Lower than `framesOut` exactly when a payload was
543
+ * shared by several recipients: the bytes count once per recipient (that is what left the box)
544
+ * but the walk happens once per encode. The adversarial tests read this.
545
+ */
546
+ readonly walks: number;
547
+ }
548
+ declare const EMPTY_PROFILE: ProfileSnapshot;
549
+ /** AOI enter/leave ids for one client's frame, by collection. */
550
+ type ChurnIds = ReadonlyMap<string, ReadonlySet<string>>;
551
+ interface AttributeOptions {
552
+ /**
553
+ * Ids whose whole `add`/`remove` op is visibility churn rather than a real spawn or despawn.
554
+ * Server-side only: only the encoder knows which ops the global dirty set never contained.
555
+ */
556
+ readonly churn?: ChurnIds | undefined;
557
+ /**
558
+ * The shared payload object this frame was built from. Frames built from the same payload in
559
+ * one flush are walked once and replayed per recipient. Cleared by `newFlush()`.
560
+ */
561
+ readonly shared?: object | undefined;
562
+ /** Correlation scope for matching a REPLY back to its CALL's name. A client id, usually. */
563
+ readonly peer?: string | undefined;
564
+ /**
565
+ * The client's approximation of churn: every `add`/`remove` in a `spatial-grid` collection is
566
+ * counted as visibility churn, because a decoder genuinely cannot tell a synthetic add from a
567
+ * real spawn — they are the same bytes. Surfaces that use it must say so; the overlay labels
568
+ * the row `enter/leave (incl. spawns)`.
569
+ */
570
+ readonly spatialChurn?: boolean | undefined;
571
+ }
572
+ declare class ProfileLedger {
573
+ private readonly rows;
574
+ private readonly memo;
575
+ /** Peer and reqId to the rpc name its CALL carried, so the matching REPLY can be named too. */
576
+ private readonly pending;
577
+ private scratch;
578
+ private bytesIn;
579
+ private bytesOut;
580
+ private framesIn;
581
+ private framesOut;
582
+ private walkCount;
583
+ private rpcSchema;
584
+ /**
585
+ * `schema` is the runtime-extended schema (`withBuiltins`), which is what delta and snapshot
586
+ * bodies are encoded against. `rpcSchema` is the one whose `rpcTable` the `rpcId` on the wire
587
+ * indexes; the two tables agree today (extending adds a collection, not an rpc), but the
588
+ * senders name them separately and so does this.
589
+ */
590
+ private schema;
591
+ constructor(schema: AnySchema, rpcSchema?: AnySchema);
592
+ /**
593
+ * D50: a session whose schema was swapped mid-flight keeps its ledger and its accumulated rows.
594
+ * Rows named after a field the new schema dropped simply stop growing, which is the honest
595
+ * reading — those bytes really were spent.
596
+ */
597
+ swapSchema(schema: AnySchema, rpcSchema?: AnySchema): void;
598
+ get walks(): number;
599
+ /** Drops the shared-payload memo. Call at the top of each flush. */
600
+ newFlush(): void;
601
+ reset(): void;
602
+ snapshot(): ProfileSnapshot;
603
+ /**
604
+ * Attributes one whole frame, envelope byte included. Never throws: a frame this ledger cannot
605
+ * read still contributes its full length, to `overhead/unattributed`.
606
+ */
607
+ attribute(dir: 'in' | 'out', frame: Uint8Array, options?: AttributeOptions): void;
608
+ /**
609
+ * Attributes bare snapshot bytes under `join`. The room worker needs this: it hands the host
610
+ * snapshot bytes and the host wraps them in a `WELCOME`, so the room never sees that frame,
611
+ * but the bytes are still the room's egress and the join rows are the reason anyone profiles a
612
+ * join at all. A client, which does see the whole frame, uses `attribute` instead.
613
+ */
614
+ attributeSnapshot(dir: 'in' | 'out', bytes: Uint8Array): void;
615
+ private commit;
616
+ private describe;
617
+ /** WELCOME: the snapshot inside it is `join`; everything around it is `control/welcome`. */
618
+ private welcome;
619
+ private snapshotBody;
620
+ private body;
621
+ /**
622
+ * The walker's consumer. `op` latches: a churn op routes its own framing *and* every field
623
+ * that follows it into `churn`, because a synthetic add carries a whole record whose bytes are
624
+ * the cost of the entity crossing the boundary, not the cost of the fields changing.
625
+ */
626
+ private collector;
627
+ private rpcName;
628
+ private remember;
629
+ private recall;
630
+ }
631
+ /** `next` minus `prev`, row by row. Rows that did not move are dropped. */
632
+ declare function diffProfiles(prev: ProfileSnapshot, next: ProfileSnapshot): ProfileSnapshot;
633
+ /** The `n` heaviest rows, by out + in. */
634
+ declare function topProfileRows(snap: ProfileSnapshot, n: number): readonly ProfileRow[];
635
+ /** Divides every counter by `n`, for the per-bot convention `simulate` already prints. */
636
+ declare function scaleProfile(snap: ProfileSnapshot, n: number): ProfileSnapshot;
637
+ /** Adds `b` into `a`, row by row. Used to roll several sessions into one table. */
638
+ declare function mergeProfiles(a: ProfileSnapshot, b: ProfileSnapshot): ProfileSnapshot;
639
+
331
640
  /**
332
641
  * MSG frame payload: an out-of-band message with an addressing discriminator. The same
333
642
  * shape is reused in both directions — client→server as the send *target*, server→client as
@@ -343,15 +652,130 @@ type MsgTarget = {
343
652
  readonly role: string;
344
653
  } | {
345
654
  readonly kind: 'server';
655
+ } | {
656
+ readonly kind: 'voice';
346
657
  };
347
658
  interface Msg {
348
659
  readonly target: MsgTarget;
349
660
  /** Rest-of-buffer: opaque application payload. */
350
661
  readonly payload: Uint8Array;
351
662
  }
663
+ /**
664
+ * A one-byte peek: is this MSG payload voice signaling? The supervisor calls this on every MSG
665
+ * before deciding where the frame goes, so it must not allocate or decode. An empty payload is
666
+ * not voice (it is malformed, and the existing paths report that).
667
+ */
668
+ declare function isVoiceMsg(payload: Uint8Array): boolean;
352
669
  declare function encodeMsg(m: Msg): Uint8Array;
353
670
  declare function decodeMsg(bytes: Uint8Array): Msg;
354
671
 
672
+ /**
673
+ * D51 voice signaling — the message set that rides inside a `{ kind: 'voice' }` MSG.
674
+ *
675
+ * Two deliberate choices worth stating, because both are cheap to reverse later and expensive to
676
+ * get wrong now.
677
+ *
678
+ * **JSON, not the binary schema codec.** Every other payload on this socket is binary because it
679
+ * is on the tick path and its shape is fixed. Signaling is neither: a handful of messages per
680
+ * participant per call, carrying mediasoup's own RTP capability and DTLS parameter objects, whose
681
+ * shape is defined by mediasoup rather than by us. Re-encoding those into a hand-rolled binary
682
+ * format would buy nothing and would have to be revised every time mediasoup adds a field. The
683
+ * volume is a few kilobytes per join, once.
684
+ *
685
+ * **Everything here is per-project at most.** The blast radius note the docs repeat: transport
686
+ * parameters, ICE candidates and DTLS fingerprints are secrets about one call in one project's
687
+ * room. A tenant is assumed hostile and a hostile tenant can already do anything it likes to its
688
+ * own game — the same stance as BYO JWT signing keys. No platform credential, no agent token and
689
+ * no other project's identifier is representable in any message below, and the SFU never holds a
690
+ * credential of any kind for anything to leak (fact 5, and bug #27's lesson).
691
+ */
692
+ /** Client → SFU. */
693
+ type VoiceClientMessage = {
694
+ readonly t: 'join';
695
+ readonly rtpCapabilities: unknown;
696
+ } | {
697
+ readonly t: 'connect';
698
+ readonly transportId: string;
699
+ readonly dtlsParameters: unknown;
700
+ } | {
701
+ readonly t: 'produce';
702
+ readonly transportId: string;
703
+ readonly kind: 'audio';
704
+ readonly rtpParameters: unknown;
705
+ } | {
706
+ readonly t: 'consume';
707
+ readonly producerId: string;
708
+ readonly rtpCapabilities: unknown;
709
+ } | {
710
+ readonly t: 'resume';
711
+ readonly consumerId: string;
712
+ }
713
+ /**
714
+ * Mute is signalled, not merely stopped locally, because the meter depends on it: an SFU that
715
+ * only sees a silent stream cannot tell a muted participant from a quiet room, and §1.3 makes
716
+ * mute the boundary between minutes (which keep accruing) and egress (which stops).
717
+ */
718
+ | {
719
+ readonly t: 'mute';
720
+ readonly muted: boolean;
721
+ } | {
722
+ readonly t: 'leave';
723
+ };
724
+ /** SFU → client. */
725
+ type VoiceServerMessage = {
726
+ readonly t: 'joined';
727
+ readonly sendTransport: VoiceTransportInfo;
728
+ readonly recvTransport: VoiceTransportInfo;
729
+ readonly routerRtpCapabilities: unknown;
730
+ readonly peers: readonly VoicePeer[];
731
+ } | {
732
+ readonly t: 'produced';
733
+ readonly producerId: string;
734
+ } | {
735
+ readonly t: 'consumed';
736
+ readonly consumerId: string;
737
+ readonly producerId: string;
738
+ readonly peerId: string;
739
+ readonly kind: 'audio';
740
+ readonly rtpParameters: unknown;
741
+ } | {
742
+ readonly t: 'connected';
743
+ readonly transportId: string;
744
+ } | {
745
+ readonly t: 'peer-joined';
746
+ readonly peer: VoicePeer;
747
+ } | {
748
+ readonly t: 'peer-left';
749
+ readonly peerId: string;
750
+ } | {
751
+ readonly t: 'peer-muted';
752
+ readonly peerId: string;
753
+ readonly muted: boolean;
754
+ } | {
755
+ readonly t: 'error';
756
+ readonly code: string;
757
+ readonly message: string;
758
+ };
759
+ interface VoiceTransportInfo {
760
+ readonly id: string;
761
+ readonly iceParameters: unknown;
762
+ readonly iceCandidates: unknown;
763
+ readonly dtlsParameters: unknown;
764
+ }
765
+ interface VoicePeer {
766
+ /** The room's client id — the same identifier the render store keys entities' owners by. */
767
+ readonly peerId: string;
768
+ readonly producerId?: string;
769
+ readonly muted: boolean;
770
+ }
771
+ declare function encodeVoiceMessage(m: VoiceClientMessage | VoiceServerMessage): Uint8Array;
772
+ /**
773
+ * Returns `undefined` rather than throwing on anything malformed. This is parsing bytes that
774
+ * arrived over a socket from an untrusted peer; the callers all want "ignore it", and a throw on
775
+ * the supervisor's frame path would be a denial of service with extra steps.
776
+ */
777
+ declare function decodeVoiceMessage<T extends VoiceClientMessage | VoiceServerMessage>(bytes: Uint8Array): T | undefined;
778
+
355
779
  declare const presenceEntity: _irtio_schema.EntityDef<{
356
780
  clientId: _irtio_schema.Type<string, false>;
357
781
  role: _irtio_schema.Type<string, false>;
@@ -384,6 +808,668 @@ declare const relaySchema: Schema<{
384
808
  declare const RELAY_HASH8: Uint8Array;
385
809
  declare function isRelayHash8(hash8: Uint8Array): boolean;
386
810
 
811
+ /**
812
+ * Room-code constants, in the one package every component that has an opinion about them already
813
+ * depends on.
814
+ *
815
+ * They started in `@irtio/supervisor` (`codes.ts`), which is still the only place that *allocates*
816
+ * a code for a running room. D52 added a second minter: quick-match answers a filled queue with a
817
+ * fresh code from the control plane, which has no supervisor dependency and never will (control
818
+ * placing a room would drag cross-room state into a deliberately single-room component). Rather
819
+ * than let control keep a private copy of an alphabet that is a public contract, the contract
820
+ * moved here and `supervisor/codes.ts` re-exports it — the same move `auth.ts` already made for
821
+ * the origin policy when the router became a second enforcer of it.
822
+ */
823
+ /** The unambiguous base-32 alphabet room codes are drawn from: no `0`/`O`, no `1`/`I`. */
824
+ declare const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
825
+ /**
826
+ * The one character that separates a room type from a room id, decided once (M5 part 2, shard
827
+ * note 1).
828
+ *
829
+ * Room types and tenant sharding both want to read structure out of a roomId, and if they reach
830
+ * for their own separators independently they collide. So the delimiter is a contract, stated
831
+ * here, and `parseRoomId` below is the only code that knows it. Two rules follow, binding on
832
+ * everything downstream:
833
+ *
834
+ * - It is never `/`. The alarm sidecar key is `${liveKey}/alarms` and its reverse parse assumes
835
+ * the room segment holds no `/`.
836
+ * - Anything that special-cases a particular room (a directory-pinned `notifications:global`,
837
+ * later) matches the whole roomId by value. It never splits on this character to do it.
838
+ */
839
+ declare const ROOM_TYPE_DELIMITER = ":";
840
+ /**
841
+ * A room type name: lowercase, starts alphanumeric, at most 24 characters.
842
+ *
843
+ * Narrower than the id charset on purpose. A type name is a filename in `irtio/rooms/`, so it has
844
+ * to survive a case-insensitive filesystem, and it must never be mistaken for a room code
845
+ * (`looksLikeRoomCode` upper-cases 4-5 character alphanumeric ids, and lowercase-only types plus
846
+ * the split-before-check rule in `normalizeRoomId` keep those two worlds apart).
847
+ */
848
+ declare const ROOM_TYPE_RE: RegExp;
849
+ /**
850
+ * Externally supplied room ids must match this (`E_ROOM_NOT_FOUND` otherwise).
851
+ *
852
+ * M5 part 2 widened it by exactly one optional prefix: `<type>:`. A plain id means the project's
853
+ * default room type and is accepted verbatim, exactly as it was before, which is what makes every
854
+ * roomId minted before this change keep meaning what it meant. The id half still carries its own
855
+ * 1-32 budget, so a type prefix costs a project nothing from the id it was already using.
856
+ */
857
+ declare const ROOM_ID_RE: RegExp;
858
+ /**
859
+ * The name the default room type is registered under.
860
+ *
861
+ * A plain roomId carries no type and means this one. It is a real name rather than a `null` key so
862
+ * that a project can also address it explicitly (`default:lobby` and `lobby` are the same room),
863
+ * and so that the supervisor's type map has no special case in it.
864
+ */
865
+ declare const DEFAULT_ROOM_TYPE = "default";
866
+ /** A roomId split into its optional type and its id half. */
867
+ interface ParsedRoomId {
868
+ /** The declared room type, or `undefined` for a plain id, which means the default type. */
869
+ readonly type: string | undefined;
870
+ /** The id half: what a room code, a match code, or `__tenant` would be. */
871
+ readonly id: string;
872
+ }
873
+ /**
874
+ * Splits `<type>:<id>` into its halves, or returns `undefined` when the string is not a legal
875
+ * roomId at all. The sole reader of `ROOM_TYPE_DELIMITER`.
876
+ *
877
+ * A plain id parses to `{ type: undefined, id: raw }` rather than to a default type name, because
878
+ * the default type's *name* is a per-project fact the protocol package does not know.
879
+ */
880
+ declare function parseRoomId(raw: string): ParsedRoomId | undefined;
881
+ /** Joins a type and an id back into a roomId. A missing type gives the plain form. */
882
+ declare function formatRoomId(type: string | undefined, id: string): string;
883
+ /**
884
+ * How many characters a control-minted quick-match code carries.
885
+ *
886
+ * Ten, not four. The supervisor's own codes start at four because they are read aloud and typed
887
+ * in by a human, and it grows them on collision because it can see every room it holds. Control
888
+ * can see no such thing: it mints without asking any box whether the code is free (fact 2 — no
889
+ * occupancy read, no round trip), so the code has to be long enough that a collision is not a
890
+ * thing that happens rather than a thing that is retried. Ten characters of this alphabet is 50
891
+ * bits.
892
+ *
893
+ * It is also deliberately longer than five, which is what `looksLikeRoomCode` treats as
894
+ * code-shaped and upper-cases: a ten-character code is passed through verbatim by
895
+ * `normalizeRoomId`, so the string control hands a client is exactly the string the supervisor
896
+ * resolves.
897
+ */
898
+ declare const MATCH_CODE_LENGTH = 10;
899
+
900
+ /**
901
+ * D59: the tenant-local room bus — limits, error names, and the pure validators.
902
+ *
903
+ * This module is the shared half, and it lives in `@irtio/protocol` because the two sides that
904
+ * must agree on it are the room runtime (which is bundled into a room worker and cannot pull in
905
+ * anything heavy) and the supervisor (which enforces every one of these). Neither can be trusted
906
+ * to check on the other's behalf: the runtime's checks are a courtesy that gives a developer a
907
+ * message at the call site, and the supervisor's are the boundary, because room code runs inside
908
+ * a tenant VM and anything the runtime refuses is something a hostile bundle can simply not call.
909
+ *
910
+ * ## The two verbs, and why their guarantees differ
911
+ *
912
+ * `publish`/`subscribe` is fire-and-forget fan-out to *awake* subscribers. Nothing queues, nothing
913
+ * wakes, and there is no ordering promise across channels. A hibernated subscriber misses the
914
+ * message the same way it misses wall-clock time, and resyncs from state when it wakes.
915
+ *
916
+ * `send(roomId, payload)` is directed, durable and at-least-once, and it wakes the target. It
917
+ * rides the durable-alarm machinery: a mailbox entry is an alarm with a payload.
918
+ *
919
+ * These guarantees are deliberately weak. Sharding (M5 part 7) turns `send` into a network hop and
920
+ * `publish` into a shard fan-out, and nothing stronger would survive that. **At-least-once means
921
+ * at-least-once**: a handler can see the same message twice, and must be written to tolerate it.
922
+ *
923
+ * ## The boundary
924
+ *
925
+ * Tenant-scoped only, ever. There is no project argument on any verb, in any layer, at any depth.
926
+ * A room can name a channel and a roomId; it cannot name a project, so a cross-project reach is
927
+ * not something the supervisor refuses, it is something the API cannot express. The supervisor's
928
+ * bus registry is per-tenant because a supervisor *is* one tenant, and the roomId a `send` names
929
+ * is resolved against that supervisor's own room table and nothing else.
930
+ */
931
+ /**
932
+ * Bus limits. Every number here is enforced in the supervisor, and each is small on purpose: the
933
+ * bus is signaling, not state transfer. Anything big goes through a room the recipient joins.
934
+ */
935
+ declare const BUS_LIMITS: {
936
+ /**
937
+ * Max UTF-8 bytes in one payload, for both verbs.
938
+ *
939
+ * 4 KiB is a quarter of the KV value limit, and it is sized for what the feature is for: a
940
+ * match-ready notice, an invite, a bracket announcement — an id, a couple of names, a reason.
941
+ * A payload is also a *store* cost for `send`, because a mailbox entry persists in the alarm
942
+ * sidecar, so the ceiling on one room's mailbox is this times `mailboxDepth`, and both numbers
943
+ * were picked together.
944
+ */
945
+ readonly payloadBytes: number;
946
+ /** Max UTF-8 bytes in a channel name. Names are vocabulary, not data. */
947
+ readonly channelBytes: 128;
948
+ /** Max channels one room may hold a subscription to at once. */
949
+ readonly subscriptionsPerRoom: 32;
950
+ /**
951
+ * Max undelivered mailbox entries for one target room.
952
+ *
953
+ * This is a store bound as much as a memory one: entries live in the alarm sidecar, so at the
954
+ * payload cap above, a full mailbox is 256 KiB of persisted object. Past this, a `send` is
955
+ * refused at the sender with a named error rather than silently dropped at the target, because
956
+ * a sender that is outrunning a receiver is a fact the sender can act on.
957
+ */
958
+ readonly mailboxDepth: 64;
959
+ /**
960
+ * How many times one mailbox entry may be delivered before it is dead-lettered.
961
+ *
962
+ * The bound exists because at-least-once and the room crash threshold compose badly on purpose.
963
+ * A handler that always throws surfaces as a crash, and the supervisor's window is 3 restarts in
964
+ * 60 seconds; an entry that re-armed forever would close the room in four deliveries, on every
965
+ * wake, for as long as the entry existed. Five attempts is enough to ride out a transient (a
966
+ * deploy mid-flight, a wake that raced a hibernate) and few enough that a genuinely poisonous
967
+ * message costs a room one bad minute rather than its life. What happens then is a log line and
968
+ * a drop: honest for v1, and the docs say so rather than implying a dead-letter queue exists.
969
+ */
970
+ readonly maxDeliveryAttempts: 5;
971
+ /**
972
+ * Bus operations one room may issue per second, averaged, and the burst it may take at once.
973
+ *
974
+ * This is the "one hot room cannot melt the supervisor" guardrail D59 asks for. It counts both
975
+ * verbs together, because both cost the supervisor a fan-out or a store write, and a limiter
976
+ * that only counted the cheap one would be a limiter a room could route around.
977
+ */
978
+ readonly opsPerSecond: 50;
979
+ readonly opsBurst: 100;
980
+ /**
981
+ * How long a delivered-but-unacknowledged mailbox entry stays armed before it is delivered
982
+ * again. A visibility timeout, and it is what makes at-least-once actually true rather than
983
+ * nearly true.
984
+ *
985
+ * Posting a message to a worker is not the same as the worker running it: a room that dies
986
+ * between the post and the handler (a crash, a restart, a hibernate that raced the delivery)
987
+ * would otherwise lose the message with no trace, which is at-most-once wearing at-least-once's
988
+ * name. So an entry is re-armed for this long at the moment it is posted, and cleared only when
989
+ * the worker says the handler ran. 30 seconds is far longer than a handler takes and short
990
+ * enough that a genuinely lost delivery is retried while anyone still cares.
991
+ */
992
+ readonly redeliveryDelayMs: 30000;
993
+ };
994
+ /**
995
+ * A channel name: the same shape as a room type, widened to allow dots so a project can namespace
996
+ * its channels (`match.ready`, `party.invite`). Lowercase, starts alphanumeric, up to the byte
997
+ * cap. Narrow on purpose: a channel name is a map key that appears in logs and in a report a
998
+ * developer reads, and there is no reason for it to be able to hold arbitrary text.
999
+ */
1000
+ declare const BUS_CHANNEL_RE: RegExp;
1001
+ /**
1002
+ * The named failures. Each one is a thing the caller can tell apart and act on differently: a
1003
+ * payload the caller must shrink, a target the caller named wrong, a receiver the caller is
1004
+ * outrunning, and a rate the caller must slow to.
1005
+ */
1006
+ declare const BUS_ERRORS: {
1007
+ readonly badChannel: "E_BUS_BAD_CHANNEL";
1008
+ readonly payloadTooLarge: "E_BUS_PAYLOAD_TOO_LARGE";
1009
+ readonly tooManySubscriptions: "E_BUS_TOO_MANY_SUBSCRIPTIONS";
1010
+ /** The named room does not exist in this tenant, or is a relay room and runs no handlers. */
1011
+ readonly noSuchRoom: "E_BUS_NO_SUCH_ROOM";
1012
+ readonly mailboxFull: "E_BUS_MAILBOX_FULL";
1013
+ readonly rateLimited: "E_BUS_RATE_LIMITED";
1014
+ };
1015
+ type BusErrorCode = (typeof BUS_ERRORS)[keyof typeof BUS_ERRORS];
1016
+ /** `{ code, message }` when the input is refused, `undefined` when it is fine. */
1017
+ interface BusProblem {
1018
+ readonly code: BusErrorCode;
1019
+ readonly message: string;
1020
+ }
1021
+ /** Validates a channel name. Shared by the runtime's courtesy check and the supervisor's real one. */
1022
+ declare function busChannelProblem(channel: unknown): BusProblem | undefined;
1023
+ /** Validates a payload. Bytes, not characters: the cap is a store and wire cost. */
1024
+ declare function busPayloadProblem(payload: unknown): BusProblem | undefined;
1025
+ /**
1026
+ * The alarm-name prefix a durable mailbox entry is armed under.
1027
+ *
1028
+ * Mailbox entries ride `AlarmSet`, whose `set` **replaces by name**. Two sends of the same payload
1029
+ * to the same room must deliver twice, so every entry gets a minted-unique name rather than a
1030
+ * stable one. The prefix is what lets the supervisor tell a mailbox entry from a room's own alarm
1031
+ * on the way out of `fireDue`, and it is not a legal `room.alarm` name (`isAlarmName` refuses a
1032
+ * leading `__`), so a room cannot arm one itself or cancel someone else's.
1033
+ */
1034
+ declare const BUS_MAILBOX_PREFIX = "__bus.";
1035
+ /** True for an alarm name that is a mailbox entry rather than one of the room's own alarms. */
1036
+ declare function isMailboxAlarm(name: string): boolean;
1037
+ /**
1038
+ * What a mailbox entry's persisted payload holds. Serialised into the alarm sidecar's `payload`
1039
+ * field, which is why the sidecar codec had to grow one.
1040
+ *
1041
+ * `from` is stamped by the supervisor from the sending room's own record, never read off anything
1042
+ * the sender supplied. A room can therefore not forge a source: the field the receiver reads is
1043
+ * written by the only party that knows the truth.
1044
+ */
1045
+ interface BusMailboxEntry {
1046
+ /** The roomId that sent this. Supervisor-stamped. */
1047
+ readonly from: string;
1048
+ readonly payload: string;
1049
+ /** Deliveries attempted so far, including the one in flight. Dead-lettered past the cap. */
1050
+ readonly attempts: number;
1051
+ }
1052
+ /**
1053
+ * M5 part 7 (D67-e): the alarm-name prefix a **cross-shard outbox** entry is armed under, on the
1054
+ * SENDER's own sidecar. A `send` to a room that is not on this shard parks the message here and
1055
+ * forwards it through the control plane; the entry is the retry and the durability, exactly as
1056
+ * `BUS_MAILBOX_PREFIX` is for a local delivery. Recognised by the supervisor before its generic
1057
+ * alarm branch, or it would reach `onAlarm` as a garbage name; a platform rolled back to a
1058
+ * supervisor older than this part would do exactly that, which the docs say beside the same
1059
+ * warning for `__bus.`. Not a legal `room.alarm` name (`isAlarmName` refuses a leading `__`).
1060
+ */
1061
+ declare const BUS_OUTBOX_PREFIX = "__busout.";
1062
+ /** True for an alarm name that is a cross-shard outbox entry. */
1063
+ declare function isOutboxAlarm(name: string): boolean;
1064
+ /**
1065
+ * What an outbox entry's persisted payload holds. `to` is the target room; `id` is the entry's
1066
+ * own name, carried end to end so a target that receives the same message twice (a lost
1067
+ * acknowledgement) logs the duplicate by name. `firstAt` is when the message was parked, for the
1068
+ * one-hour dead-letter bound; `attempts` counts forwards so the retry schedule doubles.
1069
+ */
1070
+ interface BusOutboxEntry {
1071
+ readonly to: string;
1072
+ readonly from: string;
1073
+ readonly payload: string;
1074
+ readonly id: string;
1075
+ readonly firstAt: number;
1076
+ readonly attempts: number;
1077
+ }
1078
+ /**
1079
+ * The cross-shard retry schedule (D67-e): 1, 2, 4 ... 60 seconds between forwards, for up to an
1080
+ * hour after the message was parked, then a dead letter in the sender's log at warn. Enforced
1081
+ * in the supervisor; every number is here so the docs and the code quote the same ones.
1082
+ */
1083
+ declare const BUS_OUTBOX: {
1084
+ readonly firstRetryMs: 1000;
1085
+ readonly maxRetryMs: 60000;
1086
+ readonly ttlMs: number;
1087
+ };
1088
+ /** D67-e: the target's shard is not running; the control plane is placing it. Retry later. */
1089
+ declare const BUS_SHARD_STARTING = "E_SHARD_STARTING";
1090
+
1091
+ /**
1092
+ * D60: room size classes, and the one place the mapping from a declaration to a billed class is
1093
+ * written down.
1094
+ *
1095
+ * A class is a *billing* concept, and saying that plainly is the whole point of this file. What is
1096
+ * enforced on a room is its worker heap cap, which part 2's declared sizing already handles. What
1097
+ * a class does is decide the rate that room's hours are charged at.
1098
+ *
1099
+ * The classes are Small, Medium and Large, with the shared relay host beneath them. Relay is not a
1100
+ * class: a relay room reserves no memory and holds no VM slot, so no declared `memoryMb` maps to
1101
+ * it. Pricing amendment 1 (2026-09-01) retired the Nano class: with the M5 RAM guardrails enforced,
1102
+ * a Nano room's real footprint was a VM slot plus a 32 MB heap floor, which is not meaningfully
1103
+ * cheaper to host than a Small room. A room that would once have been Nano is Small now, and a
1104
+ * schema declaring `nano` fails classification the same way any unknown class does.
1105
+ *
1106
+ * ## Why the class is derived, not declared separately
1107
+ *
1108
+ * `memoryMb` on `defineRoom` already exists, is validated to a whole 32..1024, drives the worker's
1109
+ * `resourceLimits`, and is summed by `checkDeclaredFit` against the VM budget. Deriving the class
1110
+ * from it means a project has one number to think about and the bill cannot disagree with the
1111
+ * enforcement. A separate `class` field could say Small while `memoryMb` said 512, and then the
1112
+ * invoice and the room would be telling different stories about the same room, with no way for a
1113
+ * developer to tell which one was the lie.
1114
+ *
1115
+ * The cost of deriving is that the thresholds are a policy that a project reads off a table rather
1116
+ * than states outright. That is the better trade here: the table is short, it is in the pricing
1117
+ * docs, and the alternative failure mode is a wrong bill.
1118
+ */
1119
+ /** The size classes, smallest first. `small` is what every room that predates D60 is. */
1120
+ type RoomSizeClass = 'small' | 'medium' | 'large';
1121
+ /**
1122
+ * The floor on a declared `memoryMb`.
1123
+ *
1124
+ * The same 32 {@link DEFAULT_MEMORY_MB} is: V8's usable old-generation floor, below which a worker
1125
+ * cannot boot a room at all, so accepting a smaller declaration would only move the failure later.
1126
+ */
1127
+ declare const ROOM_MEMORY_MB_MIN = 32;
1128
+ /**
1129
+ * The ceiling on a declared `memoryMb`, and the top of the Large class.
1130
+ *
1131
+ * M5 part 3.6: 1024, down from the 4096 that `VM_MEM_MIB_MAX` used to lend it by accident. Large is
1132
+ * *advertised* on a 1 GiB basis and priced on one, and until this cap existed a room could declare
1133
+ * 4096 and pay Large's rate for four times Large's memory. The under-charge was the whole band from
1134
+ * 1025 to 4096, silently, with the class table below saying otherwise.
1135
+ *
1136
+ * This is deliberately **not** `VM_MEM_MIB_MAX`. The two numbers answer different questions: this
1137
+ * one is the largest room a project may declare, and 4096 is the largest machine the platform will
1138
+ * build for a project's declarations. Three 1024 MB types at `maxAwake` 1 is still a legal tenant,
1139
+ * so the VM ceiling stays where it is.
1140
+ *
1141
+ * It lives in this file rather than beside the VM sizing constants because it is a *class*
1142
+ * boundary — the top of Large — and because `ROOM_CLASS_MAX_MB` below must be able to read it
1143
+ * without the two modules importing each other.
1144
+ */
1145
+ declare const ROOM_MEMORY_MB_MAX = 1024;
1146
+ /**
1147
+ * The declared-memory ceiling for each class, in MB, as declared on `defineRoom`.
1148
+ *
1149
+ * `small` is 64 because that is the historical default and the rate every existing project is on,
1150
+ * so the boundary is placed where nothing already deployed moves class. Small is also the bottom of
1151
+ * the table: every declaration from V8's 32 MB floor up to 64 is Small, which is what retiring Nano
1152
+ * means in one line.
1153
+ *
1154
+ * `large` is 1024 because that is the 1 GiB basis Large is advertised and priced on, and because
1155
+ * M5 part 3.6 made it the ceiling on a declaration rather than a decoration
1156
+ * ({@link ROOM_MEMORY_MB_MAX}). Before that, `large` read 4096 and a room could declare four times
1157
+ * the memory Large's rate is derived from and pay Large's rate for it. The top of the top class and
1158
+ * the largest declarable room are now the same number by construction, so no band is left between
1159
+ * what a project may ask for and what any class is priced to cover.
1160
+ */
1161
+ declare const ROOM_CLASS_MAX_MB: Readonly<Record<RoomSizeClass, number>>;
1162
+ /**
1163
+ * The class a room with this declared `memoryMb` is billed as.
1164
+ *
1165
+ * An undeclared room is `small`, which is the compatibility rule that matters: every project that
1166
+ * existed before D60 declared nothing, ran on the 64 MB default, and was billed at Small's rate,
1167
+ * and all three of those stay true.
1168
+ */
1169
+ declare function roomClassFor(memoryMb: number | undefined): RoomSizeClass;
1170
+ /**
1171
+ * The `source` a class's room-hours are reported under.
1172
+ *
1173
+ * The size-class dimension rides the existing `source` column rather than a new one, and that is a
1174
+ * deliberate reuse rather than a shortcut. `source` is already in the usage table's primary key
1175
+ * (`project, meter, window_start, source, engine`), it already distinguishes writers of one meter
1176
+ * that must not overwrite each other, and the relay host's `$0.001` rate was already decided to
1177
+ * ride it. One dimension answering "what kind of thing produced this hour" is easier to reason
1178
+ * about than two that can disagree.
1179
+ *
1180
+ * Small stays `room`, unchanged, which is what keeps every window ever written still meaning what
1181
+ * it meant and keeps a Small project's bill byte-identical.
1182
+ */
1183
+ declare function roomHoursSourceFor(cls: RoomSizeClass): string;
1184
+ /** The class a room-hours `source` denotes, or `undefined` when it is not a class at all. */
1185
+ declare function roomClassOfSource(source: string): RoomSizeClass | undefined;
1186
+
1187
+ /**
1188
+ * M5 part 3.5: a tenant VM's memory is arithmetic over its room types' declarations.
1189
+ *
1190
+ * Before this file, `vmMemSizeMibFor` answered the question with two guesses: 256 MiB when any
1191
+ * schema declared physics, 128 MiB otherwise. Both numbers were chosen before anything measured
1192
+ * either of the terms they were standing in for, and part 1 measured them: the supervisor's own
1193
+ * resident set is roughly 53 MiB, so a 128 MiB VM has about 70 MiB for every room in it, and one
1194
+ * minimally-sized worker very nearly exhausts that. The 128 was not a small VM, it was a VM that
1195
+ * could not host a room of any size and did not say so.
1196
+ *
1197
+ * The replacement is a sum, and the whole design is that the sum's terms are things a project
1198
+ * *declared* rather than things the platform inferred:
1199
+ *
1200
+ * ```
1201
+ * S = Σ_type (memoryMb + youngGenMb) × maxAwake
1202
+ * + supervisorBaselineMib
1203
+ * + physicsHeadroomMib (once per VM, when any type declares physics)
1204
+ * vmMib = clamp(ceil(S × 8/7), 128, 4096)
1205
+ * ```
1206
+ *
1207
+ * Three things about that arithmetic are load-bearing and are stated here rather than left to be
1208
+ * rediscovered by whoever changes it next.
1209
+ *
1210
+ * **The native reserve is closed-form.** Part 1's derivation reserves `vmMib / 8` for off-heap
1211
+ * cost (worker stacks, the Rapier WASM code, socket buffers, the encode scratch). That is circular
1212
+ * when `vmMib` is the output rather than the input, so the reserve is solved for instead of
1213
+ * iterated: `vmMib = S × 8/7` makes `vmMib - S` exactly `vmMib / 8`. Pinned by
1214
+ * {@link nativeReserveOf} and a round-trip test — the derived worker caps, applied back to the
1215
+ * size this function returned, must still leave every declared worker fitting.
1216
+ *
1217
+ * **The physics headroom is measured and it is big.** A Rapier world's first step costs a
1218
+ * transient while V8 tier-compiles the WASM on background threads: measured in a real 1-vCPU
1219
+ * guest at **+97.6 MiB over pre-step RSS**, settling to +28 (part 1 report addendum, live probe
1220
+ * 2026-08-31). It is added once per VM rather than per type or per room, because the compile
1221
+ * happens once per process, and it is added at all because without it arithmetic sizing would
1222
+ * recreate exactly the OOM class D55 was built to fix.
1223
+ *
1224
+ * **`maxAwake` is what makes the sum well-defined.** Part 2 summed every declared type once,
1225
+ * because nothing could know how many rooms of a type would be awake together, and recorded the
1226
+ * pessimism as a debt. Multiplying by a declared concurrency is the answer that debt asked for,
1227
+ * and it is only honest because the supervisor *enforces* it: room N+1 of a type is refused. A
1228
+ * `maxAwake` nobody enforced would be a number that made the VM look big enough.
1229
+ *
1230
+ * This module is in `@irtio/protocol` because three packages need the same answer and must not
1231
+ * each carry their own copy of it: the host agent sizes the Firecracker machine, the supervisor
1232
+ * checks the size it was given against what it is about to run, and (part 7) control's
1233
+ * `pickServer` needs to know what a tenant will cost a box before choosing one. The fleet
1234
+ * protocol module is deliberately zero-dependency, which is what lets all three import it.
1235
+ */
1236
+
1237
+ /** The floor. A guest kernel plus a Node process make anything smaller pointless. */
1238
+ declare const VM_MEM_MIB_MIN = 128;
1239
+ /** The ceiling. A guard against a typo booking a whole box's memory for one tenant. */
1240
+ declare const VM_MEM_MIB_MAX = 4096;
1241
+ /**
1242
+ * The supervisor's own resident set with zero rooms awake, in MiB, measured rather than guessed.
1243
+ *
1244
+ * Method (part 1's, re-run in part 3.5 after the baseline-shaving work): `supervisor/dist/bin.js`
1245
+ * booted as a child process with the placed-shaped environment `buildSupervisorEnv` emits, one
1246
+ * minimal room bundled by the real `bundleRoom`, six `/admin/metrics` readings over ~36 s with
1247
+ * `roomsByState` all zero and `sockets: 0` on every one. Rounded up to the next whole MiB.
1248
+ * Provenance, the raw readings and the before/after of the shaving are in
1249
+ * `docs/m5-part3.5-report.md`.
1250
+ *
1251
+ * This is a *constant term* in every VM's size, so a MiB here is a MiB on every tenant on every
1252
+ * box. Re-measure it by the same method if the supervisor's boot-time module graph changes
1253
+ * materially, and record the new provenance rather than nudging the number.
1254
+ *
1255
+ * **54, and most of the drop from part 1's 57 is a re-measurement rather than a saving.** Measured
1256
+ * on the same machine, by the same method, on both builds: the pre-shave build reads a settled max
1257
+ * of 53.46 MiB and the shaved build 53.20, so the lazy-import work is worth about a third of a MiB
1258
+ * and the other two and a half are part 1's number having been taken on a process whose first
1259
+ * reading had not settled. Both halves are in the report, because a constant that quietly improved
1260
+ * would be worse than one that says how little it moved.
1261
+ */
1262
+ declare const SUPERVISOR_BASELINE_MIB = 54;
1263
+ /**
1264
+ * Off-heap headroom, as a fraction of the final VM size: `vmMib / NATIVE_RESERVE_FRACTION`.
1265
+ * A fraction rather than a constant because the things it covers — worker stacks, native
1266
+ * allocations, socket buffers, encode scratch — scale with what a bigger VM was made bigger for.
1267
+ */
1268
+ declare const NATIVE_RESERVE_FRACTION = 8;
1269
+ /**
1270
+ * Added once per VM when any room type declares physics.
1271
+ *
1272
+ * 112, not the measured 97.6: the peak is what a guest must survive, and rounding a survival
1273
+ * margin down is how the week-9 pachinko OOM happened. The nearest power-of-two-ish number above
1274
+ * the peak is the honest rounding direction for a number whose failure mode is the VM dying.
1275
+ */
1276
+ declare const PHYSICS_HEADROOM_MIB = 112;
1277
+ /** Every worker's young generation. Small, and scaling it buys nothing. */
1278
+ declare const YOUNG_GEN_MB = 32;
1279
+ /**
1280
+ * What an undeclared room type is counted at.
1281
+ *
1282
+ * 32 is V8's usable floor for an old generation and the smallest cap the platform can apply, so
1283
+ * counting an undeclared type at anything larger would be inventing a promise the project never
1284
+ * made. Together with {@link DEFAULT_MAX_AWAKE} it is the honest reading of part 1's 128 MiB
1285
+ * finding: one real worker, minimally sized, is what a project that declared nothing was actually
1286
+ * being given.
1287
+ */
1288
+ declare const DEFAULT_MEMORY_MB = 32;
1289
+ /**
1290
+ * How many rooms of an undeclared type may be awake at once.
1291
+ *
1292
+ * One. That is the conservative reading and it is deliberately the number that makes an
1293
+ * undeclared single-type project size to the 128 MiB floor, which is the VM it has always had.
1294
+ * A project that wants more says so, and the sum grows to match, which is the entire point.
1295
+ */
1296
+ declare const DEFAULT_MAX_AWAKE = 1;
1297
+ /**
1298
+ * The ceiling on a declared `maxAwake`.
1299
+ *
1300
+ * 256 rooms of the smallest declarable type (32 + 32 MB) is already 16 GiB before the baseline,
1301
+ * four times the largest VM the platform will build, so anything above it is a number that cannot
1302
+ * be honoured rather than a bigger project. The ceiling exists so the refusal names the mistake at
1303
+ * `defineRoom` time instead of at placement time, where it would be a clamp nobody saw.
1304
+ */
1305
+ declare const MAX_AWAKE_MAX = 256;
1306
+ /** One room type's sizing declarations, as `defineRoom` took them. */
1307
+ interface RoomTypeSizing {
1308
+ readonly type: string;
1309
+ /** Declared worker old-generation cap in MB; `undefined` counts as {@link DEFAULT_MEMORY_MB}. */
1310
+ readonly memoryMb?: number;
1311
+ /** Declared concurrent awake rooms; `undefined` counts as {@link DEFAULT_MAX_AWAKE}. */
1312
+ readonly maxAwake?: number;
1313
+ /** Whether this type's schema declares physics. Adds the headroom once per VM, not per type. */
1314
+ readonly physics?: boolean;
1315
+ }
1316
+ /** The size, and every term that produced it, so a log line or a report can show its working. */
1317
+ interface VmSizing {
1318
+ /** The answer: what the VM is built with, after the reserve and the clamp. */
1319
+ readonly vmMib: number;
1320
+ /** Σ over the declarations, before the baseline and the headroom. */
1321
+ readonly workersMib: number;
1322
+ readonly baselineMib: number;
1323
+ readonly physicsHeadroomMib: number;
1324
+ /** `workersMib + baselineMib + physicsHeadroomMib` — the sum the reserve is solved against. */
1325
+ readonly preReserveMib: number;
1326
+ /** `vmMib - preReserveMib`, which is `vmMib / 8` whenever the clamp did not bite. */
1327
+ readonly nativeReserveMib: number;
1328
+ /** `'floor'` / `'ceiling'` when the clamp moved the answer, else `undefined`. */
1329
+ readonly clamped?: 'floor' | 'ceiling';
1330
+ /** One line naming the arithmetic, for a boot log or an error. */
1331
+ readonly detail: string;
1332
+ }
1333
+ /** The reserve a VM of this size carries: one eighth, floored. */
1334
+ declare function nativeReserveOf(vmMib: number): number;
1335
+ /** What one declared type contributes to the sum: `(memoryMb + youngGen) × maxAwake`. */
1336
+ declare function typeFootprintMib(type: RoomTypeSizing): number;
1337
+ /**
1338
+ * The VM size a project's declarations ask for.
1339
+ *
1340
+ * A project with no room types at all — a relay-only tenant, which still gets a VM until D66's
1341
+ * second half lands — sizes to the floor. So does an undeclared single-type project, which is
1342
+ * the compatibility-shaped case even though compatibility is not what is being kept here: it is
1343
+ * the size that project has always had, and the arithmetic agrees with it rather than being made
1344
+ * to.
1345
+ */
1346
+ declare function vmSizeFor(types: readonly RoomTypeSizing[], options?: {
1347
+ readonly baselineMib?: number;
1348
+ }): VmSizing;
1349
+ /**
1350
+ * How many awake rooms of each class one vCPU is expected to carry.
1351
+ *
1352
+ * The CPU allowance is arithmetic over declarations for the same reason the memory size is: the
1353
+ * alternative is a number chosen from a marketing table that the throttle then has to pretend to
1354
+ * honour. A project declares `memoryMb` and `maxAwake`, the class comes from the memory, and the
1355
+ * quota comes from the sum. One number decides the class, the bill, the heap cap and now the CPU
1356
+ * share, so none of them can disagree with the others.
1357
+ *
1358
+ * **Where these came from.** Small and Large are the owner's anchors. Medium is 8 rather than
1359
+ * the plan's 10, **set from measurement and owner-reviewable at merge**: a density bench against a
1360
+ * real supervisor with real WebSocket clients measured marginal CPU per room on the full
1361
+ * supervisor-and-network path at 1.2% for a casual tick room, 2.3% for an active one, and 7.1% for
1362
+ * a physics room of 60 Rapier bodies. That is about 8.5 physics rooms per core at 60% utilisation
1363
+ * on the bench machine, and the production box is slower per core, so 10 would have over-promised
1364
+ * for exactly the rooms that land in Medium by default. The bench numbers and their caveats are in
1365
+ * `docs/m5-part3.6-report.md`.
1366
+ *
1367
+ * Keep them here, in one table, so an owner adjustment is one line rather than an audit.
1368
+ */
1369
+ declare const ROOMS_PER_VCPU: Readonly<Record<RoomSizeClass, number>>;
1370
+ /** The floor on a derived quota, as a percentage of one vCPU. Below this a room cannot serve. */
1371
+ declare const CPU_QUOTA_PCT_MIN = 10;
1372
+ /** The ceiling: one whole vCPU, which is all a Firecracker guest is built with. */
1373
+ declare const CPU_QUOTA_PCT_MAX = 100;
1374
+ /** A tenant's CPU allowance, and the terms that produced it. */
1375
+ interface VmCpuSizing {
1376
+ /** The answer: percent of one vCPU, for `systemctl set-property … CPUQuota=<n>%`. */
1377
+ readonly quotaPct: number;
1378
+ /** The sum before the clamp, so a log line can show an over-declared project its own number. */
1379
+ readonly rawPct: number;
1380
+ /** `true` when the project declared nothing at all and is therefore not throttled. */
1381
+ readonly undeclared: boolean;
1382
+ /** `'floor'` / `'ceiling'` when the clamp moved the answer, else `undefined`. */
1383
+ readonly clamped?: 'floor' | 'ceiling';
1384
+ /** One line naming the arithmetic, for a boot log. */
1385
+ readonly detail: string;
1386
+ }
1387
+ /**
1388
+ * The CPU quota a project's declarations ask for, as a percentage of the VM's one vCPU.
1389
+ *
1390
+ * ```
1391
+ * rawPct = Σ_type ceil-free (maxAwake × 100 / roomsPerVcpu(class(memoryMb)))
1392
+ * quotaPct = clamp(ceil(rawPct), 10, 100)
1393
+ * ```
1394
+ *
1395
+ * **An undeclared project gets no throttle**, which is the same posture `maxAwake` enforcement took
1396
+ * in part 3.5 and is stated here because it is the rule most likely to be read as an oversight.
1397
+ * Only declarations are held against a project. A project that declared nothing made no promise
1398
+ * about its concurrency, so there is no sum to derive a share from, and inventing one would throttle
1399
+ * every tenant that predates this function on a number it never said.
1400
+ *
1401
+ * **The quota is a ceiling, not a reservation.** `CPUQuota=` bounds what a VM may take; it does not
1402
+ * hold anything back for it. So a project that declares more concurrency than its class density
1403
+ * supports is not taking CPU from its neighbours on the box, it is spreading its own allowance
1404
+ * thinner across its own rooms. The failure mode of an optimistic density is a project's own rooms
1405
+ * ticking late, which is the right place for it to land.
1406
+ *
1407
+ * The class comes from {@link roomClassFor} over the same `memoryMb` that decides the bill, so the
1408
+ * CPU share and the invoice cannot tell different stories about one room.
1409
+ */
1410
+ declare function vmCpuFor(types: readonly RoomTypeSizing[]): VmCpuSizing;
1411
+ /**
1412
+ * Does what a project declared fit a VM of this size?
1413
+ *
1414
+ * Under arithmetic sizing this can only be false one way: a project set an explicit `vmMemMib`
1415
+ * override smaller than its own declarations. That is why the supervisor treats a false answer as
1416
+ * a refusal to boot rather than a log line. Pre-release, an override that lies about the machine
1417
+ * its rooms are running in should fail where somebody is looking.
1418
+ */
1419
+ declare function declarationsFit(vmMemMib: number, types: readonly RoomTypeSizing[], options?: {
1420
+ readonly baselineMib?: number;
1421
+ }): {
1422
+ readonly fits: boolean;
1423
+ readonly required: VmSizing;
1424
+ };
1425
+
1426
+ /**
1427
+ * Room retention: how long a room outlives its last activity, as a room type declares it.
1428
+ *
1429
+ * A room type declares `retention: '10m'` beside `memoryMb` and `maxAwake`, and control's reaper
1430
+ * deletes the room's stored objects once that long has passed since the room last hibernated.
1431
+ * Absent means forever, which is what every room has always had and stays the default.
1432
+ *
1433
+ * **Why a string and not a number of milliseconds.** Every other duration in this repository is a
1434
+ * `durationMs` number, and this one deliberately is not. The declaration is read by a human in a
1435
+ * room file and it is the one number in that file whose mistakes delete data: `retention: 600000`
1436
+ * and `retention: 600_000_000` look alike at a glance, and `'10m'` and `'10000m'` do not. The
1437
+ * parsed number never reaches storage either — control stores the declared string, so a row can
1438
+ * always be read back as the thing the developer wrote.
1439
+ *
1440
+ * **The grammar is narrow on purpose.** One integer, one unit, no space, no fraction, no compound
1441
+ * (`'1h30m'`), no seconds. A window is a coarse promise about when data goes away and the sweep
1442
+ * cadence is its real resolution, so a grammar that can express a precision the platform does not
1443
+ * have would be a lie in the type system. Anything outside it is refused by name at every door
1444
+ * rather than coerced: a retention that was silently rounded, clamped or defaulted is the exact
1445
+ * failure mode a deletion feature must not have.
1446
+ *
1447
+ * This lives in `@irtio/protocol` because it is a declaration constant shared by more than one
1448
+ * package, which is what `sizing.ts` and `classes.ts` are already here for. `@irtio/server` is on
1449
+ * the room-bundle side of the fence and may not import it, so it carries the same grammar and the
1450
+ * same bounds as literals, pinned equal by `packages/supervisor/test/declaration-doors.test.ts`.
1451
+ */
1452
+ /**
1453
+ * The declared form: a positive integer of up to five digits, then one of `m`, `h`, `d`.
1454
+ *
1455
+ * Five digits because the ceiling (3650 days) needs four in days, five in hours, and seven in
1456
+ * minutes would be past the ceiling anyway — the bounds check below is what actually decides, and
1457
+ * the digit cap only exists so a pathological input cannot be a number at all.
1458
+ */
1459
+ declare const RETENTION_RE: RegExp;
1460
+ /** One minute. Below this the sweep cadence (15 minutes by default) is the whole window. */
1461
+ declare const RETENTION_MIN_MS = 60000;
1462
+ /** Ten years. A declaration above this is asking for "forever", which is written by omitting it. */
1463
+ declare const RETENTION_MAX_MS: number;
1464
+ /**
1465
+ * The window in milliseconds, or `undefined` when `raw` is not a legal declaration.
1466
+ *
1467
+ * `undefined` is the single answer for every rejection — malformed, out of range, wrong type —
1468
+ * because every caller does the same thing with it: refuse by name, naming the value it was given.
1469
+ * A caller that needs to say *why* has the value in hand and the bounds exported beside this.
1470
+ */
1471
+ declare function parseRetention(raw: unknown): number | undefined;
1472
+
387
1473
  /**
388
1474
  * Browser-origin policy, shared by every component that has to answer "may this page connect?"
389
1475
  *
@@ -432,4 +1518,4 @@ declare function originAllowed(origins: readonly string[], allowNoOrigin: boolea
432
1518
  */
433
1519
  declare function parseOriginList(raw: string | undefined): readonly string[];
434
1520
 
435
- export { type Call, type ClientCallable, type Credential, ERROR_CATALOGUE, type ErrorCatalogueEntry, ErrorCode, type ErrorCodeDef, type ErrorCodeName, type ErrorPayload, type Frame, FrameType, type Hello, type Msg, type MsgTarget, PRESENCE_COLLECTION, PROTOCOL_VERSION, type Ping, type Pong, type PresenceRecord, RELAY_HASH8, type Reply, type Welcome, builtinRpcs, correctPayload, decodeCall, decodeErrorPayload, decodeFrame, decodeHello, decodeMsg, decodePing, decodePong, decodeReply, decodeWelcome, deltaPayload, encodeCall, encodeCorrectFrame, encodeDeltaFrame, encodeErrorPayload, encodeFrame, encodeHello, encodeMsg, encodePing, encodePong, encodeReply, encodeWelcome, encodeWriteFrame, errorByCode, formatError, isFrameType, isLocalhostOrigin, isRelayHash8, originAllowed, parseOriginList, presenceEntity, readCorrectAppliedTick, readCorrectClientTick, relaySchema, requestOwnership, rpcByIdOf, rpcIdOf, rpcTable, withBuiltins, writePayload };
1521
+ export { type AttributeOptions, BUS_CHANNEL_RE, BUS_ERRORS, BUS_LIMITS, BUS_MAILBOX_PREFIX, BUS_OUTBOX, BUS_OUTBOX_PREFIX, BUS_SHARD_STARTING, type BusErrorCode, type BusMailboxEntry, type BusOutboxEntry, type BusProblem, CLOSE_EGRESS_WALL, CLOSE_TRY_AGAIN_LATER, CODE_ALPHABET, CPU_QUOTA_PCT_MAX, CPU_QUOTA_PCT_MIN, type Call, type ChurnIds, type ClientCallable, type Credential, DEFAULT_MAX_AWAKE, DEFAULT_MEMORY_MB, DEFAULT_ROOM_TYPE, EMPTY_PROFILE, ERROR_CATALOGUE, type ErrorCatalogueEntry, ErrorCode, type ErrorCodeDef, type ErrorCodeName, type ErrorPayload, type Frame, FrameType, HELLO_SCHEMA_SWAP_BIT, type Hello, MATCH_CODE_LENGTH, MAX_AWAKE_MAX, type Msg, type MsgTarget, NATIVE_RESERVE_FRACTION, PHYSICS_HEADROOM_MIB, PRESENCE_COLLECTION, PROFILE_KINDS, PROTOCOL_VERSION, type ParsedRoomId, type Ping, type Pong, type PresenceRecord, type ProfileKind, ProfileLedger, type ProfileRow, type ProfileSnapshot, RELAY_HASH8, RETENTION_MAX_MS, RETENTION_MIN_MS, RETENTION_RE, ROOMS_PER_VCPU, ROOM_CLASS_MAX_MB, ROOM_ID_RE, ROOM_MEMORY_MB_MAX, ROOM_MEMORY_MB_MIN, ROOM_TYPE_DELIMITER, ROOM_TYPE_RE, type Reply, type RoomSizeClass, type RoomTypeSizing, SUPERVISOR_BASELINE_MIB, VM_MEM_MIB_MAX, VM_MEM_MIB_MIN, type VmCpuSizing, type VmSizing, type VoiceClientMessage, type VoicePeer, type VoiceServerMessage, type VoiceTransportInfo, type Welcome, YOUNG_GEN_MB, builtinRpcs, busChannelProblem, busPayloadProblem, correctPayload, declarationsFit, decodeCall, decodeErrorPayload, decodeFrame, decodeHello, decodeMsg, decodePing, decodePong, decodeReply, decodeVoiceMessage, decodeWelcome, deltaPayload, diffProfiles, encodeCall, encodeCorrectFrame, encodeDeltaFrame, encodeErrorPayload, encodeFrame, encodeHello, encodeMsg, encodePing, encodePong, encodeReply, encodeSchemaFrame, encodeVoiceMessage, encodeWelcome, encodeWriteFrame, errorByCode, formatError, formatRoomId, isFrameType, isLocalhostOrigin, isMailboxAlarm, isOutboxAlarm, isRelayHash8, isVoiceMsg, mergeProfiles, nativeReserveOf, originAllowed, parseOriginList, parseRetention, parseRoomId, presenceEntity, readCorrectAppliedTick, readCorrectClientTick, readSchemaPayload, relaySchema, requestOwnership, roomClassFor, roomClassOfSource, roomHoursSourceFor, rpcByIdOf, rpcIdOf, rpcTable, scaleProfile, schemaPayload, topProfileRows, typeFootprintMib, vmCpuFor, vmSizeFor, withBuiltins, writePayload };