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