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

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