@clowder-ai/plugin-sdk 0.1.0-beta.5

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.
@@ -0,0 +1,999 @@
1
+ /**
2
+ * Wire dispatch classifier — pre-dispatch frame classification per the
3
+ * disposition table (T-A through T-M, §3.8-1 plus beta.8 closure).
4
+ *
5
+ * This module classifies decoded NDJSON frames into one of the 13
6
+ * disposition classes. T-A (transport failure) and T-B (JSON parse error)
7
+ * are handled by the NDJSON frame decoder layer — this classifier covers
8
+ * T-C through T-M.
9
+ *
10
+ * A frame that passes all rejection checks returns disposition=null with
11
+ * outcome='accept', indicating a valid request that should be dispatched
12
+ * to a method handler.
13
+ *
14
+ * This module defines nothing new — every classification rule traces to
15
+ * the frozen disposition table in @clowder-ai/plugin-contract.
16
+ */
17
+ import { validateRequestId, hasHandshakeAuthorityInjection, validateCandidateHello, validateBrokerReadyParams, validateSessionBinding, validateEffectiveGrants, validateEventsPublishInput, validateEventsPublishResult, isWireMethod, isWireUInt53, isCanonicalUInt53Token, NOTIFICATION_METHODS, WIRE_METHOD_REGISTRY, INVALID_REQUEST_CODE, INVALID_REQUEST_MESSAGE, METHOD_NOT_FOUND_CODE, METHOD_NOT_FOUND_MESSAGE, INVALID_PARAMS_CODE, INVALID_PARAMS_MESSAGE, PING_NONCE_MIN_LENGTH, PING_NONCE_MAX_LENGTH, SUBSCRIBE_HANDLE_MIN_LENGTH, SUBSCRIBE_HANDLE_MAX_LENGTH, SUBSCRIBE_SUBSCRIPTION_ID_MIN_LENGTH, SUBSCRIBE_SUBSCRIPTION_ID_MAX_LENGTH, ACK_SUBSCRIPTION_ID_MIN_LENGTH, ACK_SUBSCRIPTION_ID_MAX_LENGTH, ACK_TOKEN_MIN_LENGTH, ACK_TOKEN_MAX_LENGTH, ALL_ERROR_CODES, APPLICATION_ERROR_CODES, ERROR_CODE_TO_MESSAGE,
18
+ // Per-arm application error codes (for data schema dispatch)
19
+ HANDSHAKE_REJECTED_CODE, HANDSHAKE_REJECTED_MESSAGE, DELIVERY_REJECTED_CODE, DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE, SNAPSHOT_UNAVAILABLE_CODE,
20
+ // Standard error code (ParseError null-id arm validation)
21
+ PARSE_ERROR_CODE,
22
+ // Reject-reason closed enums (application error data validation)
23
+ HANDSHAKE_REJECT_REASONS, DELIVERY_REJECT_REASONS, SNAPSHOT_UNAVAILABLE_REASONS, } from '@clowder-ai/plugin-contract';
24
+ // ---------------------------------------------------------------------------
25
+ // Error response builders
26
+ // ---------------------------------------------------------------------------
27
+ function respondInvalidRequestNull() {
28
+ return {
29
+ disposition: 'T-D',
30
+ outcome: 'respond',
31
+ response: {
32
+ jsonrpc: '2.0',
33
+ id: null,
34
+ error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE },
35
+ },
36
+ };
37
+ }
38
+ function respondInvalidRequestId(id) {
39
+ return {
40
+ disposition: 'T-F',
41
+ outcome: 'respond',
42
+ response: {
43
+ jsonrpc: '2.0',
44
+ id,
45
+ error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE },
46
+ },
47
+ };
48
+ }
49
+ function respondMethodNotFound(id) {
50
+ return {
51
+ disposition: 'T-F',
52
+ outcome: 'respond',
53
+ response: {
54
+ jsonrpc: '2.0',
55
+ id,
56
+ error: { code: METHOD_NOT_FOUND_CODE, message: METHOD_NOT_FOUND_MESSAGE },
57
+ },
58
+ };
59
+ }
60
+ function respondInvalidParams(id) {
61
+ return {
62
+ disposition: 'T-F',
63
+ outcome: 'respond',
64
+ response: {
65
+ jsonrpc: '2.0',
66
+ id,
67
+ error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE },
68
+ },
69
+ };
70
+ }
71
+ function respondInvalidParamsValue(id) {
72
+ return {
73
+ disposition: 'T-G',
74
+ outcome: 'respond',
75
+ response: {
76
+ jsonrpc: '2.0',
77
+ id,
78
+ error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE },
79
+ },
80
+ };
81
+ }
82
+ function respondHandshakeAuthorityViolation(id) {
83
+ return {
84
+ disposition: 'T-G',
85
+ outcome: 'respond',
86
+ response: {
87
+ jsonrpc: '2.0',
88
+ id,
89
+ error: {
90
+ code: HANDSHAKE_REJECTED_CODE,
91
+ message: HANDSHAKE_REJECTED_MESSAGE,
92
+ data: { reason: 'AUTHORITY_VIOLATION' },
93
+ },
94
+ },
95
+ };
96
+ }
97
+ function close(disposition) {
98
+ return { disposition, outcome: 'close' };
99
+ }
100
+ function accept(disposition) {
101
+ return { disposition, outcome: 'accept' };
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Non-scalar string detection (T-C canonicality, lone surrogate check)
105
+ // ---------------------------------------------------------------------------
106
+ /**
107
+ * Returns true if any string value or object key in the parsed JSON tree
108
+ * contains a lone surrogate (U+D800–U+DFFF). These are non-scalar strings
109
+ * per the Unicode specification and fail the T-C canonicality predicate.
110
+ *
111
+ * Lone surrogates roundtrip through JSON.stringify (ES2019+ escapes them
112
+ * as \uXXXX), so byte-equality alone cannot catch them.
113
+ */
114
+ function containsNonScalarString(value) {
115
+ if (typeof value === 'string') {
116
+ for (let i = 0; i < value.length; i++) {
117
+ const c = value.charCodeAt(i);
118
+ if (c >= 0xD800 && c <= 0xDBFF) {
119
+ // High surrogate — must be followed by a low surrogate (U+DC00–U+DFFF)
120
+ const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
121
+ if (next < 0xDC00 || next > 0xDFFF)
122
+ return true;
123
+ i++; // Skip the valid low surrogate pair partner
124
+ }
125
+ else if (c >= 0xDC00 && c <= 0xDFFF) {
126
+ // Lone low surrogate (not preceded by a high surrogate)
127
+ return true;
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+ if (value === null || typeof value !== 'object')
133
+ return false;
134
+ if (Array.isArray(value)) {
135
+ for (const item of value) {
136
+ if (containsNonScalarString(item))
137
+ return true;
138
+ }
139
+ return false;
140
+ }
141
+ for (const key of Object.keys(value)) {
142
+ if (containsNonScalarString(key))
143
+ return true;
144
+ if (containsNonScalarString(value[key]))
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+ /**
150
+ * Detect numbers whose JSON.stringify form uses exponent notation.
151
+ *
152
+ * V8 serialises numbers ≥ 10^21 (and very small fractions) in exponent
153
+ * form (e.g. `1e+21`). When the raw frame also uses the same exponent
154
+ * form, byte-equality passes — but exponent-form numeric tokens are
155
+ * non-canonical per the WireUInt53 profile (which requires raw decimal
156
+ * digits `0|[1-9][0-9]*`, no exponent). Deep traversal mirrors
157
+ * containsNonScalarString.
158
+ */
159
+ function containsExponentNumber(value) {
160
+ if (typeof value === 'number') {
161
+ const s = String(value);
162
+ return s.includes('e') || s.includes('E');
163
+ }
164
+ if (value === null || typeof value !== 'object')
165
+ return false;
166
+ if (Array.isArray(value)) {
167
+ for (const item of value) {
168
+ if (containsExponentNumber(item))
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ for (const v of Object.values(value)) {
174
+ if (containsExponentNumber(v))
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // WireUInt53 raw-token validation (P1-1 maintainer requirement)
181
+ // ---------------------------------------------------------------------------
182
+ /**
183
+ * Check whether any WireUInt53 position in the parsed frame value has a
184
+ * numeric token whose V8-canonical form violates the WireUInt53 raw grammar
185
+ * (0|[1-9][0-9]{0,15}, no sign, no decimal, no exponent).
186
+ *
187
+ * After byte-equality passes (rawStr === JSON.stringify(value)), the raw
188
+ * token at any numeric position IS String(parsedValue). So we walk the
189
+ * parsed structure to WireUInt53 positions and validate String(n) against
190
+ * isCanonicalUInt53Token. This catches negative integers (-1), fractions
191
+ * (1.5), and oversized values (>2^53-1) at the T-C layer, before any
192
+ * method-specific validation runs.
193
+ *
194
+ * WireUInt53 positions in the frozen schema:
195
+ * - params.meta.deadlineUnixMs (every request/notification)
196
+ * - params.input.deadlineUnixMs (host.lifecycle.drain input)
197
+ * - params.input.grantRevision (host.grants.changed input)
198
+ * - result.grantRevision (broker.hello SessionBinding)
199
+ */
200
+ function hasNonCanonicalUInt53Token(value, inFlight) {
201
+ const requestMethod = typeof value.method === 'string'
202
+ ? value.method
203
+ : undefined;
204
+ const params = value.params;
205
+ if (requestMethod !== undefined && params !== null && typeof params === 'object' && !Array.isArray(params)) {
206
+ const paramsObj = params;
207
+ // ── params.meta.deadlineUnixMs ──
208
+ const meta = paramsObj.meta;
209
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
210
+ const metaObj = meta;
211
+ if (typeof metaObj.deadlineUnixMs === 'number') {
212
+ if (!isCanonicalUInt53Token(String(metaObj.deadlineUnixMs)))
213
+ return true;
214
+ }
215
+ }
216
+ // ── method-owned input WireUInt53 leaves ──
217
+ const input = paramsObj.input;
218
+ if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
219
+ const inputObj = input;
220
+ if (requestMethod === 'host.lifecycle.drain' && typeof inputObj.deadlineUnixMs === 'number') {
221
+ if (!isCanonicalUInt53Token(String(inputObj.deadlineUnixMs)))
222
+ return true;
223
+ }
224
+ if (requestMethod === 'host.grants.changed' && typeof inputObj.grantRevision === 'number') {
225
+ if (!isCanonicalUInt53Token(String(inputObj.grantRevision)))
226
+ return true;
227
+ }
228
+ }
229
+ }
230
+ // H7 is a WireUInt53 result leaf only for broker.hello's SessionBinding.
231
+ // Consult the correlated in-flight row before applying this raw-token gate:
232
+ // result.grantRevision on every other response belongs to that row's own
233
+ // result grammar and must therefore reach T-H rather than T-C.
234
+ const result = value.result;
235
+ const responseMethod = typeof value.id === 'string'
236
+ ? inFlight.get(value.id)?.method
237
+ : undefined;
238
+ if (!('method' in value) && responseMethod === 'broker.hello' && result !== null && typeof result === 'object' && !Array.isArray(result)) {
239
+ const resultObj = result;
240
+ if (typeof resultObj.grantRevision === 'number') {
241
+ if (!isCanonicalUInt53Token(String(resultObj.grantRevision)))
242
+ return true;
243
+ }
244
+ }
245
+ return false;
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Contract-mirror imports (key sets from contract-mirror.ts)
249
+ //
250
+ // These mirror contract type-level constraints (additionalProperties: false)
251
+ // that lack runtime exports. Each is drift-tested in contract-mirror.test.ts.
252
+ // See contract-mirror.ts for deletion schedule and anchoring.
253
+ // ---------------------------------------------------------------------------
254
+ import { MESSAGING_ERROR_CODE_SET, RESPONSE_SUCCESS_KEYS, RESPONSE_ERROR_KEYS, NOTIFICATION_ALLOWED_KEYS, REQUEST_ALLOWED_KEYS, PARAMS_ALLOWED_KEYS, META_ALLOWED_KEYS, PING_INPUT_KEYS, DRAIN_INPUT_KEYS, SUBSCRIBE_INPUT_KEYS, ACK_INPUT_KEYS, GRANTS_CHANGED_INPUT_KEYS, PING_RESULT_KEYS, SUBSCRIBE_RESULT_KEYS, DELIVER_RESULT_KEYS, DELIVER_DELIVERY_ID_MIN_LENGTH, DELIVER_DELIVERY_ID_MAX_LENGTH, ERROR_BODY_STANDARD_KEYS, ERROR_BODY_APPLICATION_KEYS, REASON_DATA_KEYS, CODE_DATA_KEYS, } from './contract-mirror.js';
255
+ // ---------------------------------------------------------------------------
256
+ // Derived constants (built from contract runtime imports, NOT mirrors)
257
+ // ---------------------------------------------------------------------------
258
+ // Error code validation sets (built from contract arrays).
259
+ const KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES);
260
+ const APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES);
261
+ // Reason enum sets (built from contract arrays).
262
+ const HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS);
263
+ const DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS);
264
+ const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS);
265
+ // Standard error codes that mandate null id (not string RequestId).
266
+ // ParseError (-32700) ALWAYS has id: null per the contract envelope.
267
+ // If we reach the error validation path, id is already a valid string
268
+ // (verified upstream), so ParseError with string id is invalid.
269
+ const NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]);
270
+ // ---------------------------------------------------------------------------
271
+ // Per-method application error allowlists (frozen per-row registry)
272
+ // ---------------------------------------------------------------------------
273
+ //
274
+ // Every registry row's application-error set resolves through the closed
275
+ // application table in #1165. Eligibility is keyed off the row, NOT
276
+ // leafClosure — RESERVED rows have frozen error sets too.
277
+ //
278
+ // Rows 1-2 (broker.hello/ready): HANDSHAKE_REJECTED
279
+ // Rows 3-7 (messaging send/append/sub/read/ack): DOMAIN_ERROR, DEADLINE_EXPIRED
280
+ // Row 8 (messaging.snapshot): DOMAIN_ERROR, DEADLINE_EXPIRED, SNAPSHOT_UNAVAILABLE
281
+ // Row 9 (host.messaging.deliver): DELIVERY_REJECTED
282
+ // Row 10 (host.grants.changed): notification-only (no response)
283
+ // Row 11 (host.lifecycle.ping): standard only (no application errors)
284
+ // Row 12 (host.lifecycle.drain): DEADLINE_EXPIRED
285
+ // Row 13 (events.publish): standard only (Host policy errors stay transport-owned)
286
+ //
287
+ // Standard errors are always allowed on every row. Application error
288
+ // codes NOT in the per-row allowlist → T-H.
289
+ const EMPTY_ERROR_SET = new Set();
290
+ const HANDSHAKE_ERROR_SET = new Set([HANDSHAKE_REJECTED_CODE]);
291
+ const MESSAGING_ERROR_SET = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]);
292
+ const SNAPSHOT_ERROR_SET = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE, SNAPSHOT_UNAVAILABLE_CODE]);
293
+ const DELIVERY_ERROR_SET = new Set([DELIVERY_REJECTED_CODE]);
294
+ const DEADLINE_ONLY_SET = new Set([DEADLINE_EXPIRED_CODE]);
295
+ const METHOD_APPLICATION_ERROR_ALLOW = {
296
+ 'broker.hello': HANDSHAKE_ERROR_SET,
297
+ 'broker.ready': HANDSHAKE_ERROR_SET,
298
+ 'messaging.send': MESSAGING_ERROR_SET,
299
+ 'messaging.appendElements': MESSAGING_ERROR_SET,
300
+ 'messaging.subscribe': MESSAGING_ERROR_SET,
301
+ 'messaging.read': MESSAGING_ERROR_SET,
302
+ 'messaging.ack': MESSAGING_ERROR_SET,
303
+ 'messaging.snapshot': SNAPSHOT_ERROR_SET,
304
+ 'host.messaging.deliver': DELIVERY_ERROR_SET,
305
+ 'host.grants.changed': EMPTY_ERROR_SET, // notification-only
306
+ 'host.lifecycle.ping': EMPTY_ERROR_SET, // standard only
307
+ 'host.lifecycle.drain': DEADLINE_ONLY_SET,
308
+ 'events.publish': EMPTY_ERROR_SET,
309
+ };
310
+ // DELIVER_RESULT_KEYS imported from contract-mirror.ts (row 9 ack shape).
311
+ // ---------------------------------------------------------------------------
312
+ // Response candidate sub-classifier (T-H / T-L)
313
+ // ---------------------------------------------------------------------------
314
+ function classifyResponseCandidate(value, inFlight) {
315
+ const hasResult = 'result' in value;
316
+ const hasError = 'error' in value;
317
+ // ── Closed envelope structure ──────────────────────────────────────
318
+ if (value.jsonrpc !== '2.0')
319
+ return close('T-H');
320
+ // Mutual exclusivity: exactly one of result/error
321
+ if (hasResult && hasError)
322
+ return close('T-H');
323
+ // Closed outer keys: no additional members
324
+ const allowedKeys = hasResult ? RESPONSE_SUCCESS_KEYS : RESPONSE_ERROR_KEYS;
325
+ for (const key of Object.keys(value)) {
326
+ if (!allowedKeys.has(key))
327
+ return close('T-H');
328
+ }
329
+ // ── id validation ──────────────────────────────────────────────────
330
+ if (!('id' in value))
331
+ return close('T-H');
332
+ const id = value.id;
333
+ if (typeof id !== 'string')
334
+ return close('T-H');
335
+ if (validateRequestId(id) === null)
336
+ return close('T-H');
337
+ // ── In-flight correlation ──────────────────────────────────────────
338
+ const inFlightEntry = inFlight.get(id);
339
+ if (inFlightEntry === undefined)
340
+ return close('T-H');
341
+ // ── Error body: closed union validation ────────────────────────────
342
+ if (hasError) {
343
+ const error = value.error;
344
+ if (error === null || typeof error !== 'object' || Array.isArray(error)) {
345
+ return close('T-H');
346
+ }
347
+ const errObj = error;
348
+ // code: number, message: string (structural)
349
+ if (typeof errObj.code !== 'number')
350
+ return close('T-H');
351
+ if (typeof errObj.message !== 'string')
352
+ return close('T-H');
353
+ // Error code must be from the closed set
354
+ if (!KNOWN_ERROR_CODES.has(errObj.code))
355
+ return close('T-H');
356
+ // Code → message canonical mapping
357
+ const expectedMessage = ERROR_CODE_TO_MESSAGE[errObj.code];
358
+ if (errObj.message !== expectedMessage)
359
+ return close('T-H');
360
+ // Standard vs application error body structure
361
+ if (APPLICATION_CODES.has(errObj.code)) {
362
+ // Application errors (-32090..-32094) MUST have `data` (object)
363
+ if (!('data' in errObj))
364
+ return close('T-H');
365
+ if (errObj.data === null || typeof errObj.data !== 'object' || Array.isArray(errObj.data)) {
366
+ return close('T-H');
367
+ }
368
+ // Closed keys: {code, message, data} only
369
+ for (const key of Object.keys(errObj)) {
370
+ if (!ERROR_BODY_APPLICATION_KEYS.has(key))
371
+ return close('T-H');
372
+ }
373
+ // Per-arm data schema validation
374
+ const dataCheck = validateApplicationErrorData(errObj.code, errObj.data);
375
+ if (dataCheck !== null)
376
+ return dataCheck;
377
+ }
378
+ else {
379
+ // Standard errors: ParseError (-32700) mandates id: null.
380
+ // We already validated id is a string (not null) upstream.
381
+ // Therefore ParseError with string id is a protocol violation.
382
+ if (NULL_ID_ERROR_CODES.has(errObj.code))
383
+ return close('T-H');
384
+ // Standard errors (-32700..-32603) MUST NOT have `data`
385
+ if ('data' in errObj)
386
+ return close('T-H');
387
+ // Closed keys: {code, message} only
388
+ for (const key of Object.keys(errObj)) {
389
+ if (!ERROR_BODY_STANDARD_KEYS.has(key))
390
+ return close('T-H');
391
+ }
392
+ }
393
+ // ── Per-method error code restriction ────────────────────────────
394
+ // Application errors are only valid on methods whose frozen per-row
395
+ // error set includes them. Standard errors are always allowed.
396
+ // The complete map covers all 13 rows — no fallback needed.
397
+ if (APPLICATION_CODES.has(errObj.code)) {
398
+ if (!METHOD_APPLICATION_ERROR_ALLOW[inFlightEntry.method].has(errObj.code)) {
399
+ return close('T-H');
400
+ }
401
+ }
402
+ return accept('T-L');
403
+ }
404
+ // ── Success result: method-specific shape validation ───────────────
405
+ const resultCheck = validateResponseResult(value.result, inFlightEntry);
406
+ if (resultCheck !== null)
407
+ return resultCheck;
408
+ return accept('T-L');
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // Application error data schema validation (per-arm)
412
+ // ---------------------------------------------------------------------------
413
+ /**
414
+ * Validate the `data` field of an application error against the
415
+ * per-arm closed schema (additionalProperties: false).
416
+ *
417
+ * 5 arms: HandshakeRejected, DeliveryRejected, DomainError,
418
+ * DeadlineExpired, SnapshotUnavailable.
419
+ *
420
+ * Contract seam: DomainError.data.code (MessagingErrorCode) has no
421
+ * runtime enum in the contract public surface — only the TypeScript
422
+ * type union exists. We validate structure (key + string type) but
423
+ * skip enum validation to avoid a second truth source (P15).
424
+ * See Sol R3 F1 → Fable escalation.
425
+ */
426
+ function validateApplicationErrorData(code, data) {
427
+ switch (code) {
428
+ case HANDSHAKE_REJECTED_CODE: {
429
+ // data: { reason: HandshakeRejectReason } — closed
430
+ for (const key of Object.keys(data)) {
431
+ if (!REASON_DATA_KEYS.has(key))
432
+ return close('T-H');
433
+ }
434
+ if (typeof data.reason !== 'string')
435
+ return close('T-H');
436
+ if (!HANDSHAKE_REASONS.has(data.reason))
437
+ return close('T-H');
438
+ return null;
439
+ }
440
+ case DELIVERY_REJECTED_CODE: {
441
+ // data: { reason: DeliveryRejectReason } — closed
442
+ for (const key of Object.keys(data)) {
443
+ if (!REASON_DATA_KEYS.has(key))
444
+ return close('T-H');
445
+ }
446
+ if (typeof data.reason !== 'string')
447
+ return close('T-H');
448
+ if (!DELIVERY_REASONS.has(data.reason))
449
+ return close('T-H');
450
+ return null;
451
+ }
452
+ case DOMAIN_ERROR_CODE: {
453
+ // data: { code: MessagingErrorCode } — closed keys + enum
454
+ // MESSAGING_ERROR_CODE_SET from contract-mirror.ts (drift-tested
455
+ // against messaging.schema.json enum, Fable ruling on R3 seam).
456
+ for (const key of Object.keys(data)) {
457
+ if (!CODE_DATA_KEYS.has(key))
458
+ return close('T-H');
459
+ }
460
+ if (typeof data.code !== 'string')
461
+ return close('T-H');
462
+ if (!MESSAGING_ERROR_CODE_SET.has(data.code))
463
+ return close('T-H');
464
+ return null;
465
+ }
466
+ case DEADLINE_EXPIRED_CODE: {
467
+ // data: Record<string, never> — must be empty object
468
+ if (Object.keys(data).length !== 0)
469
+ return close('T-H');
470
+ return null;
471
+ }
472
+ case SNAPSHOT_UNAVAILABLE_CODE: {
473
+ // data: { reason: SnapshotUnavailableReason } — closed
474
+ for (const key of Object.keys(data)) {
475
+ if (!REASON_DATA_KEYS.has(key))
476
+ return close('T-H');
477
+ }
478
+ if (typeof data.reason !== 'string')
479
+ return close('T-H');
480
+ if (!SNAPSHOT_REASONS.has(data.reason))
481
+ return close('T-H');
482
+ return null;
483
+ }
484
+ default:
485
+ // Unknown application code — should be unreachable since
486
+ // APPLICATION_CODES was already checked. Defense-in-depth.
487
+ return close('T-H');
488
+ }
489
+ }
490
+ // ---------------------------------------------------------------------------
491
+ // Response result shape validation (per-method, CLOSED rows only)
492
+ // ---------------------------------------------------------------------------
493
+ /**
494
+ * Validate the `result` value of a success response against the
495
+ * correlated in-flight method's expected result shape.
496
+ *
497
+ * CLOSED rows: full per-method result shape validation (additionalProperties: false).
498
+ * RESERVED rows: no shape contract to validate — only cross-frame oracle applies.
499
+ *
500
+ * Returns null if valid; DispatchResult (T-H) if invalid.
501
+ */
502
+ function validateResponseResult(result, entry) {
503
+ const method = entry.method;
504
+ const row = WIRE_METHOD_REGISTRY[method];
505
+ // RESERVED rows: fail closed — no executable result schema exists.
506
+ // Row 9 (deliver) has a frozen ack shape; all others → T-H.
507
+ if (row.leafClosure !== 'CLOSED') {
508
+ return validateReservedRowResult(result, entry);
509
+ }
510
+ switch (method) {
511
+ case 'host.lifecycle.ping': {
512
+ // PingResult: {nonce: string} — additionalProperties: false
513
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
514
+ return close('T-H');
515
+ }
516
+ const obj = result;
517
+ // Closed keys: {nonce} only
518
+ for (const key of Object.keys(obj)) {
519
+ if (!PING_RESULT_KEYS.has(key))
520
+ return close('T-H');
521
+ }
522
+ if (typeof obj.nonce !== 'string')
523
+ return close('T-H');
524
+ // Nonce bounds
525
+ const cpLen = [...obj.nonce].length;
526
+ if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) {
527
+ return close('T-H');
528
+ }
529
+ // Cross-frame oracle: nonce byte-equality (REQUIRED for ping).
530
+ // Ping's nonce echo is the fundamental liveness proof — accepting
531
+ // a response without verifying the oracle defeats the purpose.
532
+ // Missing snapshot is a caller bug; fail-closed, not fail-open.
533
+ if (entry.requestSnapshot?.nonce === undefined)
534
+ return close('T-H');
535
+ if (obj.nonce !== entry.requestSnapshot.nonce)
536
+ return close('T-H');
537
+ return null;
538
+ }
539
+ case 'broker.hello': {
540
+ if (!validateSessionBinding(result))
541
+ return close('T-H');
542
+ // SessionBinding is Host-authoritative only for H5–H9. Its four
543
+ // candidate fields are cross-frame echoes, so accepting a structurally
544
+ // valid but different binding would settle the wrong in-flight hello.
545
+ const candidateHello = entry.requestSnapshot?.candidateHello;
546
+ if (candidateHello === undefined)
547
+ return close('T-H');
548
+ if (result.pluginId !== candidateHello.pluginId ||
549
+ result.packageDigest !== candidateHello.packageDigest ||
550
+ result.contractVersion !== candidateHello.contractVersion ||
551
+ result.wireVersion !== candidateHello.wireVersion) {
552
+ return close('T-H');
553
+ }
554
+ return null;
555
+ }
556
+ case 'broker.ready': {
557
+ if (result !== null)
558
+ return close('T-H');
559
+ return null;
560
+ }
561
+ case 'host.lifecycle.drain': {
562
+ // DrainResult: null
563
+ if (result !== null)
564
+ return close('T-H');
565
+ return null;
566
+ }
567
+ case 'messaging.subscribe': {
568
+ // SubscribeResult: {subscriptionId: string} — additionalProperties: false
569
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
570
+ return close('T-H');
571
+ }
572
+ const obj = result;
573
+ // Closed keys: {subscriptionId} only
574
+ for (const key of Object.keys(obj)) {
575
+ if (!SUBSCRIBE_RESULT_KEYS.has(key))
576
+ return close('T-H');
577
+ }
578
+ if (typeof obj.subscriptionId !== 'string')
579
+ return close('T-H');
580
+ const cpLen = [...obj.subscriptionId].length;
581
+ if (cpLen < SUBSCRIBE_SUBSCRIPTION_ID_MIN_LENGTH || cpLen > SUBSCRIBE_SUBSCRIPTION_ID_MAX_LENGTH) {
582
+ return close('T-H');
583
+ }
584
+ return null;
585
+ }
586
+ case 'messaging.ack': {
587
+ // MessagingAckResult: null
588
+ if (result !== null)
589
+ return close('T-H');
590
+ return null;
591
+ }
592
+ case 'events.publish': {
593
+ return validateEventsPublishResult(result).valid ? null : close('T-H');
594
+ }
595
+ case 'host.grants.changed': {
596
+ // Notification-only — should never be in in-flight.
597
+ // Direction gate prevents this; defense-in-depth.
598
+ return close('T-H');
599
+ }
600
+ default: {
601
+ // Unreachable for CLOSED rows (all covered above).
602
+ // Defense-in-depth: unknown method in in-flight → T-H.
603
+ return close('T-H');
604
+ }
605
+ }
606
+ }
607
+ // ---------------------------------------------------------------------------
608
+ // Cross-frame oracle for RESERVED row responses
609
+ // ---------------------------------------------------------------------------
610
+ /**
611
+ * Validate results for RESERVED rows.
612
+ *
613
+ * P1-2 maintainer requirement: fail closed when no executable result
614
+ * schema exists, rather than treating absence of an oracle as validity.
615
+ *
616
+ * Row 9 (host.messaging.deliver) is the ONE exception among RESERVED
617
+ * rows — its acknowledgement result shape {deliveryId} is frozen as a
618
+ * closed member set. Validated with deliveryId byte-equality oracle
619
+ * AND strict closed-key enforcement.
620
+ *
621
+ * ALL other RESERVED rows: no executable result schema exists → T-H
622
+ * for any result value (fail-closed).
623
+ */
624
+ function validateReservedRowResult(result, entry) {
625
+ // ── Row 9 deliver: frozen ack shape {deliveryId: string, 1..128 cp} ──
626
+ if (entry.method === 'host.messaging.deliver') {
627
+ // Fail-closed if snapshot absent or snapshot deliveryId is not a
628
+ // legal oracle value (string within 1..128 code points).
629
+ const snapId = entry.requestSnapshot?.deliveryId;
630
+ if (snapId === undefined)
631
+ return close('T-H');
632
+ if (typeof snapId !== 'string')
633
+ return close('T-H');
634
+ const snapLen = [...snapId].length;
635
+ if (snapLen < DELIVER_DELIVERY_ID_MIN_LENGTH || snapLen > DELIVER_DELIVERY_ID_MAX_LENGTH) {
636
+ return close('T-H');
637
+ }
638
+ // Result must be a non-null object
639
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
640
+ return close('T-H');
641
+ }
642
+ const resultObj = result;
643
+ // Closed member set: {deliveryId} only — no extras
644
+ for (const key of Object.keys(resultObj)) {
645
+ if (!DELIVER_RESULT_KEYS.has(key))
646
+ return close('T-H');
647
+ }
648
+ // deliveryId must be a string within frozen bounds (1..128 code points)
649
+ if (typeof resultObj.deliveryId !== 'string')
650
+ return close('T-H');
651
+ const resultIdLen = [...resultObj.deliveryId].length;
652
+ if (resultIdLen < DELIVER_DELIVERY_ID_MIN_LENGTH || resultIdLen > DELIVER_DELIVERY_ID_MAX_LENGTH) {
653
+ return close('T-H');
654
+ }
655
+ // Byte-equality oracle: result.deliveryId must match snapshot
656
+ if (resultObj.deliveryId !== snapId)
657
+ return close('T-H');
658
+ return null;
659
+ }
660
+ // ── All other RESERVED rows: no executable result schema → T-H ────
661
+ // The row's result shape is RESERVED (type = `never`). No legal result
662
+ // value exists in v0 — accepting anything would be fail-open.
663
+ return close('T-H');
664
+ }
665
+ // ---------------------------------------------------------------------------
666
+ // Notification sub-classifier (T-J / T-K)
667
+ // ---------------------------------------------------------------------------
668
+ function classifyNotification(value) {
669
+ const method = value.method;
670
+ // Closed outer keys: {jsonrpc, method, params} only
671
+ for (const key of Object.keys(value)) {
672
+ if (!NOTIFICATION_ALLOWED_KEYS.has(key))
673
+ return close('T-K');
674
+ }
675
+ // Missing params → T-K
676
+ if (!('params' in value))
677
+ return close('T-K');
678
+ // Only row 10 (host.grants.changed) is a legal notification in v0
679
+ const isLegalNotification = NOTIFICATION_METHODS.includes(method);
680
+ if (!isLegalNotification)
681
+ return close('T-K');
682
+ // Validate params structure
683
+ const params = value.params;
684
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
685
+ return close('T-K');
686
+ }
687
+ const paramsObj = params;
688
+ // Closed params keys: {meta, input} only
689
+ for (const key of Object.keys(paramsObj)) {
690
+ if (!PARAMS_ALLOWED_KEYS.has(key))
691
+ return close('T-K');
692
+ }
693
+ // meta.deadlineUnixMs
694
+ if (!('meta' in paramsObj))
695
+ return close('T-K');
696
+ const meta = paramsObj.meta;
697
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
698
+ return close('T-K');
699
+ }
700
+ const metaObj = meta;
701
+ // Closed meta keys: {deadlineUnixMs} only
702
+ for (const key of Object.keys(metaObj)) {
703
+ if (!META_ALLOWED_KEYS.has(key))
704
+ return close('T-K');
705
+ }
706
+ const notifDeadline = metaObj.deadlineUnixMs;
707
+ if (typeof notifDeadline !== 'number' || !isWireUInt53(notifDeadline))
708
+ return close('T-K');
709
+ if (notifDeadline === 0)
710
+ return close('T-K');
711
+ // input validation for row 10 (grants.changed)
712
+ if (!('input' in paramsObj))
713
+ return close('T-K');
714
+ const input = paramsObj.input;
715
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
716
+ return close('T-K');
717
+ }
718
+ const inputObj = input;
719
+ // Closed input keys: {grantRevision, effectiveGrants} only
720
+ for (const key of Object.keys(inputObj)) {
721
+ if (!GRANTS_CHANGED_INPUT_KEYS.has(key))
722
+ return close('T-K');
723
+ }
724
+ // grantRevision must be WireUInt53 (≥0)
725
+ const grantRev = inputObj.grantRevision;
726
+ if (typeof grantRev !== 'number' || !isWireUInt53(grantRev))
727
+ return close('T-K');
728
+ // effectiveGrants must pass authorization boundary validation
729
+ if (!Array.isArray(inputObj.effectiveGrants))
730
+ return close('T-K');
731
+ if (!validateEffectiveGrants(inputObj.effectiveGrants))
732
+ return close('T-K');
733
+ return accept('T-J');
734
+ }
735
+ // ---------------------------------------------------------------------------
736
+ // Request sub-classifier (T-E / T-F / T-G / T-I / accept)
737
+ // ---------------------------------------------------------------------------
738
+ function classifyRequest(value, id, method, inFlight) {
739
+ // Closed outer keys: {jsonrpc, id, method, params} only
740
+ for (const key of Object.keys(value)) {
741
+ if (!REQUEST_ALLOWED_KEYS.has(key))
742
+ return respondInvalidRequestId(id);
743
+ }
744
+ // Missing params → T-F InvalidRequest
745
+ if (!('params' in value))
746
+ return respondInvalidRequestId(id);
747
+ // params must be an object
748
+ const params = value.params;
749
+ if (params === null || typeof params !== 'object') {
750
+ return respondInvalidParams(id);
751
+ }
752
+ if (Array.isArray(params))
753
+ return respondInvalidParams(id);
754
+ // Method gate: unknown method → T-F MethodNotFound
755
+ if (!isWireMethod(method))
756
+ return respondMethodNotFound(id);
757
+ // Direction gate: notification-only methods must not appear as requests
758
+ const wireMethod = method;
759
+ const row = WIRE_METHOD_REGISTRY[wireMethod];
760
+ if (row.isNotification)
761
+ return respondInvalidRequestId(id);
762
+ // In-flight collision → T-I
763
+ if (inFlight.has(id))
764
+ return close('T-I');
765
+ const paramsObj = params;
766
+ // Closed params keys: {meta, input} only
767
+ for (const key of Object.keys(paramsObj)) {
768
+ if (!PARAMS_ALLOWED_KEYS.has(key))
769
+ return respondInvalidParams(id);
770
+ }
771
+ // Validate params.meta structure
772
+ if (!('meta' in paramsObj))
773
+ return respondInvalidParams(id);
774
+ const meta = paramsObj.meta;
775
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
776
+ return respondInvalidParams(id);
777
+ }
778
+ const metaObj = meta;
779
+ // Closed meta keys: {deadlineUnixMs} only
780
+ for (const key of Object.keys(metaObj)) {
781
+ if (!META_ALLOWED_KEYS.has(key))
782
+ return respondInvalidParamsValue(id);
783
+ }
784
+ // deadlineUnixMs must be positive WireUInt53
785
+ const reqDeadline = metaObj.deadlineUnixMs;
786
+ if (typeof reqDeadline !== 'number' || !isWireUInt53(reqDeadline))
787
+ return respondInvalidParamsValue(id);
788
+ if (reqDeadline === 0)
789
+ return respondInvalidParamsValue(id);
790
+ // Validate params.input exists and is an object
791
+ if (!('input' in paramsObj))
792
+ return respondInvalidParams(id);
793
+ const input = paramsObj.input;
794
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
795
+ return respondInvalidParams(id);
796
+ }
797
+ // Row-specific value validation
798
+ if (row.leafClosure === 'CLOSED') {
799
+ const valueResult = validateClosedRowInput(wireMethod, input, id);
800
+ if (valueResult !== null)
801
+ return valueResult;
802
+ }
803
+ else {
804
+ // RESERVED rows: input type is `never` → no legal params value exists
805
+ // in the current contract → any invocation is a value violation (T-G).
806
+ return respondInvalidParamsValue(id);
807
+ }
808
+ // Ready plugin-to-Host rows are legal Requests at the contract boundary.
809
+ // The standalone shell deliberately returns MethodNotFound because it is
810
+ // not a Host Broker and must not absorb Host-owned ingress or handshake work;
811
+ // classification itself must not relabel their valid input as T-G or T-F.
812
+ if (row.ready &&
813
+ (wireMethod === 'broker.hello' ||
814
+ wireMethod === 'broker.ready' ||
815
+ wireMethod === 'events.publish')) {
816
+ return accept('T-M');
817
+ }
818
+ // Direction gate: plugin SDK only accepts host-to-plugin methods as
819
+ // inbound requests. Plugin-to-host methods (rows 1–8) received by
820
+ // the plugin are protocol violations → T-F MethodNotFound.
821
+ //
822
+ // Positioned after all envelope/value checks so that:
823
+ // - In-flight collision (T-I) takes precedence (per contract fixtures)
824
+ // - RESERVED row rejection (T-G) takes precedence (input type `never`)
825
+ // - Only CLOSED plugin-to-host rows with valid params reach here
826
+ if (row.direction === 'plugin-to-host')
827
+ return respondMethodNotFound(id);
828
+ // All checks passed — valid host-to-plugin CLOSED-row request for dispatch
829
+ return { disposition: null, outcome: 'accept' };
830
+ }
831
+ // ---------------------------------------------------------------------------
832
+ // CLOSED row input validation (T-G detection)
833
+ // ---------------------------------------------------------------------------
834
+ function validateClosedRowInput(method, input, id) {
835
+ switch (method) {
836
+ case 'broker.hello':
837
+ if (hasHandshakeAuthorityInjection(input))
838
+ return respondHandshakeAuthorityViolation(id);
839
+ return validateCandidateHello(input) ? null : respondInvalidParamsValue(id);
840
+ case 'broker.ready':
841
+ if (hasHandshakeAuthorityInjection(input))
842
+ return respondHandshakeAuthorityViolation(id);
843
+ return validateBrokerReadyParams(input) ? null : respondInvalidParamsValue(id);
844
+ case 'events.publish':
845
+ return validateEventsPublishInput(input).valid
846
+ ? null
847
+ : respondInvalidParamsValue(id);
848
+ case 'host.lifecycle.ping': {
849
+ // Closed input keys: {nonce} only
850
+ for (const key of Object.keys(input)) {
851
+ if (!PING_INPUT_KEYS.has(key))
852
+ return respondInvalidParamsValue(id);
853
+ }
854
+ // nonce must be string, 1..512 code points
855
+ if (typeof input.nonce !== 'string')
856
+ return respondInvalidParamsValue(id);
857
+ const cpLen = [...input.nonce].length;
858
+ if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) {
859
+ return respondInvalidParamsValue(id);
860
+ }
861
+ return null;
862
+ }
863
+ case 'host.lifecycle.drain': {
864
+ // Closed input keys: {deadlineUnixMs} only
865
+ for (const key of Object.keys(input)) {
866
+ if (!DRAIN_INPUT_KEYS.has(key))
867
+ return respondInvalidParamsValue(id);
868
+ }
869
+ // input.deadlineUnixMs must be positive WireUInt53
870
+ const drainDeadline = input.deadlineUnixMs;
871
+ if (typeof drainDeadline !== 'number' || !isWireUInt53(drainDeadline))
872
+ return respondInvalidParamsValue(id);
873
+ if (drainDeadline === 0)
874
+ return respondInvalidParamsValue(id);
875
+ return null;
876
+ }
877
+ case 'messaging.subscribe': {
878
+ // Closed input keys: {handle} only
879
+ for (const key of Object.keys(input)) {
880
+ if (!SUBSCRIBE_INPUT_KEYS.has(key))
881
+ return respondInvalidParamsValue(id);
882
+ }
883
+ // handle must be string, bounds from contract
884
+ if (typeof input.handle !== 'string')
885
+ return respondInvalidParamsValue(id);
886
+ const cpLen = [...input.handle].length;
887
+ if (cpLen < SUBSCRIBE_HANDLE_MIN_LENGTH || cpLen > SUBSCRIBE_HANDLE_MAX_LENGTH) {
888
+ return respondInvalidParamsValue(id);
889
+ }
890
+ return null;
891
+ }
892
+ case 'messaging.ack': {
893
+ // Closed input keys: {subscriptionId, ackToken} only
894
+ for (const key of Object.keys(input)) {
895
+ if (!ACK_INPUT_KEYS.has(key))
896
+ return respondInvalidParamsValue(id);
897
+ }
898
+ // subscriptionId + ackToken: string, bounds from contract
899
+ if (typeof input.subscriptionId !== 'string')
900
+ return respondInvalidParamsValue(id);
901
+ if (typeof input.ackToken !== 'string')
902
+ return respondInvalidParamsValue(id);
903
+ const subLen = [...input.subscriptionId].length;
904
+ const tokenLen = [...input.ackToken].length;
905
+ if (subLen < ACK_SUBSCRIPTION_ID_MIN_LENGTH || subLen > ACK_SUBSCRIPTION_ID_MAX_LENGTH) {
906
+ return respondInvalidParamsValue(id);
907
+ }
908
+ if (tokenLen < ACK_TOKEN_MIN_LENGTH || tokenLen > ACK_TOKEN_MAX_LENGTH) {
909
+ return respondInvalidParamsValue(id);
910
+ }
911
+ return null;
912
+ }
913
+ // host.grants.changed (row 10) is notification-only — direction gate
914
+ // in classifyRequest rejects it before reaching this function.
915
+ default:
916
+ // RESERVED rows: skip input validation (shapes are `never`)
917
+ return null;
918
+ }
919
+ }
920
+ // ---------------------------------------------------------------------------
921
+ // Main classifier entry point
922
+ // ---------------------------------------------------------------------------
923
+ /**
924
+ * Classify a decoded NDJSON frame into a disposition class.
925
+ *
926
+ * Covers T-C through T-M of the disposition table. T-A and T-B
927
+ * are handled by the NDJSON frame decoder layer.
928
+ *
929
+ * @param frame Decoded frame with raw bytes and parsed value.
930
+ * @param inFlight Map of in-flight request IDs to their entry metadata.
931
+ * @returns The disposition result with class, outcome, and optional error response.
932
+ */
933
+ export function classifyFrame(frame, inFlight) {
934
+ const { raw, value } = frame;
935
+ // ── T-C: canonicality check ──────────────────────────────────────────
936
+ // The T-C predicate covers: whitespace, duplicate keys, non-scalar
937
+ // strings, non-canonical numbers, and BOM-prefixed frames.
938
+ // Byte-equality catches whitespace, duplicate keys, and most non-
939
+ // canonical numbers. Three supplementary measures close the gaps:
940
+ // 1. ignoreBOM:true — keeps BOM visible so it fails byte-equality.
941
+ // 2. containsNonScalarString — lone surrogates roundtrip thru stringify.
942
+ // 3. containsExponentNumber — V8-canonical exponent form (≥10^21)
943
+ // also roundtrips, but exponent tokens violate the WireUInt53
944
+ // raw decimal-digit-only profile.
945
+ //
946
+ // Guard: JSON.stringify and the deep-traversal helpers use recursive
947
+ // descent. A canonical frame nested thousands of levels deep passes
948
+ // V8's iterative JSON.parse but overflows the call stack on stringify.
949
+ // The try-catch ensures classifyFrame never throws — stack overflow
950
+ // is mapped to T-C (close, no response).
951
+ try {
952
+ const rawStr = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
953
+ const canonical = JSON.stringify(value);
954
+ if (rawStr !== canonical)
955
+ return close('T-C');
956
+ if (containsNonScalarString(value))
957
+ return close('T-C');
958
+ if (containsExponentNumber(value))
959
+ return close('T-C');
960
+ // P1-1: WireUInt53 raw-token grammar — after byte-equality, the raw
961
+ // token at each WireUInt53 position is String(parsedValue). Tokens
962
+ // like "-1" or "1.5" violate 0|[1-9][0-9]{0,15} → T-C.
963
+ if (hasNonCanonicalUInt53Token(value, inFlight))
964
+ return close('T-C');
965
+ }
966
+ catch {
967
+ // Stack overflow from deep nesting, or other canonicality edge case.
968
+ return close('T-C');
969
+ }
970
+ // ── Response candidate detection ─────────────────────────────────────
971
+ const hasMethod = 'method' in value;
972
+ const hasResult = 'result' in value;
973
+ const hasError = 'error' in value;
974
+ if (!hasMethod && (hasResult || hasError)) {
975
+ return classifyResponseCandidate(value, inFlight);
976
+ }
977
+ // ── Structural validity ──────────────────────────────────────────────
978
+ if (value.jsonrpc !== '2.0' || typeof value.method !== 'string') {
979
+ if ('id' in value) {
980
+ const id = validateRequestId(value.id);
981
+ if (id !== null) {
982
+ return respondInvalidRequestId(id);
983
+ }
984
+ }
985
+ return respondInvalidRequestNull();
986
+ }
987
+ const method = value.method;
988
+ // ── Notification vs Request fork ─────────────────────────────────────
989
+ if (!('id' in value)) {
990
+ return classifyNotification(value);
991
+ }
992
+ // ── T-E: profile-invalid id ──────────────────────────────────────────
993
+ const id = validateRequestId(value.id);
994
+ if (id === null)
995
+ return close('T-E');
996
+ // ── Request path ─────────────────────────────────────────────────────
997
+ return classifyRequest(value, id, method, inFlight);
998
+ }
999
+ //# sourceMappingURL=wire-dispatch.js.map