@voltro/protocol 0.24.0 → 0.26.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.
package/dist/index.d.ts CHANGED
@@ -38,6 +38,28 @@ export declare interface ActionProcedureDescriptor<Name extends string, Input ex
38
38
  readonly exposeAsTool: ExposeAsTool | undefined;
39
39
  /** True when the procedure is kept OFF the wire — no client-group entry and no
40
40
  * route in dev or serve. See `internal` on the definer's options. */
41
+ /**
42
+ * Replace a PLUGIN route that answers to this same tag.
43
+ *
44
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
45
+ * and correctly so — two handlers behind one name is not a thing a caller can
46
+ * reason about. But refusing is the wrong answer when the app deliberately
47
+ * wants its own version: the two escapes available otherwise are to rename
48
+ * your procedure (so the split runs along "who built it" rather than along a
49
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
50
+ * For a frontend developer that is the worst possible partition.
51
+ *
52
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
53
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
54
+ * already composes, since the collision check compares FULL tags and not
55
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
56
+ *
57
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
58
+ * upgrade that adds a route could shadow an app procedure with no diff to
59
+ * read; declaring it makes the intent reviewable and puts the override in the
60
+ * file that performs it.
61
+ */
62
+ readonly overridesPlugin: boolean | undefined;
41
63
  readonly internal: boolean | undefined;
42
64
  }
43
65
 
@@ -54,6 +76,9 @@ export declare const anonymousSubject: (tenantId: string | null) => Subject;
54
76
  /** A guard entry is either a scope check or a relationship check. */
55
77
  export declare type AnyCheckSpec = GuardCheckSpec | PolicyCheckSpec;
56
78
 
79
+ /** Any declared event, with the generics erased — for registries and audits. */
80
+ export declare type AnyEventDescriptor = EventDescriptor<string, Schema.Schema.Any, Schema.Schema.Any>;
81
+
57
82
  /** A guard is either a scope check or a relationship check. */
58
83
  export declare type AnyGuardSpec<Input = unknown> = GuardSpec<Input> | PolicyGuardSpec<Input>;
59
84
 
@@ -485,6 +510,28 @@ export declare interface ConnectionInfoValue {
485
510
  * (every `useMutation` call does). Read by `bindMutation` to dedupe a retried
486
511
  * mutation. Absent for callers that don't send it. */
487
512
  readonly idempotencyKey?: string;
513
+ /**
514
+ * Unix-SECONDS expiry of the credential that authorized this call, when it
515
+ * has one. Absent for credentials with no expiry (anonymous, a non-expiring
516
+ * strategy) — and absent means "no bound", so the failure direction is the
517
+ * behaviour that already existed.
518
+ *
519
+ * It exists for LONG-LIVED work. A request is checked once and is over in
520
+ * milliseconds, so expiry never mattered; an event subscription is a
521
+ * standing state that reconnects forever by design, so one opened a minute
522
+ * before the token dies would otherwise keep delivering for days on a
523
+ * credential that is long gone. `bindEvent` ends the stream here, and
524
+ * `useEvent`'s existing reconnect immediately re-opens it — which is a NEW
525
+ * request, so it re-resolves the subject and re-runs the guards for real.
526
+ * That is what makes the bound seamless rather than a disconnection the app
527
+ * has to handle: still entitled, it continues; no longer entitled, it fails
528
+ * loudly instead of quietly continuing.
529
+ *
530
+ * This bounds EXPIRY, not revocation. A role revoked mid-session is not
531
+ * observed until the credential runs out — do not let this field grow a
532
+ * doc comment that claims otherwise.
533
+ */
534
+ readonly credentialExpiresAt?: number;
488
535
  }
489
536
 
490
537
  /** The two credential shapes a connection can hold. `oauth2` = an
@@ -691,8 +738,99 @@ export declare const defineAction: <const Name extends string, Input extends Sch
691
738
  * need to check who is asking.
692
739
  */
693
740
  readonly internal?: boolean;
741
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
742
+ * inferred — see `overridesPlugin` on the descriptor. */
743
+ readonly overridesPlugin?: boolean;
694
744
  }) => ActionProcedureDescriptor<Name, Input, Output, Error>;
695
745
 
746
+ /**
747
+ * Declare an event.
748
+ *
749
+ * ```ts
750
+ * export const gameStarted = defineEvent({
751
+ * name: 'games.started',
752
+ * key: Schema.Struct({ arenaId: Schema.String }),
753
+ * payload: Schema.Struct({ gameId: Schema.String, startedAt: Schema.Number }),
754
+ * guards: [{ scope: 'display:read' }],
755
+ * })
756
+ * ```
757
+ */
758
+ export declare const defineEvent: <const Name extends string, Key extends Schema.Schema.Any, Payload extends Schema.Schema.Any>(options: {
759
+ /**
760
+ * Wire identifier, `camelCase.dotted` like an rpc tag — and it shares the rpc
761
+ * tag COLLISION SPACE, so a duplicate aborts boot rather than resolving itself
762
+ * at the first delivery. Two declarations answering to one name is precisely
763
+ * the failure mode a string channel has; a name that can collide silently
764
+ * would reintroduce it one level up.
765
+ */
766
+ readonly name: Name;
767
+ /**
768
+ * WHERE it goes. Part of the contract, not a filter the client applies:
769
+ * a subscriber receives only events published under a key it asked for, so
770
+ * the server never sends the others at all.
771
+ *
772
+ * ONLY ROUTING FIELDS BELONG HERE. Every field fragments the subscriber set
773
+ * and costs fan-out dedup — a discriminator the handler reads (`gameType`) is
774
+ * payload, an address the delivery is decided by (`arenaId`) is key.
775
+ *
776
+ * The tenant is NOT part of it and must never be added: it is derived from the
777
+ * subject on both sides, so a cross-tenant delivery is impossible by
778
+ * construction rather than by remembering to filter.
779
+ */
780
+ readonly key: Key;
781
+ /** WHAT happened. Decoded when publishing, so a mismatch is a typed error at
782
+ * the PRODUCER instead of a broken handler at every consumer. */
783
+ readonly payload: Payload;
784
+ /**
785
+ * WHO MAY LISTEN — the same vocabulary as a query's guards, so a
786
+ * resource-scoped rule ("may this terminal watch this arena") stays
787
+ * declarative. `ScopeError` is merged into the wire error union automatically.
788
+ *
789
+ * Re-checked when the SUBJECT changes (a revoked role ends the stream), not on
790
+ * every delivery. Per-delivery authorization is the design a comparable
791
+ * product measured into a scaling wall: one check per subscriber per message
792
+ * makes throughput scale with the audience instead of the publish rate.
793
+ */
794
+ readonly guards?: Guards<Schema.Schema.Type<Key>>;
795
+ /**
796
+ * Deliver recently-buffered events on a FIRST attach. Default `false`, and the
797
+ * default is the interesting half.
798
+ *
799
+ * A fresh subscriber wanting only what happens from now on, and a RECONNECTING
800
+ * subscriber wanting the messages it missed, are different requests that read
801
+ * as one contradiction ("never replay history" vs "never lose a message"). They
802
+ * are separated here: a first attach starts empty unless it opts in, while a
803
+ * re-attach always resumes from the last serial the client saw. Mounting a
804
+ * component is not the same event as a WebSocket dropping, and the framework
805
+ * knows which one it is.
806
+ */
807
+ readonly rewind?: boolean;
808
+ /**
809
+ * Deliver this event to subscribed HTTP targets as well.
810
+ *
811
+ * The unification the whole design is for: ONE declaration, and the audiences
812
+ * are consumers of it. Without this an app that both fans an event out to its
813
+ * screens and posts it to a partner declares the thing twice, in two shapes,
814
+ * and the two drift — which is the defect a declaration exists to remove, one
815
+ * level up from the string channel it already removed.
816
+ *
817
+ * Requires `@voltro/plugin-webhooks`. Absent ⇒ no outbound delivery, and no
818
+ * cost.
819
+ */
820
+ readonly webhook?: EventWebhookSpec;
821
+ /**
822
+ * `'each'` (default) — every delivery matters; a slow subscriber loses the
823
+ * oldest and is told how many.
824
+ *
825
+ * `'latest'` — a newer delivery supersedes a pending one; a slow subscriber
826
+ * gets the current value and is told nothing, because nothing was lost.
827
+ *
828
+ * See {@link EventDeliverySemantics}. The test is "would a consumer be wrong
829
+ * to miss one?" — not "is this event frequent?".
830
+ */
831
+ readonly delivery?: EventDeliverySemantics;
832
+ }) => EventDescriptor<Name, Key, Payload>;
833
+
696
834
  export declare const defineMutation: <const Name extends string, Input extends Schema.Schema.Any, Output extends Schema.Schema.Any, Error extends Schema.Schema.All = typeof Schema.Never>(options: {
697
835
  readonly name: Name;
698
836
  readonly input: Input;
@@ -734,6 +872,9 @@ export declare const defineMutation: <const Name extends string, Input extends S
734
872
  * need to check who is asking.
735
873
  */
736
874
  readonly internal?: boolean;
875
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
876
+ * inferred — see `overridesPlugin` on the descriptor. */
877
+ readonly overridesPlugin?: boolean;
737
878
  }) => MutationProcedureDescriptor<Name, Input, Output, Error>;
738
879
 
739
880
  /**
@@ -828,6 +969,9 @@ export declare const defineQuery: <const Name extends string, Input extends Sche
828
969
  * need to check who is asking.
829
970
  */
830
971
  readonly internal?: boolean;
972
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
973
+ * inferred — see `overridesPlugin` on the descriptor. */
974
+ readonly overridesPlugin?: boolean;
831
975
  }) => QueryProcedureDescriptor<Name, Input, Output, Error>;
832
976
 
833
977
  /**
@@ -844,6 +988,22 @@ export declare const defineStream: <const Name extends string, Input extends Sch
844
988
  * dev or serve. Same contract as `internal` on the other definers; a stream
845
989
  * without it would be a hole in the same boundary. */
846
990
  readonly internal?: boolean;
991
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
992
+ * inferred — see `overridesPlugin` on the descriptor. */
993
+ readonly overridesPlugin?: boolean;
994
+ /**
995
+ * WHO MAY LISTEN.
996
+ *
997
+ * A stream is the same long-lived grant a subscription is, and it was the one
998
+ * primitive that could not express authorization at all — queries, mutations
999
+ * and actions carry `guards:`, streams did not, so any protection lived
1000
+ * hand-written inside an executor where nothing could verify it existed.
1001
+ *
1002
+ * Checked at subscribe and re-checked before every element, so a resource
1003
+ * un-shared or a membership ended stops the stream rather than continuing to
1004
+ * push. Same shape and same semantics as a query's.
1005
+ */
1006
+ readonly guards?: Guards;
847
1007
  }) => StreamProcedureDescriptor<Name, Input, Element, Error>;
848
1008
 
849
1009
  export declare interface DeleteTarget<Input = unknown> extends NestedTargetFields<Input> {
@@ -878,6 +1038,17 @@ export declare const diffRows: (prev: ReadonlyArray<PatchRow>, next: ReadonlyArr
878
1038
  */
879
1039
  export declare const effectiveScopes: (subject: Subject) => ReadonlyArray<string>;
880
1040
 
1041
+ /**
1042
+ * Canonical string for a routing key.
1043
+ *
1044
+ * Object key ORDER must not change the routing: `{a,b}` and `{b,a}` are the same
1045
+ * address, and a subscriber that spelled its literal in the other order is the
1046
+ * kind of never-matches bug this whole design exists to make impossible. Sorting
1047
+ * the entries is what makes the two sides agree without either knowing about the
1048
+ * other.
1049
+ */
1050
+ export declare const encodeEventKey: (key: unknown) => string;
1051
+
881
1052
  /**
882
1053
  * Read the `_tag` off a thrown error value, or `undefined` when it has none.
883
1054
  *
@@ -895,6 +1066,285 @@ export declare const effectiveScopes: (subject: Subject) => ReadonlyArray<string
895
1066
  */
896
1067
  export declare const errorTag: (err: unknown) => string | undefined;
897
1068
 
1069
+ /**
1070
+ * The full routing address: tenant, event, key.
1071
+ *
1072
+ * The tenant is FIRST and is supplied by the caller of this function from the
1073
+ * SUBJECT — never from anything a client sent. Two apps in two tenants
1074
+ * publishing `games.started` for `arena-1` are addressing different channels,
1075
+ * and no amount of key collision can make them the same one.
1076
+ *
1077
+ * Separated by `EVENT_ROUTE_SEP` — NUL, and NOBODY WRITES IT INLINE. The character is
1078
+ * the right separator (it cannot occur in a tenant id or an event name, so no
1079
+ * pair of parts can spell another pair's address), but a source file containing
1080
+ * a literal one is BINARY to every text tool: `grep` skips it and prints
1081
+ * nothing, which reads exactly like a clean file. This repo has already lost an
1082
+ * audit that way.
1083
+ *
1084
+ * The constant exists because escaping it correctly at each site does NOT work
1085
+ * in practice: writing this module produced three literals in three different
1086
+ * files — including inside the comment warning against them — before the
1087
+ * separator was hoisted. So there is exactly one place the character appears,
1088
+ * and `parseEventRoute` / `formatEventRoute` mean no consumer needs to name it
1089
+ * at all. `eventRouteHygiene` in the test file scans the whole package.
1090
+ */
1091
+ export declare const EVENT_ROUTE_SEP = "\0";
1092
+
1093
+ /** First element of every subscription: the stream is live from here on. */
1094
+ export declare const eventAttached: Schema.Struct<{
1095
+ _tag: Schema.Literal<["attached"]>;
1096
+ }>;
1097
+
1098
+ /**
1099
+ * What it means for a subscriber to fall behind.
1100
+ *
1101
+ * `'each'` — every delivery matters. A subscriber that cannot keep up loses the
1102
+ * OLDEST and is told exactly how many. This is the default because it is the
1103
+ * safe reading: an arena that misses a game-start signal must find out.
1104
+ *
1105
+ * `'latest'` — only the current value matters, and a newer delivery SUPERSEDES a
1106
+ * pending one. A subscriber that falls behind receives the current state on its
1107
+ * next read and is told nothing, because nothing was lost: for a 60Hz stream of
1108
+ * positions, frame 1 stopped being interesting the moment frame 2 existed.
1109
+ *
1110
+ * The distinction is semantic, not a performance knob. Choosing `'latest'` for a
1111
+ * stream where each delivery matters silently drops the ones in between; choosing
1112
+ * `'each'` for a per-frame stream makes a slow client work through a backlog to
1113
+ * reach a state it could have had immediately, and report a "loss" that was
1114
+ * never a loss.
1115
+ *
1116
+ * The honest test: **would a consumer be wrong to miss one?** If the next value
1117
+ * supersedes it, that is `'latest'` — and it is probably state rather than an
1118
+ * event at all.
1119
+ */
1120
+ export declare type EventDeliverySemantics = 'each' | 'latest';
1121
+
1122
+ /**
1123
+ * A declared event: a name, a routing key, a payload, and who may listen.
1124
+ *
1125
+ * Type parameters are inferred from the Schemas, and every call site — `publish`,
1126
+ * `useEvent`, a workflow trigger — derives its types from this one value.
1127
+ */
1128
+ export declare interface EventDescriptor<Name extends string, Key extends Schema.Schema.Any, Payload extends Schema.Schema.Any> {
1129
+ readonly kind: 'event';
1130
+ readonly name: Name;
1131
+ readonly key: Key;
1132
+ readonly payload: Payload;
1133
+ /** Who may LISTEN. Re-checked when the subject changes, not per delivery —
1134
+ * see `EventDefinition.guards` for why that distinction is deliberate. */
1135
+ readonly guards: Guards<Schema.Schema.Type<Key>> | undefined;
1136
+ /** Deliver buffered events on a FIRST attach. Default false. */
1137
+ readonly rewind: boolean | undefined;
1138
+ /** `'each'` (default) or `'latest'` — see the definer. */
1139
+ readonly delivery: EventDeliverySemantics | undefined;
1140
+ /** Opt this event into outbound HTTP delivery. See `EventWebhookSpec`. */
1141
+ readonly webhook: EventWebhookSpec | undefined;
1142
+ }
1143
+
1144
+ /**
1145
+ * One delivery.
1146
+ *
1147
+ * `origin` + `n` are what make a lost message COUNTABLE. `n` is monotonic per
1148
+ * (origin, event, tenant, key), so a subscriber that sees 7 then 9 knows exactly
1149
+ * one delivery is gone — where a dropping buffer, which is what every
1150
+ * `onSlowConsumer: 'drop-oldest'` option in this space actually is, discards in
1151
+ * silence by definition. Silence is the one outcome you cannot build on: an
1152
+ * arena cannot tell "no game started" from "the start signal was dropped".
1153
+ *
1154
+ * It is per ORIGIN rather than global because two instances publishing the same
1155
+ * key have no shared clock and no shared counter. A global sequence would need
1156
+ * one owner per key — an extra hop on every publish and a failover story for
1157
+ * every key — which is a real architecture (a single-threaded actor per room)
1158
+ * and not the one this framework has.
1159
+ */
1160
+ export declare const eventEnvelope: <P extends Schema.Schema.Any>(payload: P) => Schema.Struct<{
1161
+ _tag: Schema.Literal<["event"]>;
1162
+ /** Publishing instance id — serials are only comparable within one. */
1163
+ origin: typeof Schema.String;
1164
+ /** Monotonic per (origin, event, tenant, key). */
1165
+ n: typeof Schema.Number;
1166
+ emittedAt: typeof Schema.Number;
1167
+ payload: P;
1168
+ }>;
1169
+
1170
+ /** Told to the subscriber when the SERVER knows it cannot fill a gap. */
1171
+ export declare const eventGap: Schema.Struct<{
1172
+ _tag: Schema.Literal<["gap"]>;
1173
+ /** How many deliveries are known lost. Never a guess: a shortfall is computed
1174
+ * from the requested serial against what the ring still holds. */
1175
+ missed: typeof Schema.Number;
1176
+ /** `buffer` — evicted before this subscriber could be served.
1177
+ * `resume` — the re-attach asked for a serial older than the ring. */
1178
+ reason: Schema.Literal<["buffer", "resume"]>;
1179
+ }>;
1180
+
1181
+ /** The routing key did not decode against the descriptor's schema. */
1182
+ export declare class EventKeyInvalid extends EventKeyInvalid_base {
1183
+ }
1184
+
1185
+ declare const EventKeyInvalid_base: Schema.TaggedErrorClass<EventKeyInvalid, "EventKeyInvalid", {
1186
+ readonly _tag: Schema.tag<"EventKeyInvalid">;
1187
+ } & {
1188
+ event: typeof Schema.String;
1189
+ message: typeof Schema.String;
1190
+ }>;
1191
+
1192
+ /** The payload did not decode against the descriptor's schema. */
1193
+ export declare class EventPayloadInvalid extends EventPayloadInvalid_base {
1194
+ }
1195
+
1196
+ declare const EventPayloadInvalid_base: Schema.TaggedErrorClass<EventPayloadInvalid, "EventPayloadInvalid", {
1197
+ readonly _tag: Schema.tag<"EventPayloadInvalid">;
1198
+ } & {
1199
+ event: typeof Schema.String;
1200
+ message: typeof Schema.String;
1201
+ }>;
1202
+
1203
+ /** The encoded envelope exceeds `MAX_EVENT_ENVELOPE_BYTES`. */
1204
+ export declare class EventPayloadTooLarge extends EventPayloadTooLarge_base {
1205
+ }
1206
+
1207
+ declare const EventPayloadTooLarge_base: Schema.TaggedErrorClass<EventPayloadTooLarge, "EventPayloadTooLarge", {
1208
+ readonly _tag: Schema.tag<"EventPayloadTooLarge">;
1209
+ } & {
1210
+ event: typeof Schema.String;
1211
+ bytes: typeof Schema.Number;
1212
+ limit: typeof Schema.Number;
1213
+ }>;
1214
+
1215
+ export declare type EventResumePoint = {
1216
+ readonly origin: string;
1217
+ readonly n: number;
1218
+ };
1219
+
1220
+ /** Where a re-attaching subscriber left off, per origin. */
1221
+ export declare const eventResumePoint: Schema.Struct<{
1222
+ origin: typeof Schema.String;
1223
+ n: typeof Schema.Number;
1224
+ }>;
1225
+
1226
+ export declare const eventRoute: (tenantId: string | null, event: string, key: unknown) => string;
1227
+
1228
+ export declare type EventStreamEvent<P> = {
1229
+ readonly _tag: 'attached';
1230
+ } | {
1231
+ readonly _tag: 'event';
1232
+ readonly origin: string;
1233
+ readonly n: number;
1234
+ readonly emittedAt: number;
1235
+ readonly payload: P;
1236
+ } | {
1237
+ readonly _tag: 'gap';
1238
+ readonly missed: number;
1239
+ readonly reason: 'buffer' | 'resume';
1240
+ };
1241
+
1242
+ /**
1243
+ * What a subscription emits. `attached` first, then `event`s, with `gap`
1244
+ * interleaved whenever the server can prove a loss.
1245
+ */
1246
+ export declare const eventStreamEvent: <P extends Schema.Schema.Any>(payload: P) => Schema.Union<[Schema.Struct<{
1247
+ _tag: Schema.Literal<["attached"]>;
1248
+ }>, Schema.Struct<{
1249
+ _tag: Schema.Literal<["event"]>;
1250
+ /** Publishing instance id — serials are only comparable within one. */
1251
+ origin: typeof Schema.String;
1252
+ /** Monotonic per (origin, event, tenant, key). */
1253
+ n: typeof Schema.Number;
1254
+ emittedAt: typeof Schema.Number;
1255
+ payload: P;
1256
+ }>, Schema.Struct<{
1257
+ _tag: Schema.Literal<["gap"]>;
1258
+ /** How many deliveries are known lost. Never a guess: a shortfall is computed
1259
+ * from the requested serial against what the ring still holds. */
1260
+ missed: typeof Schema.Number;
1261
+ /** `buffer` — evicted before this subscriber could be served.
1262
+ * `resume` — the re-attach asked for a serial older than the ring. */
1263
+ reason: Schema.Literal<["buffer", "resume"]>;
1264
+ }>]>;
1265
+
1266
+ /**
1267
+ * The subscription payload: the key, plus where to resume.
1268
+ *
1269
+ * `resume` present ⇒ this is a RE-attach and the client is owed continuity;
1270
+ * absent ⇒ a first attach, which gets nothing older than itself unless the
1271
+ * descriptor opted into `rewind`. The distinction lives on the wire because only
1272
+ * the client knows whether it has seen this stream before — the server cannot
1273
+ * tell a reconnect from a fresh mount.
1274
+ */
1275
+ export declare const eventSubscribeInput: <K extends Schema.Schema.Any>(key: K) => Schema.Struct<{
1276
+ key: K;
1277
+ resume: Schema.optional<Schema.Array$<Schema.Struct<{
1278
+ origin: typeof Schema.String;
1279
+ n: typeof Schema.Number;
1280
+ }>>>;
1281
+ }>;
1282
+
1283
+ /**
1284
+ * Lift an event descriptor into its rpc.
1285
+ *
1286
+ * A `stream: true` rpc, like a query — the framework's push transport is the
1287
+ * WebSocket a subscription already holds open, and reusing it is why `useEvent`
1288
+ * shares one connection lifecycle, one reconnect policy and one devtools view
1289
+ * with `useSubscription` instead of being a second realtime concept in the same
1290
+ * app.
1291
+ */
1292
+ export declare const eventToRpc: <Name extends string, Key extends Schema.Schema.Any, Payload extends Schema.Schema.Any>(descriptor: EventDescriptor<Name, Key, Payload>, extraErrors?: ReadonlyArray<Schema.Schema.All>) => Rpc.Rpc<Name, Schema.Struct<{
1293
+ key: Key;
1294
+ resume: Schema.optional<Schema.Array$<Schema.Struct<{
1295
+ origin: typeof Schema.String;
1296
+ n: typeof Schema.Number;
1297
+ }>>>;
1298
+ }>, Stream<Schema.Union<[Schema.Struct<{
1299
+ _tag: Schema.Literal<["attached"]>;
1300
+ }>, Schema.Struct<{
1301
+ _tag: Schema.Literal<["event"]>;
1302
+ /** Publishing instance id — serials are only comparable within one. */
1303
+ origin: typeof Schema.String;
1304
+ /** Monotonic per (origin, event, tenant, key). */
1305
+ n: typeof Schema.Number;
1306
+ emittedAt: typeof Schema.Number;
1307
+ payload: Payload;
1308
+ }>, Schema.Struct<{
1309
+ _tag: Schema.Literal<["gap"]>;
1310
+ /** How many deliveries are known lost. Never a guess: a shortfall is computed
1311
+ * from the requested serial against what the ring still holds. */
1312
+ missed: typeof Schema.Number;
1313
+ /** `buffer` — evicted before this subscriber could be served.
1314
+ * `resume` — the re-attach asked for a serial older than the ring. */
1315
+ reason: Schema.Literal<["buffer", "resume"]>;
1316
+ }>]>, Schema.Schema.Any | Schema.Schema<never, never, unknown>>, typeof Schema.Never, never>;
1317
+
1318
+ /**
1319
+ * The WEBHOOK audience of a declared event.
1320
+ *
1321
+ * Present ⇒ `@voltro/plugin-webhooks` treats this event as an outbound one:
1322
+ * subscribers can register HTTP targets for it and every publish is delivered to
1323
+ * them, signed and retried, from the SAME call that fans it out to clients.
1324
+ *
1325
+ * Structurally typed rather than importing the plugin's own types, and that is a
1326
+ * dependency direction rather than a preference: `@voltro/protocol` is
1327
+ * browser-safe and must not reach a plugin. The plugin reads this block and maps
1328
+ * it onto its own knobs — the shapes are the plugin's to define, so a value here
1329
+ * is a plain number or string, never one of its enums.
1330
+ *
1331
+ * Namespaced under `webhook:` rather than spread across the descriptor because
1332
+ * these settings are meaningless to the other three audiences. A `retry` at the
1333
+ * top level would read as if it applied to client delivery, which is
1334
+ * at-most-once by design and has no retry at all.
1335
+ */
1336
+ export declare interface EventWebhookSpec {
1337
+ /** Human-readable summary for the dashboard's event list. */
1338
+ readonly description?: string;
1339
+ /** Payload schema version. Bump when subscribers must adapt. Default 1. */
1340
+ readonly version?: number;
1341
+ /** Shared ceiling across ALL deliveries of this event — the runaway-emit
1342
+ * guard. Over-limit deliveries are deferred, never dropped. */
1343
+ readonly rateLimit?: {
1344
+ readonly perMinute: number;
1345
+ };
1346
+ }
1347
+
898
1348
  /** Normalize the `exposeAsTool` shorthand. `true` is only valid when the
899
1349
  * descriptor carries a top-level `description`; callers pass that in. */
900
1350
  export declare type ExposeAsTool = boolean | ExposeAsToolSpec;
@@ -952,6 +1402,9 @@ export declare const findAdvisoryResourceGuards: (procedures: ReadonlyArray<{
952
1402
  /** Record a completed response for replay. */
953
1403
  export declare const finishIdempotent: (store: IdempotencyStore, scope: string, key: string, response: IdempotencyResponse, now: number) => Promise<void>;
954
1404
 
1405
+ /** A route rendered for humans — logs, the inspect surface, the dashboard. */
1406
+ export declare const formatEventRoute: (route: string) => string;
1407
+
955
1408
  /** The currently-registered policy-guard resolver, or `undefined`. */
956
1409
  export declare const getPolicyGuardResolver: () => PolicyGuardResolver | undefined;
957
1410
 
@@ -1133,6 +1586,8 @@ export declare interface InsertTarget<Input = unknown, Row = unknown, Item = Rec
1133
1586
  readonly shapeItem?: ((input: Input, optimisticId: string) => Item) | undefined;
1134
1587
  }
1135
1588
 
1589
+ export declare const isEventDescriptor: (value: unknown) => value is AnyEventDescriptor;
1590
+
1136
1591
  /** True when every row in the set carries an `id`. The dispatcher uses
1137
1592
  * this to decide patch-vs-full-snapshot: a result whose rows aren't
1138
1593
  * id-keyed (rare — a custom projection that drops the id) can't be
@@ -1165,6 +1620,23 @@ export declare const isWireReachable: (descriptor: {
1165
1620
  readonly internal?: boolean | undefined;
1166
1621
  }) => boolean;
1167
1622
 
1623
+ /**
1624
+ * How large one encoded envelope may be, on EVERY dialect.
1625
+ *
1626
+ * Postgres `NOTIFY` dies past 8000 bytes and the other transports have no such
1627
+ * bound. Enforcing the limit only where the wire imposes it would make "switch
1628
+ * the broadcast provider" a silent behaviour change — an app developed against
1629
+ * Redis would start failing when it moved to the pg-native path, at the worst
1630
+ * possible moment. So the smallest transport's ceiling is the framework's
1631
+ * ceiling, minus room for the envelope around the payload.
1632
+ *
1633
+ * An oversized payload is also a design smell in its own right: an event says
1634
+ * that something happened, so `photo.added` carries a photo REFERENCE, and the
1635
+ * consumer fetches the photo through a route that can stream, cache and
1636
+ * authorize it.
1637
+ */
1638
+ export declare const MAX_EVENT_ENVELOPE_BYTES = 7500;
1639
+
1168
1640
  /** In-memory store — the default for single-process dev + the test double. */
1169
1641
  export declare const memoryIdempotencyStore: () => IdempotencyStore;
1170
1642
 
@@ -1189,6 +1661,28 @@ export declare interface MutationProcedureDescriptor<Name extends string, Input
1189
1661
  readonly exposeAsTool: ExposeAsTool | undefined;
1190
1662
  /** True when the procedure is kept OFF the wire — no client-group entry and no
1191
1663
  * route in dev or serve. See `internal` on the definer's options. */
1664
+ /**
1665
+ * Replace a PLUGIN route that answers to this same tag.
1666
+ *
1667
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
1668
+ * and correctly so — two handlers behind one name is not a thing a caller can
1669
+ * reason about. But refusing is the wrong answer when the app deliberately
1670
+ * wants its own version: the two escapes available otherwise are to rename
1671
+ * your procedure (so the split runs along "who built it" rather than along a
1672
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
1673
+ * For a frontend developer that is the worst possible partition.
1674
+ *
1675
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
1676
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
1677
+ * already composes, since the collision check compares FULL tags and not
1678
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
1679
+ *
1680
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
1681
+ * upgrade that adds a route could shadow an app procedure with no diff to
1682
+ * read; declaring it makes the intent reviewable and puts the override in the
1683
+ * file that performs it.
1684
+ */
1685
+ readonly overridesPlugin: boolean | undefined;
1192
1686
  readonly internal: boolean | undefined;
1193
1687
  }
1194
1688
 
@@ -1235,6 +1729,13 @@ export declare interface ObservabilityContribution {
1235
1729
  readonly sampler?: unknown;
1236
1730
  }
1237
1731
 
1732
+ /** Split a route back into its three parts. */
1733
+ export declare const parseEventRoute: (route: string) => {
1734
+ readonly tenantId: string | null;
1735
+ readonly event: string;
1736
+ readonly key: string;
1737
+ };
1738
+
1238
1739
  /** A single row in a subscription result. Must carry an `id`; everything
1239
1740
  * else is opaque to the patch layer. */
1240
1741
  export declare type PatchRow = Readonly<Record<string, unknown>> & {
@@ -1270,6 +1771,41 @@ export declare type PluginActivateHook = (ctx: PluginLifecycleContext) => Effect
1270
1771
  * touches them.
1271
1772
  */
1272
1773
  export declare interface PluginBindContext {
1774
+ /**
1775
+ * This process's replica identity — the same value the event bus stamps as
1776
+ * its publish `origin` and the membership registry announces under.
1777
+ *
1778
+ * ONE id across all three on purpose: a plugin holding state per replica
1779
+ * (presence is the case) must be able to correlate "who owns this" with "is
1780
+ * that one still alive", and three identities for one process would make the
1781
+ * correlation quietly wrong rather than obviously broken.
1782
+ */
1783
+ readonly instanceId?: string;
1784
+ /**
1785
+ * Live instance membership — subscribe to learn when a replica joins, leaves
1786
+ * or restarts.
1787
+ *
1788
+ * Present only when the host provides one. A plugin holding per-replica state
1789
+ * needs it and cannot derive it: pub/sub delivers messages, it does not report
1790
+ * who is on the channel, and an instance that dies simply goes quiet.
1791
+ */
1792
+ readonly membership?: {
1793
+ readonly onChange: (listener: (event: {
1794
+ readonly kind: 'joined' | 'left' | 'restarted';
1795
+ readonly instanceId: string;
1796
+ }) => void) => () => void;
1797
+ };
1798
+ /**
1799
+ * The app's cross-replica broadcast transport, when one is configured.
1800
+ *
1801
+ * Absent ⇒ single instance, which is a complete answer rather than a
1802
+ * degraded one. A plugin must not branch on "do we have a cluster" — that
1803
+ * branch is how a feature comes to work in dev and differ in production.
1804
+ */
1805
+ readonly broadcast?: {
1806
+ readonly publish: (channel: string, payload: string) => unknown;
1807
+ readonly subscribe: (channel: string, handler: (payload: string) => void) => unknown;
1808
+ };
1273
1809
  /**
1274
1810
  * The framework's already-open `SqlClient` (from `@effect/sql`) — the
1275
1811
  * SAME pool the app's store uses. A plugin runs raw SQL through this
@@ -2083,6 +2619,28 @@ export declare interface QueryProcedureDescriptor<Name extends string, Input ext
2083
2619
  readonly exposeAsTool: ExposeAsTool | undefined;
2084
2620
  /** True when the procedure is kept OFF the wire — no client-group entry and no
2085
2621
  * route in dev or serve. See `internal` on the definer's options. */
2622
+ /**
2623
+ * Replace a PLUGIN route that answers to this same tag.
2624
+ *
2625
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
2626
+ * and correctly so — two handlers behind one name is not a thing a caller can
2627
+ * reason about. But refusing is the wrong answer when the app deliberately
2628
+ * wants its own version: the two escapes available otherwise are to rename
2629
+ * your procedure (so the split runs along "who built it" rather than along a
2630
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
2631
+ * For a frontend developer that is the worst possible partition.
2632
+ *
2633
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
2634
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
2635
+ * already composes, since the collision check compares FULL tags and not
2636
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
2637
+ *
2638
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
2639
+ * upgrade that adds a route could shadow an app procedure with no diff to
2640
+ * read; declaring it makes the intent reviewable and puts the override in the
2641
+ * file that performs it.
2642
+ */
2643
+ readonly overridesPlugin: boolean | undefined;
2086
2644
  readonly internal: boolean | undefined;
2087
2645
  }
2088
2646
 
@@ -2440,6 +2998,10 @@ export declare interface StreamProcedureDescriptor<Name extends string, Input ex
2440
2998
  /** True when the stream is kept OFF the wire — no client-group entry and no
2441
2999
  * route in dev or serve. See `internal` on the definer's options. */
2442
3000
  readonly internal: boolean | undefined;
3001
+ /** WHO MAY LISTEN. Checked at subscribe AND re-checked before every element,
3002
+ * the same as a query's — a stream is a long-lived grant and the scopes that
3003
+ * justified it can be withdrawn while it is still open. */
3004
+ readonly guards: Guards | undefined;
2443
3005
  }
2444
3006
 
2445
3007
  export declare const streamToRpc: <Name extends string, Input extends Schema.Schema.Any, Element extends Schema.Schema.Any, Err extends Schema.Schema.All>(descriptor: StreamProcedureDescriptor<Name, Input, Element, Err>, extraErrors?: ExtraErrors) => Rpc.Rpc<Name, Input extends Schema.Struct.Fields ? Schema.Struct<Input> : Input, Stream<Element, Schema.Schema.All>, typeof Schema.Never, never>;
@@ -2484,36 +3046,36 @@ export declare class SubjectService extends SubjectService_base {
2484
3046
 
2485
3047
  declare const SubjectService_base: Context.TagClass<SubjectService, "@voltro/Subject", {
2486
3048
  readonly id: string;
2487
- readonly tenantId: string;
2488
3049
  readonly type: "user";
3050
+ readonly tenantId: string;
2489
3051
  readonly scopes?: readonly string[] | undefined;
2490
3052
  readonly metadata?: {
2491
3053
  readonly [x: string]: unknown;
2492
3054
  } | undefined;
2493
3055
  } | {
2494
3056
  readonly id: string;
2495
- readonly tenantId: string;
2496
3057
  readonly type: "apiKey";
3058
+ readonly tenantId: string;
2497
3059
  readonly scopes?: readonly string[] | undefined;
2498
3060
  readonly metadata?: {
2499
3061
  readonly [x: string]: unknown;
2500
3062
  } | undefined;
2501
3063
  } | {
2502
3064
  readonly id: string;
2503
- readonly tenantId: string;
2504
3065
  readonly type: "serviceAccount";
3066
+ readonly tenantId: string;
2505
3067
  readonly scopes?: readonly string[] | undefined;
2506
3068
  readonly metadata?: {
2507
3069
  readonly [x: string]: unknown;
2508
3070
  } | undefined;
2509
3071
  } | {
2510
3072
  readonly id: null;
2511
- readonly tenantId: string | null;
2512
3073
  readonly type: "anonymous";
3074
+ readonly tenantId: string | null;
2513
3075
  } | {
2514
3076
  readonly id: string;
2515
- readonly tenantId: null;
2516
3077
  readonly type: "system";
3078
+ readonly tenantId: null;
2517
3079
  readonly scopes?: readonly string[] | undefined;
2518
3080
  readonly metadata?: {
2519
3081
  readonly [x: string]: unknown;