diff --git a/docs/plans/2026-07-27-p1-remaining-slices-plan.md b/docs/plans/2026-07-27-p1-remaining-slices-plan.md new file mode 100644 index 0000000..318145b --- /dev/null +++ b/docs/plans/2026-07-27-p1-remaining-slices-plan.md @@ -0,0 +1,104 @@ +--- +feature_ids: [P-1] +topics: [sdk, wire-dispatch, standalone-io, slice-plan] +doc_kind: plan +created: 2026-07-27 +--- + +# P-1 Remaining Slices Plan (M0 PR-4 Completion) + +**Baseline**: `main@ae9dc86` (PR #12 merged) + `feat/p1-sdk-dispatch` +**Truth source**: `docs/proposals/v0-implementation-roadmap.md` PR-4 + M0 gate +**Contract surface**: beta.4 frozen wire shapes (12 rows, all `ready: false`) + +## Slice DAG + +``` +S1 dispatch → S2 shell → S3 handshake → S4 loopback plugin → S5 adversarial matrix (SDK half) + ↓ (blocked seam) +K-1/K-2 merged (maintainer) ───────────→ host half co-run + M0 gate acceptance +``` + +| Slice | Content | Package boundary | RED input | +|---|---|---|---| +| **S1** wire dispatch | Classify decoded frames per frozen disposition table T-C..T-L; method gate; direction gate; RESERVED→T-G; CLOSED-row input validation; cross-frame oracle | `plugin-sdk/src/wire-dispatch.ts` + test | Contract disposition-fixtures (test-only relative import) | +| **S2** shell completion | Manifest load+validate → fail-closed startup; `startStdioRuntime` ← S1 dispatch; `ping/drain` plugin-side response logic | `plugin-sdk/src/standalone-host.ts` (new) or runtime extension | Illegal/unauthorized manifest fixtures | +| **S3** handshake client | CandidateHello construction (digest/nonce); SessionBinding validation; zero production frames before `broker.ready` | `plugin-sdk/src/handshake-client.ts` + PassThrough fake host test | Bad digest / illegal binding / out-of-order ready | +| **S4** loopback fixture plugin | Independent private workspace package; legal manifest + stdio startup + schema-neutral echo | `packages/loopback-fixture-plugin/` (`private: true`) | End-to-end: spawn real child process | +| **S5** adversarial matrix SDK half | Framing/envelope/method-gate/handshake/lifecycle/oversize violations; fixture-driven for host CI reuse | `plugin-sdk` or conformance directory | Contract fixtures + S4 | + +## Non-claims (every PR body) + +1. 12 rows remain `ready: false` — no flip, no flip request +2. No host Broker/Adapter implementation (K-2 = maintainer) +3. No M0 gate claim (precondition: K-1/K-2 merged) +4. No new wire shapes beyond beta.4 +5. Loopback echo ≠ `messaging.send` production semantics +6. No changes to clowder-ai core worktree + +## Rulings (Fable, 2026-07-27) + +### F1: beta.4 artifact immutability + +S1 must not modify the published contract barrel (`wire/index.ts`). +Disposition fixtures are consumed via **test-only relative import** +(`../../plugin-contract/src/wire/disposition-fixtures.js`) — test files +are excluded from the SDK dist artifact, no P14 violation. + +**Delta carry-forward items** (next contract revision, not standalone): +- (a) Promote disposition fixtures to public conformance export +- (b) Add reserved-method fixture vectors to the disposition fixture set + +### F2: RESERVED method ready gate belongs to S1 + +Contract evidence chain: +1. Disposition table is frozen; ACCEPT_CLASSES = {T-J, T-L} only — no + accept class exists for Requests +2. RESERVED rows have input type `never` — no legal params value exists +3. Therefore: any RESERVED-method Request → T-G (value violation, + respond error, id echoed) + +S2 handler shell may keep a defensive assertion but is not the primary gate. + +### F3: Contract-mirror drift prevention (R3 seam ruling) + +When the contract expresses a closed constraint only at the type level +(additionalProperties: false, enum unions) with no runtime export, +the SDK may create a **mirror constant** under these rules: + +1. **Centralized**: all mirrors live in `contract-mirror.ts` (self-documenting + name: "this is a deletable mirror") +2. **Drift-tested**: each mirror has a test in `contract-mirror.test.ts` + anchored to the contract source (schema JSON enum where available, + structural assertions against contract types elsewhere). Drift = CI red. +3. **Delta-listed**: all mirrors are scheduled for deletion in the beta.5 + delta scope when the contract exports runtime equivalents. + +Pattern precedent: #10 MAX_FRAME_BYTES alias + drift test → F1 fixtures +test-only import → F3 systematic contract-mirror. + +**beta.5 delta scope** (accumulated): +- (a) from F1: Promote disposition fixtures to public conformance export +- (b) from F1: Add reserved-method fixture vectors +- (c) from F3: codegen "schema enum → runtime const array" for MessagingErrorCode +- (d) from F3: Delete `contract-mirror.ts` — replace with public contract imports + +### Consumption path (F1/F3 method — applies to S1 and future slices) + +SDK production code imports from `@clowder-ai/plugin-contract` public +barrel and `./contract-mirror.js` (for type-level-only constraints). +Test files may use relative imports to contract source when the needed +symbol is not part of the published surface. Typecheck uses +`tsconfig.build.json` (production code) + `tsconfig.test.json` (test +files, separate due to rootDir constraint from cross-package relative +imports). Both are run by `pnpm typecheck`. + +## Branch / Gate / Handoff + +- **Branch**: `feat/p1-sdk-dispatch` from main; single clean commit per slice +- **Gate (per slice)**: TDD → `pnpm --filter @clowder-ai/plugin-sdk test` + + monorepo typecheck/lint/build/conformance all green → quality-gate → + cross-individual review (sol/kimi) → PR + `register_pr_tracking` → + codex → maintainer +- **Shape gap / contract contradiction**: stop → `@Fable` for delta + co-sign, no workaround diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index a7e3896..0965f4b 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -20,9 +20,9 @@ }, "scripts": { "build": "pnpm clean && tsc -p tsconfig.build.json", - "typecheck": "tsc --noEmit", - "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts", - "lint": "tsc --noEmit", + "typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json", + "test": "pnpm --filter @clowder-ai/plugin-contract build && pnpm build && node --import tsx --test src/stdio-runtime.test.ts src/wire-dispatch.test.ts src/contract-mirror.test.ts", + "lint": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json", "prepack": "pnpm build", "clean": "rm -rf dist" }, diff --git a/packages/plugin-sdk/src/contract-mirror.test.ts b/packages/plugin-sdk/src/contract-mirror.test.ts new file mode 100644 index 0000000..f591fed --- /dev/null +++ b/packages/plugin-sdk/src/contract-mirror.test.ts @@ -0,0 +1,368 @@ +/** + * Drift-prevention tests for contract-mirror.ts. + * + * Every constant in contract-mirror.ts mirrors a contract type-level + * constraint that lacks a runtime export. These tests anchor each + * mirror to its contract source and fail if they drift apart. + * + * TWO-LAYER DRIFT DETECTION: + * + * 1. **Compile-time**: ExactKeys type assertions + * fail at `tsc -p tsconfig.test.json` time when the contract interface + * adds or removes a field. No runtime cost. + * + * 2. **Runtime**: each test verifies the mirror Set has the expected + * cardinality and members. Catches mirror literal drift. + * + * Together they triangulate: any single-party change (contract OR mirror) + * is caught. Only coordinated updates to both pass green — which is the + * intended outcome (mirror updated to match the contract). + * + * Automated drift tests use test-only relative imports to read contract + * source data (schema JSON, TypeScript types). These imports are excluded + * from the SDK dist artifact (test files not in tsconfig.build). + * + * Fable ruling (S1 R3 contract seam): systematic drift prevention for + * all contract mirrors. Pattern precedent: #10 MAX_FRAME_BYTES. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + MESSAGING_ERROR_CODES, + 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, + ERROR_BODY_STANDARD_KEYS, + ERROR_BODY_APPLICATION_KEYS, + REASON_DATA_KEYS, + CODE_DATA_KEYS, +} from './contract-mirror.js'; + +// --------------------------------------------------------------------------- +// Contract type imports — compile-time drift anchors +// --------------------------------------------------------------------------- + +// Public barrel: types available in published @clowder-ai/plugin-contract. +// Every contract type that a shared mirror constant serves is imported +// here — not just one representative per group (Sol R5 matrix closure). +import type { + CallMeta, + WireSuccessResponse, + // Concrete error envelopes — all 11 variants for RESPONSE_ERROR_KEYS + ParseErrorEnvelope, + HandshakeRejectedEnvelope, + DeliveryRejectedEnvelope, + DomainErrorEnvelope, + DeadlineExpiredEnvelope, + SnapshotUnavailableEnvelope, + InvalidRequestNullIdEnvelope, + InvalidRequestValidIdEnvelope, + MethodNotFoundEnvelope, + InvalidParamsEnvelope, + InternalErrorEnvelope, + // Per-method CLOSED-row input/result shapes + PingInput, + PingResult, + DrainInput, + SubscribeInput, + SubscribeResult, + MessagingAckRequest, + GrantsChangedInput, + // Standard error body types — all 5 for ERROR_BODY_STANDARD_KEYS + ParseError, + InvalidRequestError, + MethodNotFoundError, + InvalidParamsError, + InternalError, + // Application error body types — all 5 for ERROR_BODY_APPLICATION_KEYS + HandshakeRejectedError, + DeliveryRejectedError, + DomainError, + DeadlineExpiredError, + SnapshotUnavailableError, +} from '@clowder-ai/plugin-contract'; + +// Test-only relative import: generic envelopes NOT in public barrel (Q1 ruling). +// These types are the sole source for REQUEST_ALLOWED_KEYS, NOTIFICATION_ALLOWED_KEYS, +// and PARAMS_ALLOWED_KEYS. Safe: test files excluded from SDK dist artifact. +import type { + WireRequest, + WireNotification, +} from '../../plugin-contract/src/wire/envelope.js'; + +// --------------------------------------------------------------------------- +// Compile-time drift detection: ExactKeys type utility +// --------------------------------------------------------------------------- + +/** + * Evaluates to `true` iff `keyof T` is exactly the string literal union K. + * Bidirectional: catches both additions and removals. + * + * If the contract interface changes, the const assignment `const _: ExactKeys<...> = true` + * fails at tsc time with "Type 'false' is not assignable to type 'true'". + * + * Tuple wrapping [A] extends [B] prevents union distribution. + */ +type ExactKeys = + [keyof T & string] extends [K] ? [K] extends [keyof T & string] ? true : false : false; + +// ── RESPONSE_SUCCESS_KEYS ── (1 type: WireSuccessResponse) +const _d01: ExactKeys, 'jsonrpc' | 'id' | 'result'> = true; + +// ── RESPONSE_ERROR_KEYS ── (11 concrete error envelopes, all must match) +const _d02: ExactKeys = true; +const _d03: ExactKeys = true; +const _d04: ExactKeys = true; +const _d05: ExactKeys = true; +const _d06: ExactKeys = true; +const _d07: ExactKeys = true; +const _d08: ExactKeys = true; +const _d09: ExactKeys = true; +const _d10: ExactKeys = true; +const _d11: ExactKeys = true; +const _d12: ExactKeys = true; + +// ── REQUEST_ALLOWED_KEYS / NOTIFICATION_ALLOWED_KEYS ── +const _d13: ExactKeys = true; +const _d14: ExactKeys = true; + +// ── PARAMS_ALLOWED_KEYS ── (both Request and Notification params) +const _d15: ExactKeys = true; +const _d16: ExactKeys = true; + +// ── META_ALLOWED_KEYS ── +const _d17: ExactKeys = true; + +// ── Per-method CLOSED-row input shapes (1:1, no shared mirrors) ── +const _d18: ExactKeys = true; +const _d19: ExactKeys = true; +const _d20: ExactKeys = true; +const _d21: ExactKeys = true; +const _d22: ExactKeys = true; + +// ── Per-method CLOSED-row result shapes (1:1, no shared mirrors) ── +const _d23: ExactKeys = true; +const _d24: ExactKeys = true; + +// ── ERROR_BODY_STANDARD_KEYS ── (all 5 standard error arms) +const _d25: ExactKeys = true; +const _d26: ExactKeys = true; +const _d27: ExactKeys = true; +const _d28: ExactKeys = true; +const _d29: ExactKeys = true; + +// ── ERROR_BODY_APPLICATION_KEYS ── (all 5 application error arms) +const _d30: ExactKeys = true; +const _d31: ExactKeys = true; +const _d32: ExactKeys = true; +const _d33: ExactKeys = true; +const _d34: ExactKeys = true; + +// ── REASON_DATA_KEYS ── (all 3 reason-bearing error data arms) +const _d35: ExactKeys = true; +const _d36: ExactKeys = true; +const _d37: ExactKeys = true; + +// ── CODE_DATA_KEYS ── (1 type: DomainError.data) +const _d38: ExactKeys = true; + +// Suppress "unused" — these are compile-time-only sentinels. +void _d01; void _d02; void _d03; void _d04; void _d05; void _d06; +void _d07; void _d08; void _d09; void _d10; void _d11; void _d12; +void _d13; void _d14; void _d15; void _d16; void _d17; void _d18; +void _d19; void _d20; void _d21; void _d22; void _d23; void _d24; +void _d25; void _d26; void _d27; void _d28; void _d29; void _d30; +void _d31; void _d32; void _d33; void _d34; void _d35; void _d36; +void _d37; void _d38; + +// --------------------------------------------------------------------------- +// Schema JSON source path (test-only) +// --------------------------------------------------------------------------- + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MESSAGING_SCHEMA_PATH = resolve( + __dirname, + '../../plugin-contract/src/schemas/messaging.schema.json', +); + +// --------------------------------------------------------------------------- +// Automated drift test: MessagingErrorCode enum +// --------------------------------------------------------------------------- + +test('MESSAGING_ERROR_CODES matches messaging.schema.json enum exactly', () => { + const schemaRaw = readFileSync(MESSAGING_SCHEMA_PATH, 'utf8'); + const schema = JSON.parse(schemaRaw) as { + $defs: { + MessagingErrorCode: { type: string; enum: string[] }; + }; + }; + + const schemaEnum = schema.$defs.MessagingErrorCode.enum; + + assert.ok( + Array.isArray(schemaEnum), + 'schema must define MessagingErrorCode.enum as array', + ); + + // Exact member match (same values, same order) + assert.deepEqual( + [...MESSAGING_ERROR_CODES], + schemaEnum, + 'MESSAGING_ERROR_CODES must match schema enum exactly (values + order). ' + + 'If this fails, update contract-mirror.ts to match the schema.', + ); +}); + +test('MESSAGING_ERROR_CODE_SET has same cardinality as array', () => { + assert.equal( + MESSAGING_ERROR_CODE_SET.size, + MESSAGING_ERROR_CODES.length, + 'Set and array must have same size (no duplicates in array)', + ); +}); + +// --------------------------------------------------------------------------- +// Runtime drift tests: envelope key sets +// +// These verify the mirror Set has the expected cardinality and members. +// Compile-time ExactKeys assertions (above) anchor each key set to the +// contract interface — if the contract adds/removes a field, tsc fails +// before these runtime tests even run. These runtime tests catch mirror +// literal drift (someone changes the Set without updating ExactKeys). +// --------------------------------------------------------------------------- + +test('RESPONSE_SUCCESS_KEYS matches WireSuccessResponse interface', () => { + // WireSuccessResponse: { jsonrpc, id, result } + assert.equal(RESPONSE_SUCCESS_KEYS.size, 3); + assert.ok(RESPONSE_SUCCESS_KEYS.has('jsonrpc')); + assert.ok(RESPONSE_SUCCESS_KEYS.has('id')); + assert.ok(RESPONSE_SUCCESS_KEYS.has('result')); +}); + +test('RESPONSE_ERROR_KEYS matches WireErrorResponse interface', () => { + // WireApplicationErrorResponse / WireStandardErrorResponse: { jsonrpc, id, error } + assert.equal(RESPONSE_ERROR_KEYS.size, 3); + assert.ok(RESPONSE_ERROR_KEYS.has('jsonrpc')); + assert.ok(RESPONSE_ERROR_KEYS.has('id')); + assert.ok(RESPONSE_ERROR_KEYS.has('error')); +}); + +test('NOTIFICATION_ALLOWED_KEYS matches WireNotification interface', () => { + // WireNotification: { jsonrpc, method, params } + assert.equal(NOTIFICATION_ALLOWED_KEYS.size, 3); + assert.ok(NOTIFICATION_ALLOWED_KEYS.has('jsonrpc')); + assert.ok(NOTIFICATION_ALLOWED_KEYS.has('method')); + assert.ok(NOTIFICATION_ALLOWED_KEYS.has('params')); +}); + +test('REQUEST_ALLOWED_KEYS matches WireRequest interface', () => { + // WireRequest: { jsonrpc, id, method, params } + assert.equal(REQUEST_ALLOWED_KEYS.size, 4); + assert.ok(REQUEST_ALLOWED_KEYS.has('jsonrpc')); + assert.ok(REQUEST_ALLOWED_KEYS.has('id')); + assert.ok(REQUEST_ALLOWED_KEYS.has('method')); + assert.ok(REQUEST_ALLOWED_KEYS.has('params')); +}); + +test('PARAMS_ALLOWED_KEYS matches WireRequest.params / WireNotification.params', () => { + // params: { meta, input } + assert.equal(PARAMS_ALLOWED_KEYS.size, 2); + assert.ok(PARAMS_ALLOWED_KEYS.has('meta')); + assert.ok(PARAMS_ALLOWED_KEYS.has('input')); +}); + +test('META_ALLOWED_KEYS matches CallMeta interface', () => { + // CallMeta: { deadlineUnixMs } + assert.equal(META_ALLOWED_KEYS.size, 1); + assert.ok(META_ALLOWED_KEYS.has('deadlineUnixMs')); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: per-method input key sets +// --------------------------------------------------------------------------- + +test('PING_INPUT_KEYS matches PingInput interface', () => { + assert.equal(PING_INPUT_KEYS.size, 1); + assert.ok(PING_INPUT_KEYS.has('nonce')); +}); + +test('DRAIN_INPUT_KEYS matches DrainInput interface', () => { + assert.equal(DRAIN_INPUT_KEYS.size, 1); + assert.ok(DRAIN_INPUT_KEYS.has('deadlineUnixMs')); +}); + +test('SUBSCRIBE_INPUT_KEYS matches SubscribeInput interface', () => { + assert.equal(SUBSCRIBE_INPUT_KEYS.size, 1); + assert.ok(SUBSCRIBE_INPUT_KEYS.has('handle')); +}); + +test('ACK_INPUT_KEYS matches MessagingAckRequest interface', () => { + assert.equal(ACK_INPUT_KEYS.size, 2); + assert.ok(ACK_INPUT_KEYS.has('subscriptionId')); + assert.ok(ACK_INPUT_KEYS.has('ackToken')); +}); + +test('GRANTS_CHANGED_INPUT_KEYS matches GrantSnapshot interface', () => { + assert.equal(GRANTS_CHANGED_INPUT_KEYS.size, 2); + assert.ok(GRANTS_CHANGED_INPUT_KEYS.has('grantRevision')); + assert.ok(GRANTS_CHANGED_INPUT_KEYS.has('effectiveGrants')); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: per-method result key sets +// --------------------------------------------------------------------------- + +test('PING_RESULT_KEYS matches PingResult interface', () => { + assert.equal(PING_RESULT_KEYS.size, 1); + assert.ok(PING_RESULT_KEYS.has('nonce')); +}); + +test('SUBSCRIBE_RESULT_KEYS matches SubscribeResult interface', () => { + assert.equal(SUBSCRIBE_RESULT_KEYS.size, 1); + assert.ok(SUBSCRIBE_RESULT_KEYS.has('subscriptionId')); +}); + +// --------------------------------------------------------------------------- +// Structural drift tests: error body key sets +// --------------------------------------------------------------------------- + +test('ERROR_BODY_STANDARD_KEYS matches StandardWireError body', () => { + // Standard: { code, message } — no data + assert.equal(ERROR_BODY_STANDARD_KEYS.size, 2); + assert.ok(ERROR_BODY_STANDARD_KEYS.has('code')); + assert.ok(ERROR_BODY_STANDARD_KEYS.has('message')); +}); + +test('ERROR_BODY_APPLICATION_KEYS matches ApplicationWireError body', () => { + // Application: { code, message, data } + assert.equal(ERROR_BODY_APPLICATION_KEYS.size, 3); + assert.ok(ERROR_BODY_APPLICATION_KEYS.has('code')); + assert.ok(ERROR_BODY_APPLICATION_KEYS.has('message')); + assert.ok(ERROR_BODY_APPLICATION_KEYS.has('data')); +}); + +test('REASON_DATA_KEYS matches per-arm data: {reason}', () => { + assert.equal(REASON_DATA_KEYS.size, 1); + assert.ok(REASON_DATA_KEYS.has('reason')); +}); + +test('CODE_DATA_KEYS matches DomainError data: {code}', () => { + assert.equal(CODE_DATA_KEYS.size, 1); + assert.ok(CODE_DATA_KEYS.has('code')); +}); diff --git a/packages/plugin-sdk/src/contract-mirror.ts b/packages/plugin-sdk/src/contract-mirror.ts new file mode 100644 index 0000000..9fb6efd --- /dev/null +++ b/packages/plugin-sdk/src/contract-mirror.ts @@ -0,0 +1,184 @@ +/** + * Contract-mirror constants — runtime key sets and enum values that + * mirror @clowder-ai/plugin-contract TypeScript interfaces and types. + * + * These exist because the contract expresses certain closed constraints + * (additionalProperties: false, enum unions) only at the type level, + * with no runtime array/set export. + * + * ## DELETION TARGET + * + * Every constant in this module is scheduled for deletion when the + * contract exports a runtime equivalent (beta.5 delta scope, Fable + * ruling on S1 R3 contract seam). Each constant documents which + * contract source it mirrors. + * + * ## Drift prevention + * + * contract-mirror.test.ts verifies each constant against the contract + * source (schema JSON enum where available, structural assertions + * against contract types elsewhere). Drift = CI red. + * + * Pattern precedent: #10 MAX_FRAME_BYTES alias + drift test → Fable + * ruling 1 (fixtures test-only import) → this ruling (systematic). + */ + +// ═══════════════════════════════════════════════════════════════════════════ +// Enum mirrors — contract has type-only unions, no runtime arrays +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * MessagingErrorCode runtime values. + * + * Mirror of: messaging.schema.json → definitions.MessagingErrorCode.enum + * Contract type: MessagingErrorCode (contract.generated.ts:217) + * Drift test: automated, vs schema JSON enum (exact member + order match) + */ +export const MESSAGING_ERROR_CODES = [ + 'VALIDATION', + 'PERMISSION', + 'NOT_FOUND', + 'CONFLICT', + 'RETRYABLE_INFLIGHT', + 'STALE_CURSOR', +] as const; + +export const MESSAGING_ERROR_CODE_SET = new Set(MESSAGING_ERROR_CODES); + +// ═══════════════════════════════════════════════════════════════════════════ +// Envelope outer key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: WireSuccessResponse interface (envelope.ts:108-113) + * Keys: jsonrpc, id, result — additionalProperties: false + */ +export const RESPONSE_SUCCESS_KEYS = new Set(['jsonrpc', 'id', 'result']); + +/** + * Mirror of: WireApplicationErrorResponse / WireStandardErrorResponse + * (envelope.ts:129-166). Keys: jsonrpc, id, error — additionalProperties: false + */ +export const RESPONSE_ERROR_KEYS = new Set(['jsonrpc', 'id', 'error']); + +/** + * Mirror of: WireNotification interface (envelope.ts:89-96) + * Keys: jsonrpc, method, params — additionalProperties: false + */ +export const NOTIFICATION_ALLOWED_KEYS = new Set(['jsonrpc', 'method', 'params']); + +/** + * Mirror of: WireRequest interface (envelope.ts:66-75) + * Keys: jsonrpc, id, method, params — additionalProperties: false + */ +export const REQUEST_ALLOWED_KEYS = new Set(['jsonrpc', 'id', 'method', 'params']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Params / meta key sets (closed nested objects) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: WireRequest.params / WireNotification.params (envelope.ts:71-74, 93-95) + * Keys: meta, input — additionalProperties: false + */ +export const PARAMS_ALLOWED_KEYS = new Set(['meta', 'input']); + +/** + * Mirror of: CallMeta interface (envelope.ts:43-52) + * Keys: deadlineUnixMs — additionalProperties: false, v0 single field + */ +export const META_ALLOWED_KEYS = new Set(['deadlineUnixMs']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-method CLOSED-row input key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Mirror of: PingInput (row-shapes.ts:144-147). Keys: {nonce} */ +export const PING_INPUT_KEYS = new Set(['nonce']); + +/** Mirror of: DrainInput (row-shapes.ts:183-193). Keys: {deadlineUnixMs} */ +export const DRAIN_INPUT_KEYS = new Set(['deadlineUnixMs']); + +/** Mirror of: SubscribeInput (row-shapes.ts:27-30). Keys: {handle} */ +export const SUBSCRIBE_INPUT_KEYS = new Set(['handle']); + +/** Mirror of: MessagingAckRequest (row-shapes.ts:76-81). Keys: {subscriptionId, ackToken} */ +export const ACK_INPUT_KEYS = new Set(['subscriptionId', 'ackToken']); + +/** Mirror of: GrantsChangedInput = GrantSnapshot (row-shapes.ts:128, grants.ts). Keys: {grantRevision, effectiveGrants} */ +export const GRANTS_CHANGED_INPUT_KEYS = new Set(['grantRevision', 'effectiveGrants']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-method CLOSED-row result key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Mirror of: PingResult (row-shapes.ts:152-158). Keys: {nonce} */ +export const PING_RESULT_KEYS = new Set(['nonce']); + +/** Mirror of: SubscribeResult (row-shapes.ts:35-39). Keys: {subscriptionId} */ +export const SUBSCRIBE_RESULT_KEYS = new Set(['subscriptionId']); + +/** + * Mirror of: deliver acknowledgement result closed member set. + * Row 9 (host.messaging.deliver) has a frozen ack result shape + * {deliveryId: string, length 1..128 code points} — additionalProperties: false. + * + * Despite row 9 being RESERVED overall (DeliverResult = never in types), + * the ack shape is frozen per the #1165 protocol specification. The + * deliveryId echo is the fundamental delivery acknowledgement mechanism. + * + * Maintainer requirement: enforce closed member set + string bounds. + */ +export const DELIVER_RESULT_KEYS = new Set(['deliveryId']); + +/** Minimum deliveryId code-point length (frozen row 9 ack shape). */ +export const DELIVER_DELIVERY_ID_MIN_LENGTH = 1 as const; + +/** Maximum deliveryId code-point length (frozen row 9 ack shape). */ +export const DELIVER_DELIVERY_ID_MAX_LENGTH = 128 as const; + +// ═══════════════════════════════════════════════════════════════════════════ +// Error body key sets (closed per error variant) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: StandardWireError body keys (errors.ts:240-299) + * Standard errors: {code, message} — no data field. + */ +export const ERROR_BODY_STANDARD_KEYS = new Set(['code', 'message']); + +/** + * Mirror of: ApplicationWireError body keys (errors.ts:182-234) + * Application errors: {code, message, data} — data required. + */ +export const ERROR_BODY_APPLICATION_KEYS = new Set(['code', 'message', 'data']); + +// ═══════════════════════════════════════════════════════════════════════════ +// Per-arm application error data key sets (additionalProperties: false) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Mirror of: HandshakeRejectedError.data / DeliveryRejectedError.data / + * SnapshotUnavailableError.data — all {reason: } + */ +export const REASON_DATA_KEYS = new Set(['reason']); + +/** + * Mirror of: DomainError.data (errors.ts:205-206) + * Keys: {code: MessagingErrorCode} — additionalProperties: false + */ +export const CODE_DATA_KEYS = new Set(['code']); + +// ═══════════════════════════════════════════════════════════════════════════ +// NOTE: Constants NOT in this module (derived from contract imports) +// ═══════════════════════════════════════════════════════════════════════════ +// +// The following are built from contract-imported arrays, not mirrors: +// KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES) — from contract +// APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES) — from contract +// HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS) — from contract +// DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS) — from contract +// SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS) — from contract +// NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]) — from contract +// +// These stay in wire-dispatch.ts as performance wrappers, not mirrors. diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 259402d..3218672 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,9 +1,14 @@ /** * Public SDK entrypoint. * - * This package intentionally exposes schema-neutral runtime primitives only. + * This package exposes: + * 1. Schema-neutral NDJSON transport (stdio-runtime, S0/#12) + * 2. Wire dispatch classifier (wire-dispatch, S1) + * * Production RPC methods remain reserved in plugin-contract until their rows - * become executable. + * become executable. The dispatch classifier gates all methods: CLOSED rows + * validate input shapes, RESERVED rows fail-closed with T-G (input type is + * `never` in v0 — no legal params exist). */ export { NdjsonFrameError, @@ -20,3 +25,10 @@ export { type StdioRuntimeFatalErrorOptions, type StdioRuntimeOptions, } from './stdio-runtime.js'; + +export { + classifyFrame, + type DispatchResult, + type InFlightEntry, + type RequestSnapshot, +} from './wire-dispatch.js'; diff --git a/packages/plugin-sdk/src/wire-dispatch.test.ts b/packages/plugin-sdk/src/wire-dispatch.test.ts new file mode 100644 index 0000000..1b458a6 --- /dev/null +++ b/packages/plugin-sdk/src/wire-dispatch.test.ts @@ -0,0 +1,1327 @@ +/** + * Wire dispatch classifier tests — fixture-driven from the contract's + * frozen disposition table (§3.8-1), plus structural edge cases. + * + * S1 scope: classify decoded NDJSON frames into disposition classes + * T-C through T-L. T-A (transport) and T-B (JSON parse) are handled + * by the NDJSON decoder layer, not the dispatch classifier. + * + * All fixture vectors consumed from @clowder-ai/plugin-contract — no + * fixture data is redefined here (Fable's S1 boundary). + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +// Test-only relative import: disposition fixtures are not part of the +// published beta.4 public surface. SDK tests consume them directly from +// contract source (tsx resolves .ts at runtime; test files are excluded +// from the SDK dist artifact). See Fable ruling on F1/beta.4 immutability. +import { + DISPOSITION_FIXTURE_VECTORS, + type DispositionFixtureVector, +} from '../../plugin-contract/src/wire/disposition-fixtures.js'; + +import type { + DecodedNdjsonFrame, + JsonObject, +} from '@clowder-ai/plugin-contract/conformance'; + +import { + classifyFrame, + type DispatchResult, + type InFlightEntry, +} from './wire-dispatch.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a DecodedNdjsonFrame from fixture rawFrame, or null for pre-decode vectors. */ +function frameFromFixture(v: DispositionFixtureVector): DecodedNdjsonFrame | null { + if (v.rawFrameEncoding === 'hex') return null; // T-A: byte-level failure + try { + const raw = Buffer.from(v.rawFrame, 'utf8'); + const value = JSON.parse(v.rawFrame) as JsonObject; + return { raw, value }; + } catch { + return null; // T-B: JSON parse failure + } +} + +/** Build InFlightEntry map from fixture preState. */ +function inFlightFromFixture( + v: DispositionFixtureVector, +): ReadonlyMap { + const map = new Map(); + for (const rec of v.preState.inFlightRequests) { + map.set(rec.id, { + method: rec.method, + requestSnapshot: rec.requestSnapshot, + }); + } + return map; +} + +const NO_IN_FLIGHT: ReadonlyMap = new Map(); + +// --------------------------------------------------------------------------- +// Fixture-driven sweep: all T-C through T-L vectors +// --------------------------------------------------------------------------- + +const classifiableVectors = DISPOSITION_FIXTURE_VECTORS.filter( + v => v.expectedClass !== 'T-A' && v.expectedClass !== 'T-B', +); + +for (const v of classifiableVectors) { + test(`disposition ${v.id}: ${v.description}`, () => { + const frame = frameFromFixture(v); + assert.ok( + frame !== null, + `fixture ${v.id} must be parseable for post-decode classification`, + ); + + const result = classifyFrame(frame, inFlightFromFixture(v)); + + assert.equal( + result.disposition, + v.expectedClass, + `expected disposition ${v.expectedClass}, got ${result.disposition}`, + ); + assert.equal( + result.outcome, + v.expectedOutcome, + `expected outcome ${v.expectedOutcome}, got ${result.outcome}`, + ); + + if (v.expectedResponseFrame !== null) { + assert.equal(result.outcome, 'respond'); + assert.ok( + result.response !== undefined, + 'respond outcome must carry a response envelope', + ); + assert.equal( + JSON.stringify(result.response), + v.expectedResponseFrame, + 'response frame must match the contract-defined expected envelope', + ); + } else if (result.outcome !== 'accept') { + assert.equal( + result.response, + undefined, + 'non-respond outcomes must not carry a response', + ); + } + }); +} + +// --------------------------------------------------------------------------- +// Fixture coverage sanity +// --------------------------------------------------------------------------- + +test('fixture sweep covers all post-decode vectors from the contract', () => { + const preDecodeCount = DISPOSITION_FIXTURE_VECTORS.filter( + v => v.expectedClass === 'T-A' || v.expectedClass === 'T-B', + ).length; + assert.equal( + classifiableVectors.length, + DISPOSITION_FIXTURE_VECTORS.length - preDecodeCount, + ); + assert.ok( + classifiableVectors.length >= 30, + `expected ≥30 post-decode vectors, got ${classifiableVectors.length}`, + ); +}); + +// --------------------------------------------------------------------------- +// Structural edge cases — behaviors the fixtures reference but worth +// testing with explicit intent +// --------------------------------------------------------------------------- + +test('valid request that passes all checks returns accept with null disposition', () => { + const frame: DecodedNdjsonFrame = { + raw: Buffer.from( + '{"jsonrpc":"2.0","id":"req-1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"hello"}}}', + 'utf8', + ), + value: { + jsonrpc: '2.0', + id: 'req-1', + method: 'host.lifecycle.ping', + params: { + meta: { deadlineUnixMs: 1 }, + input: { nonce: 'hello' }, + }, + }, + }; + + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.outcome, 'accept'); + assert.equal(result.disposition, null); +}); + +test('reserved method with valid envelope is T-G (input type never → value violation)', () => { + const frame: DecodedNdjsonFrame = { + raw: Buffer.from( + '{"jsonrpc":"2.0","id":"r1","method":"messaging.send","params":{"meta":{"deadlineUnixMs":1},"input":{}}}', + 'utf8', + ), + value: { + jsonrpc: '2.0', + id: 'r1', + method: 'messaging.send', + params: { meta: { deadlineUnixMs: 1 }, input: {} }, + }, + }; + + const result = classifyFrame(frame, NO_IN_FLIGHT); + // RESERVED rows have input type `never` — no legal params value exists + // in v0. The disposition table has no accept class for Requests + // (ACCEPT_CLASSES = {T-J, T-L} only). Fable ruling: T-G respond error. + assert.equal(result.disposition, 'T-G'); + assert.equal(result.outcome, 'respond'); + assert.ok(result.response !== undefined); +}); + +// --------------------------------------------------------------------------- +// Direction gate: plugin SDK rejects inbound plugin-to-host methods +// (codex R1 P1 — CLOSED plugin-to-host row with valid params) +// --------------------------------------------------------------------------- + +test('CLOSED plugin-to-host method (messaging.subscribe) with valid input is T-F (direction gate)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"s1","method":"messaging.subscribe","params":{"meta":{"deadlineUnixMs":1},"input":{"handle":"chan"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + // Plugin SDK does not serve plugin-to-host methods — T-F MethodNotFound. + // Direction gate runs after all envelope/value checks; only CLOSED rows + // with valid params reach it (RESERVED rows hit T-G first). + assert.equal(result.disposition, 'T-F'); + assert.equal(result.outcome, 'respond'); + assert.ok(result.response !== undefined); +}); + +test('CLOSED plugin-to-host method (messaging.ack) with valid input is T-F (direction gate)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"a1","method":"messaging.ack","params":{"meta":{"deadlineUnixMs":1},"input":{"subscriptionId":"sub-1","ackToken":"tok-1"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-F'); + assert.equal(result.outcome, 'respond'); +}); + +// --------------------------------------------------------------------------- +// T-C canonicality: non-scalar strings (lone surrogates) +// (codex R1 P2 — JSON.stringify roundtrips surrogates, byte-equality passes) +// --------------------------------------------------------------------------- + +test('frame with lone high surrogate in string value is T-C (non-scalar string)', () => { + // \ud800 is a lone high surrogate — not a valid Unicode scalar value. + // JSON.parse produces U+D800, JSON.stringify escapes it back to \ud800, + // so byte-equality passes. The non-scalar string check catches it. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"\\ud800"}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'lone surrogate must be rejected as non-scalar string'); + assert.equal(result.outcome, 'close'); +}); + +test('frame with lone low surrogate in object key is T-C (non-scalar string)', () => { + const rawFrame = '{"jsonrpc":"2.0","\\udcba":"extra"}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'lone surrogate in key must be rejected'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// T-C canonicality: BOM-prefixed frame +// (codex R2 P2-1 — TextDecoder default strips BOM, byte-equality passes) +// --------------------------------------------------------------------------- + +test('frame with UTF-8 BOM prefix is T-C (BOM is non-canonical)', () => { + // UTF-8 BOM (EF BB BF) is stripped by TextDecoder default (ignoreBOM:false). + // With ignoreBOM:true the BOM stays in rawStr, causing byte mismatch → T-C. + const json = '{"jsonrpc":"2.0","id":"a","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"x"}}}'; + const bomBytes = new Uint8Array([0xEF, 0xBB, 0xBF, ...Buffer.from(json)]); + const frame: DecodedNdjsonFrame = { + raw: bomBytes, + value: JSON.parse(json) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'BOM-prefixed frame must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// T-C canonicality: exponent-form numbers +// (codex R2 P2-2 — V8 JSON.stringify(1e+21) → "1e+21", byte-equality passes) +// --------------------------------------------------------------------------- + +test('frame with V8-canonical exponent-form number is T-C (non-canonical number)', () => { + // 1e+21 ≥ 10^21, so V8's JSON.stringify uses exponent notation "1e+21". + // Byte-equality passes because both raw and canonical have the same form. + // The containsExponentNumber check catches it — exponent form violates + // the WireUInt53 raw decimal-digit-only profile. + const json = '{"jsonrpc":"2.0","id":"a","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1e+21},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(json) as JsonObject; + // Sanity: byte-equality would pass without the exponent check + assert.equal(json, JSON.stringify(parsed), 'exponent form roundtrips through JSON'); + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(json, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'exponent-form number must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('frame with non-V8-canonical exponent form is already T-C via byte-equality', () => { + // 1e3 → JSON.parse → 1000 → JSON.stringify → "1000" (not "1e3"). + // Byte-equality catches this without needing the exponent check. + const json = '{"jsonrpc":"2.0","id":"a","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1e3},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(json) as JsonObject; + assert.notEqual(json, JSON.stringify(parsed), 'non-V8-canonical exponent does not roundtrip'); + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(json, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'non-canonical exponent is T-C via byte-equality'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// T-C canonicality: deeply nested frame → stack overflow guard +// (codex R3 P2-2 — JSON.stringify is recursive, V8 JSON.parse is iterative) +// --------------------------------------------------------------------------- + +test('deeply nested canonical frame is T-C, not a thrown exception', () => { + // Build a deeply nested canonical JSON string that V8's iterative + // JSON.parse handles but recursive JSON.stringify overflows on. + const depth = 10_000; + const prefix = '{"a":'.repeat(depth); + const core = '{"x":1}'; + const suffix = '}'.repeat(depth); + const json = prefix + core + suffix; + const parsed = JSON.parse(json) as JsonObject; + // Sanity: JSON.stringify throws for this depth + assert.throws(() => JSON.stringify(parsed), RangeError, 'stringify must overflow'); + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(json, 'utf8'), + value: parsed, + }; + // classifyFrame must NOT throw — it must return T-C close + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'deep nesting must be T-C, not thrown'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// Fail-closed anti-examples — each proves a specific fail-open gap is sealed. +// These are the independent refutation vectors from the R1 review. +// --------------------------------------------------------------------------- + +test('response candidate missing jsonrpc is T-H, not T-L (closed envelope)', () => { + const rawFrame = '{"id":"r1","result":{"nonce":"x"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'missing jsonrpc must not settle as T-L'); + assert.equal(result.outcome, 'close'); +}); + +test('response candidate with extra outer member is T-H (closed envelope)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"x"},"extra":1}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra outer key must not settle as T-L'); + assert.equal(result.outcome, 'close'); +}); + +test('error response with empty error body {} is T-H (missing code/message)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'error without code/message must not settle as T-L'); + assert.equal(result.outcome, 'close'); +}); + +test('grants.changed notification with unknown capability is T-K (authorization boundary)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":["UNKNOWN_CAP"]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'unknown capability must not be accepted as T-J'); + assert.equal(result.outcome, 'close'); +}); + +test('grants.changed notification with duplicate capability is T-K (authorization boundary)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":["messaging.send","messaging.send"]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'duplicate capability must not be accepted as T-J'); + assert.equal(result.outcome, 'close'); +}); + +test('notification-only method (host.grants.changed) with id is T-F (direction gate)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":[]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-F', 'notification-only method as request must be rejected'); + assert.equal(result.outcome, 'respond'); +}); + +// --------------------------------------------------------------------------- +// R2 Finding 1: Response result shape validation (fail-closed) +// --------------------------------------------------------------------------- + +test('ping result:null without snapshot is T-H (null is not {nonce:string})', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'null result for ping must not accept'); + assert.equal(result.outcome, 'close'); +}); + +test('ping result with extra field is T-H (closed shape)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"x","extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra field in ping result must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('ack result:{} is T-H (ack result must be null)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.ack' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-null result for ack must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('subscribe result:null is T-H (must be {subscriptionId:string})', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'null result for subscribe must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('drain result:{} is T-H (drain result must be null)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-null result for drain must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('error with unknown code 123 is T-H (closed error code set)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":123,"message":"whatever"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'unknown error code must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('standard error with data field is T-H (standard errors forbid data)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'standard error with data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('application error without data field is T-H (application errors require data)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error without data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('error with wrong code→message mapping is T-H (canonical mapping)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"wrong message"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'wrong code→message mapping must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('valid standard error (Internal error) with in-flight is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid standard error with in-flight must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid application error (deadline_expired) on messaging.ack is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Use messaging.ack (CLOSED row 7) — allows deadline_expired. + // Ping (row 11) is standard-only per P1-2. + const inFlight = new Map([ + ['r1', { method: 'messaging.ack' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid application error on allowed method must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid subscribe result {subscriptionId:"sub-1"} is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"subscriptionId":"sub-1"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid subscribe result must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid ack result null is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.ack' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid ack null result must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid drain result null is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid drain null result must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// R2 Finding 2: Nested closed-shape key enforcement +// --------------------------------------------------------------------------- + +test('request params with extra key beyond meta+input is T-F', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"x"},"extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-F', 'extra params key must reject'); + assert.equal(result.outcome, 'respond'); +}); + +test('request meta with extra key beyond deadlineUnixMs is T-G', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1,"extra":true},"input":{"nonce":"x"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-G', 'extra meta key must reject'); + assert.equal(result.outcome, 'respond'); +}); + +test('ping input with extra key beyond nonce is T-G', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1},"input":{"nonce":"x","extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-G', 'extra input key must reject'); + assert.equal(result.outcome, 'respond'); +}); + +test('notification params with extra key is T-K', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":[]},"extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'extra notification params key must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('notification meta with extra key is T-K', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1,"extra":true},"input":{"grantRevision":0,"effectiveGrants":[]}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'extra notification meta key must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('notification input with extra key is T-K (grants.changed input closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":0,"effectiveGrants":[],"extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-K', 'extra notification input key must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('error body with extra field beyond code/message is T-H (standard error)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error","extra":1}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra error body key must reject'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// R3 Finding 1: Per-arm application error data schema validation +// --------------------------------------------------------------------------- + +test('handshake rejection with missing reason is T-H (data validation)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // broker.hello (RESERVED) — data validation rejects missing reason + // before the per-method check would also reject it. + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'handshake rejection missing reason must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('handshake rejection with unknown reason is T-H (data validation)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"UNKNOWN_REASON"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'unknown handshake rejection reason must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('handshake rejection with extra data key is T-H (data validation)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"MALFORMED_HELLO","extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra key in handshake rejection data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deadline_expired with non-empty data is T-H (must be Record)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{"extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Use messaging.ack (allows deadline_expired) to exercise DATA validation + const inFlight = new Map([ + ['r1', { method: 'messaging.ack' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-empty deadline_expired data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('domain_error with extra data key is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION","extra":1}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Use messaging.subscribe (allows domain_error) to exercise DATA validation + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra key in domain error data must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('domain_error with unknown MessagingErrorCode is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"UNKNOWN_CODE"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Use messaging.subscribe (allows domain_error) to exercise DATA validation + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'unknown MessagingErrorCode must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('domain_error with valid MessagingErrorCode on messaging.subscribe is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Use messaging.subscribe (CLOSED row 5) — allows domain_error. + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'valid MessagingErrorCode on allowed method must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('domain_error with non-string code is T-H', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":42}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Use messaging.subscribe (allows domain_error) to exercise DATA validation + const inFlight = new Map([ + ['r1', { method: 'messaging.subscribe' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'non-string domain error code must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('snapshot_unavailable with valid reason on messaging.snapshot is T-L', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32094,"message":"snapshot unavailable","data":{"reason":"VIEW_EXPIRED"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // Row 8 (messaging.snapshot) allows snapshot_unavailable per the frozen error table. + const inFlight = new Map([ + ['r1', { method: 'messaging.snapshot' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'snapshot_unavailable on messaging.snapshot must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('valid handshake_rejected on ping is T-H (per-method error restriction, maintainer RED)', () => { + // Maintainer P1-2 RED: ping (row 11) permits standard errors only. + // HANDSHAKE_REJECTED belongs to rows 1-2 (RESERVED) — not row 11. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"PACKAGE_MISMATCH"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'handshake_rejected on ping must reject (per-method)'); + assert.equal(result.outcome, 'close'); +}); + +test('ParseError (-32700) with correlated string id is T-H (must have null id)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32700,"message":"Parse error"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'x' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'ParseError with string id violates null-id mandate'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// R3 Finding 2: Oracle fail-closed on missing snapshot +// --------------------------------------------------------------------------- + +test('ping success with valid shape but no oracle snapshot is T-H (fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"hello"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // In-flight entry for ping WITHOUT requestSnapshot — caller bug + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'missing ping oracle snapshot must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// RESERVED row 9 (deliver) oracle fail-closed regression (Sol R4 F1) +// --------------------------------------------------------------------------- + +test('deliver response with missing snapshot is T-H (fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"abc"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // In-flight entry for deliver with NO requestSnapshot + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'missing deliver snapshot must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver response with empty snapshot (no deliveryId) is T-H (fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"d2","result":{"deliveryId":"abc"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + // In-flight entry for deliver with snapshot but no deliveryId field + const inFlight = new Map([ + ['d2', { method: 'host.messaging.deliver', requestSnapshot: {} }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'empty deliver snapshot must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +test('ping success with snapshot nonce present is T-L (oracle pass)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{"nonce":"hello"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping', requestSnapshot: { nonce: 'hello' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'ping with matching nonce oracle must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// Mutual-exclusivity proof pair (pre-existing) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// P1-1: WireUInt53 raw-token grammar at T-C +// (maintainer requirement — non-canonical numeric tokens at WireUInt53 +// positions must be T-C/close, not deferred to T-G/T-K) +// --------------------------------------------------------------------------- + +test('request with deadlineUnixMs:-1 is T-C (negative token violates WireUInt53 grammar)', () => { + // -1 passes byte-equality (JSON.stringify(-1) = "-1") but violates + // the WireUInt53 raw grammar 0|[1-9][0-9]{0,15} — no sign allowed. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":-1},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'negative deadlineUnixMs must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('request with deadlineUnixMs:1.5 is T-C (fractional token violates WireUInt53 grammar)', () => { + // 1.5 passes byte-equality but violates WireUInt53 — no decimal allowed. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.ping","params":{"meta":{"deadlineUnixMs":1.5},"input":{"nonce":"x"}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'fractional deadlineUnixMs must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('notification with grantRevision:-1 is T-C (negative token at WireUInt53 position)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":1},"input":{"grantRevision":-1,"effectiveGrants":[]}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'negative grantRevision must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('notification with deadlineUnixMs:0.5 is T-C (fractional meta token)', () => { + const rawFrame = '{"jsonrpc":"2.0","method":"host.grants.changed","params":{"meta":{"deadlineUnixMs":0.5},"input":{"grantRevision":0,"effectiveGrants":[]}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'fractional notification deadlineUnixMs must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +test('drain input with deadlineUnixMs:-100 is T-C (negative drain deadline)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","method":"host.lifecycle.drain","params":{"meta":{"deadlineUnixMs":1},"input":{"deadlineUnixMs":-100}}}'; + const parsed = JSON.parse(rawFrame) as JsonObject; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: parsed, + }; + const result = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(result.disposition, 'T-C', 'negative drain input deadline must be T-C'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// P1-2: Per-method error code restriction +// (maintainer requirement — application errors only allowed on their +// designated rows, standard-only rows reject application errors) +// --------------------------------------------------------------------------- + +test('drain response with deadline_expired is T-L (row 12 allows deadline)', () => { + // Maintainer probe: drain + canonical DEADLINE_EXPIRED must be T-L. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'deadline_expired on drain must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('deliver response with delivery_rejected is T-L (row 9 allows it)', () => { + // Maintainer probe: deliver + canonical DELIVERY_REJECTED must be T-L. + const rawFrame = '{"jsonrpc":"2.0","id":"d1","error":{"code":-32091,"message":"delivery rejected","data":{"reason":"PLUGIN_BUSY"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'abc' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'delivery_rejected on deliver must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('drain response with handshake_rejected is T-H (wrong-row error)', () => { + // Negative: HANDSHAKE_REJECTED only allowed on rows 1-2, not row 12. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"MALFORMED_HELLO"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'handshake_rejected on drain must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver response with domain_error is T-H (wrong-row error)', () => { + // Negative: DOMAIN_ERROR not allowed on row 9 (only DELIVERY_REJECTED). + const rawFrame = '{"jsonrpc":"2.0","id":"d1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'abc' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'domain_error on deliver must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('snapshot_unavailable on ping is T-H (wrong-row error, standard-only)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32094,"message":"snapshot unavailable","data":{"reason":"VIEW_EXPIRED"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'snapshot_unavailable on ping must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('ping response with deadline_expired is T-H (row 11 standard-only)', () => { + // Ping (row 11) permits standard errors only (maintainer-confirmed). + // deadline_expired is an application error → per-method rejection. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32093,"message":"deadline expired","data":{}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.ping' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error on standard-only row must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('drain response with domain_error is T-H (row 12 standard-only)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32092,"message":"domain error","data":{"code":"VALIDATION"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'host.lifecycle.drain' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'application error on drain must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('broker.hello response with valid handshake_rejected is T-L (row 1 allows it)', () => { + // Rows 1-2 allow HANDSHAKE_REJECTED per the frozen per-row error table. + // Error eligibility is keyed off the row, not leafClosure. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32090,"message":"handshake rejected","data":{"reason":"PACKAGE_MISMATCH"}}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'handshake_rejected on broker.hello must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('RESERVED row (broker.hello) standard error is T-L (standard errors always allowed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","error":{"code":-32603,"message":"Internal error"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'standard error on RESERVED row must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// P1-2: RESERVED row result fail-closed +// (maintainer requirement — no executable result schema → T-H) +// --------------------------------------------------------------------------- + +test('broker.hello response with result:null is T-H (RESERVED row fail-closed)', () => { + // Maintainer P1-2 RED: broker.hello in-flight entry accepts result:null. + // RESERVED rows have no executable result schema → T-H. + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":null}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'broker.hello' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'RESERVED row result must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +test('messaging.send response with result:{} is T-H (RESERVED row fail-closed)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"r1","result":{}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['r1', { method: 'messaging.send' }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'RESERVED row result must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// P1-2: Row 9 deliver closed member set enforcement +// (maintainer requirement — {deliveryId} only, no extras) +// --------------------------------------------------------------------------- + +test('deliver result with extra field is T-H (closed ack member set)', () => { + // Maintainer P1-2 RED: row 9 accepts {deliveryId:"d1",extra:true} + // despite the frozen closed acknowledgement member set {deliveryId}. + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"abc","extra":true}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'abc' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'extra field in deliver result must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver result with only deliveryId (matching) is T-L (closed ack shape)', () => { + // Positive counterexample: exact closed member set {deliveryId}, matches oracle. + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"correct"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: 'correct' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'exact closed ack shape with matching oracle must accept'); + assert.equal(result.outcome, 'accept'); +}); + +// --------------------------------------------------------------------------- +// P1-2 (R2): deliveryId string bounds enforcement (1..128 code points) +// --------------------------------------------------------------------------- + +test('deliver result with deliveryId length 1 is T-L (min bound)', () => { + const id = 'x'; + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'deliveryId at min bound must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('deliver result with deliveryId length 128 is T-L (max bound)', () => { + const id = 'a'.repeat(128); + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-L', 'deliveryId at max bound must accept'); + assert.equal(result.outcome, 'accept'); +}); + +test('deliver result with deliveryId length 129 is T-H (N+1 above max)', () => { + // Maintainer P1-2: frozen row-9 ack has deliveryId 1..128; 129 is invalid. + const id = 'a'.repeat(129); + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'deliveryId exceeding max bound must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver result with empty deliveryId is T-H (below min bound)', () => { + const rawFrame = '{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":""}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: '' } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'empty deliveryId must reject'); + assert.equal(result.outcome, 'close'); +}); + +test('deliver snapshot with deliveryId length 129 is T-H (snapshot fail-closed)', () => { + // Snapshot itself must also be a legal oracle value (1..128 code points). + const id = 'b'.repeat(129); + const rawFrame = `{"jsonrpc":"2.0","id":"d1","result":{"deliveryId":"${id}"}}`; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + const inFlight = new Map([ + ['d1', { method: 'host.messaging.deliver', requestSnapshot: { deliveryId: id } }], + ]); + const result = classifyFrame(frame, inFlight); + assert.equal(result.disposition, 'T-H', 'out-of-bounds snapshot deliveryId must fail-closed'); + assert.equal(result.outcome, 'close'); +}); + +// --------------------------------------------------------------------------- +// Mutual-exclusivity proof pair (pre-existing) +// --------------------------------------------------------------------------- + +test('the same raw frame classified as T-H without in-flight and T-L with in-flight', () => { + const rawFrame = + '{"jsonrpc":"2.0","id":"corr-test","result":{"nonce":"x"}}'; + const frame: DecodedNdjsonFrame = { + raw: Buffer.from(rawFrame, 'utf8'), + value: JSON.parse(rawFrame) as JsonObject, + }; + + // Without in-flight → T-H (uncorrelated) + const withoutInFlight = classifyFrame(frame, NO_IN_FLIGHT); + assert.equal(withoutInFlight.disposition, 'T-H'); + assert.equal(withoutInFlight.outcome, 'close'); + + // With in-flight → T-L (correlated, nonce matches) + const inFlight = new Map([ + [ + 'corr-test', + { + method: 'host.lifecycle.ping', + requestSnapshot: { nonce: 'x' }, + }, + ], + ]); + const withInFlight = classifyFrame(frame, inFlight); + assert.equal(withInFlight.disposition, 'T-L'); + assert.equal(withInFlight.outcome, 'accept'); +}); diff --git a/packages/plugin-sdk/src/wire-dispatch.ts b/packages/plugin-sdk/src/wire-dispatch.ts new file mode 100644 index 0000000..98fbc00 --- /dev/null +++ b/packages/plugin-sdk/src/wire-dispatch.ts @@ -0,0 +1,1044 @@ +/** + * Wire dispatch classifier — pre-dispatch frame classification per the + * frozen disposition table (T-A through T-L, §3.8-1 of #1165). + * + * This module classifies decoded NDJSON frames into one of the 12 + * disposition classes. T-A (transport failure) and T-B (JSON parse error) + * are handled by the NDJSON frame decoder layer — this classifier covers + * T-C through T-L. + * + * A frame that passes all rejection checks returns disposition=null with + * outcome='accept', indicating a valid request that should be dispatched + * to a method handler. + * + * This module defines nothing new — every classification rule traces to + * the frozen disposition table in @clowder-ai/plugin-contract. + */ + +import type { DecodedNdjsonFrame, JsonObject } from '@clowder-ai/plugin-contract/conformance'; + +import { + type DispositionClass, + type WireMethodName, + validateRequestId, + validateEffectiveGrants, + 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, + // Per-arm application error codes (for data schema dispatch) + HANDSHAKE_REJECTED_CODE, + DELIVERY_REJECTED_CODE, + DOMAIN_ERROR_CODE, + DEADLINE_EXPIRED_CODE, + SNAPSHOT_UNAVAILABLE_CODE, + // Standard error code (ParseError null-id arm validation) + PARSE_ERROR_CODE, + // Reject-reason closed enums (application error data validation) + HANDSHAKE_REJECT_REASONS, + DELIVERY_REJECT_REASONS, + SNAPSHOT_UNAVAILABLE_REASONS, +} from '@clowder-ai/plugin-contract'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Cross-frame oracle snapshot — original request fields needed to + * verify byte-equality invariants on correlated responses. + * + * Structurally identical to contract's internal RequestSnapshot; + * defined locally because that type is not part of the published + * beta.4 public surface (Fable ruling on F1/immutability). + */ +export interface RequestSnapshot { + /** Row 11 ping: nonce that must be echoed byte-equal in the result. */ + readonly nonce?: string; + /** Row 9 deliver: deliveryId that must match byte-equal in the ack. */ + readonly deliveryId?: string; +} + +export interface InFlightEntry { + readonly method: WireMethodName; + readonly requestSnapshot?: RequestSnapshot; +} + +export interface DispatchResult { + /** T-class from the disposition table, or null for valid requests. */ + readonly disposition: DispositionClass | null; + /** Required transport action: close, respond (with error), or accept. */ + readonly outcome: 'close' | 'respond' | 'accept'; + /** For 'respond': the error envelope to write back as NDJSON. */ + readonly response?: JsonObject; +} + +// --------------------------------------------------------------------------- +// Error response builders +// --------------------------------------------------------------------------- + +function respondInvalidRequestNull(): DispatchResult { + return { + disposition: 'T-D', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id: null, + error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE }, + }, + }; +} + +function respondInvalidRequestId(id: string): DispatchResult { + return { + disposition: 'T-F', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE }, + }, + }; +} + +function respondMethodNotFound(id: string): DispatchResult { + return { + disposition: 'T-F', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: METHOD_NOT_FOUND_CODE, message: METHOD_NOT_FOUND_MESSAGE }, + }, + }; +} + +function respondInvalidParams(id: string): DispatchResult { + return { + disposition: 'T-F', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE }, + }, + }; +} + +function respondInvalidParamsValue(id: string): DispatchResult { + return { + disposition: 'T-G', + outcome: 'respond', + response: { + jsonrpc: '2.0', + id, + error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE }, + }, + }; +} + +function close(disposition: DispositionClass): DispatchResult { + return { disposition, outcome: 'close' }; +} + +function accept(disposition: DispositionClass): DispatchResult { + return { disposition, outcome: 'accept' }; +} + +// --------------------------------------------------------------------------- +// Non-scalar string detection (T-C canonicality, lone surrogate check) +// --------------------------------------------------------------------------- + +/** + * Returns true if any string value or object key in the parsed JSON tree + * contains a lone surrogate (U+D800–U+DFFF). These are non-scalar strings + * per the Unicode specification and fail the T-C canonicality predicate. + * + * Lone surrogates roundtrip through JSON.stringify (ES2019+ escapes them + * as \uXXXX), so byte-equality alone cannot catch them. + */ +function containsNonScalarString(value: unknown): boolean { + if (typeof value === 'string') { + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xD800 && c <= 0xDBFF) { + // High surrogate — must be followed by a low surrogate (U+DC00–U+DFFF) + const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0; + if (next < 0xDC00 || next > 0xDFFF) return true; + i++; // Skip the valid low surrogate pair partner + } else if (c >= 0xDC00 && c <= 0xDFFF) { + // Lone low surrogate (not preceded by a high surrogate) + return true; + } + } + return false; + } + if (value === null || typeof value !== 'object') return false; + if (Array.isArray(value)) { + for (const item of value) { + if (containsNonScalarString(item)) return true; + } + return false; + } + for (const key of Object.keys(value as Record)) { + if (containsNonScalarString(key)) return true; + if (containsNonScalarString((value as Record)[key])) return true; + } + return false; +} + +/** + * Detect numbers whose JSON.stringify form uses exponent notation. + * + * V8 serialises numbers ≥ 10^21 (and very small fractions) in exponent + * form (e.g. `1e+21`). When the raw frame also uses the same exponent + * form, byte-equality passes — but exponent-form numeric tokens are + * non-canonical per the WireUInt53 profile (which requires raw decimal + * digits `0|[1-9][0-9]*`, no exponent). Deep traversal mirrors + * containsNonScalarString. + */ +function containsExponentNumber(value: unknown): boolean { + if (typeof value === 'number') { + const s = String(value); + return s.includes('e') || s.includes('E'); + } + if (value === null || typeof value !== 'object') return false; + if (Array.isArray(value)) { + for (const item of value) { + if (containsExponentNumber(item)) return true; + } + return false; + } + for (const v of Object.values(value as Record)) { + if (containsExponentNumber(v)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// WireUInt53 raw-token validation (P1-1 maintainer requirement) +// --------------------------------------------------------------------------- + +/** + * Check whether any WireUInt53 position in the parsed frame value has a + * numeric token whose V8-canonical form violates the WireUInt53 raw grammar + * (0|[1-9][0-9]{0,15}, no sign, no decimal, no exponent). + * + * After byte-equality passes (rawStr === JSON.stringify(value)), the raw + * token at any numeric position IS String(parsedValue). So we walk the + * parsed structure to WireUInt53 positions and validate String(n) against + * isCanonicalUInt53Token. This catches negative integers (-1), fractions + * (1.5), and oversized values (>2^53-1) at the T-C layer, before any + * method-specific validation runs. + * + * WireUInt53 positions in the frozen schema: + * - params.meta.deadlineUnixMs (every request/notification) + * - params.input.deadlineUnixMs (host.lifecycle.drain input) + * - params.input.grantRevision (host.grants.changed input) + */ +function hasNonCanonicalUInt53Token(value: JsonObject): boolean { + const params = value.params; + if (params === null || typeof params !== 'object' || Array.isArray(params)) return false; + const paramsObj = params as Record; + + // ── params.meta.deadlineUnixMs ── + const meta = paramsObj.meta; + if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) { + const metaObj = meta as Record; + if (typeof metaObj.deadlineUnixMs === 'number') { + if (!isCanonicalUInt53Token(String(metaObj.deadlineUnixMs))) return true; + } + } + + // ── params.input.deadlineUnixMs (drain) + params.input.grantRevision (grants.changed) ── + const input = paramsObj.input; + if (input !== null && typeof input === 'object' && !Array.isArray(input)) { + const inputObj = input as Record; + if (typeof inputObj.deadlineUnixMs === 'number') { + if (!isCanonicalUInt53Token(String(inputObj.deadlineUnixMs))) return true; + } + if (typeof inputObj.grantRevision === 'number') { + if (!isCanonicalUInt53Token(String(inputObj.grantRevision))) return true; + } + } + + return false; +} + +// --------------------------------------------------------------------------- +// Contract-mirror imports (key sets from contract-mirror.ts) +// +// These mirror contract type-level constraints (additionalProperties: false) +// that lack runtime exports. Each is drift-tested in contract-mirror.test.ts. +// See contract-mirror.ts for deletion schedule and anchoring. +// --------------------------------------------------------------------------- + +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'; + +// --------------------------------------------------------------------------- +// Derived constants (built from contract runtime imports, NOT mirrors) +// --------------------------------------------------------------------------- + +// Error code validation sets (built from contract arrays). +const KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES); +const APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES); + +// Reason enum sets (built from contract arrays). +const HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS); +const DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS); +const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS); + +// Standard error codes that mandate null id (not string RequestId). +// ParseError (-32700) ALWAYS has id: null per the contract envelope. +// If we reach the error validation path, id is already a valid string +// (verified upstream), so ParseError with string id is invalid. +const NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]); + +// --------------------------------------------------------------------------- +// Per-method application error allowlists (frozen per-row registry) +// --------------------------------------------------------------------------- +// +// Every registry row's application-error set resolves through the closed +// application table in #1165. Eligibility is keyed off the row, NOT +// leafClosure — RESERVED rows have frozen error sets too. +// +// Rows 1-2 (broker.hello/ready): HANDSHAKE_REJECTED +// Rows 3-7 (messaging send/append/sub/read/ack): DOMAIN_ERROR, DEADLINE_EXPIRED +// Row 8 (messaging.snapshot): DOMAIN_ERROR, DEADLINE_EXPIRED, SNAPSHOT_UNAVAILABLE +// Row 9 (host.messaging.deliver): DELIVERY_REJECTED +// Row 10 (host.grants.changed): notification-only (no response) +// Row 11 (host.lifecycle.ping): standard only (no application errors) +// Row 12 (host.lifecycle.drain): DEADLINE_EXPIRED +// +// Standard errors are always allowed on every row. Application error +// codes NOT in the per-row allowlist → T-H. + +const EMPTY_ERROR_SET: ReadonlySet = new Set(); +const HANDSHAKE_ERROR_SET: ReadonlySet = new Set([HANDSHAKE_REJECTED_CODE]); +const MESSAGING_ERROR_SET: ReadonlySet = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]); +const SNAPSHOT_ERROR_SET: ReadonlySet = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE, SNAPSHOT_UNAVAILABLE_CODE]); +const DELIVERY_ERROR_SET: ReadonlySet = new Set([DELIVERY_REJECTED_CODE]); +const DEADLINE_ONLY_SET: ReadonlySet = new Set([DEADLINE_EXPIRED_CODE]); + +const METHOD_APPLICATION_ERROR_ALLOW: Readonly>> = { + 'broker.hello': HANDSHAKE_ERROR_SET, + 'broker.ready': HANDSHAKE_ERROR_SET, + 'messaging.send': MESSAGING_ERROR_SET, + 'messaging.appendElements': MESSAGING_ERROR_SET, + 'messaging.subscribe': MESSAGING_ERROR_SET, + 'messaging.read': MESSAGING_ERROR_SET, + 'messaging.ack': MESSAGING_ERROR_SET, + 'messaging.snapshot': SNAPSHOT_ERROR_SET, + 'host.messaging.deliver': DELIVERY_ERROR_SET, + 'host.grants.changed': EMPTY_ERROR_SET, // notification-only + 'host.lifecycle.ping': EMPTY_ERROR_SET, // standard only + 'host.lifecycle.drain': DEADLINE_ONLY_SET, +}; + +// DELIVER_RESULT_KEYS imported from contract-mirror.ts (row 9 ack shape). + +// --------------------------------------------------------------------------- +// Response candidate sub-classifier (T-H / T-L) +// --------------------------------------------------------------------------- + +function classifyResponseCandidate( + value: JsonObject, + inFlight: ReadonlyMap, +): DispatchResult { + const hasResult = 'result' in value; + const hasError = 'error' in value; + + // ── Closed envelope structure ────────────────────────────────────── + if (value.jsonrpc !== '2.0') return close('T-H'); + + // Mutual exclusivity: exactly one of result/error + if (hasResult && hasError) return close('T-H'); + + // Closed outer keys: no additional members + const allowedKeys = hasResult ? RESPONSE_SUCCESS_KEYS : RESPONSE_ERROR_KEYS; + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) return close('T-H'); + } + + // ── id validation ────────────────────────────────────────────────── + if (!('id' in value)) return close('T-H'); + const id = value.id; + if (typeof id !== 'string') return close('T-H'); + if (validateRequestId(id) === null) return close('T-H'); + + // ── In-flight correlation ────────────────────────────────────────── + const inFlightEntry = inFlight.get(id); + if (inFlightEntry === undefined) return close('T-H'); + + // ── Error body: closed union validation ──────────────────────────── + if (hasError) { + const error = value.error; + if (error === null || typeof error !== 'object' || Array.isArray(error)) { + return close('T-H'); + } + const errObj = error as Record; + + // code: number, message: string (structural) + if (typeof errObj.code !== 'number') return close('T-H'); + if (typeof errObj.message !== 'string') return close('T-H'); + + // Error code must be from the closed set + if (!KNOWN_ERROR_CODES.has(errObj.code)) return close('T-H'); + + // Code → message canonical mapping + const expectedMessage = ERROR_CODE_TO_MESSAGE[errObj.code as keyof typeof ERROR_CODE_TO_MESSAGE]; + if (errObj.message !== expectedMessage) return close('T-H'); + + // Standard vs application error body structure + if (APPLICATION_CODES.has(errObj.code)) { + // Application errors (-32090..-32094) MUST have `data` (object) + if (!('data' in errObj)) return close('T-H'); + if (errObj.data === null || typeof errObj.data !== 'object' || Array.isArray(errObj.data)) { + return close('T-H'); + } + // Closed keys: {code, message, data} only + for (const key of Object.keys(errObj)) { + if (!ERROR_BODY_APPLICATION_KEYS.has(key)) return close('T-H'); + } + // Per-arm data schema validation + const dataCheck = validateApplicationErrorData( + errObj.code, + errObj.data as Record, + ); + if (dataCheck !== null) return dataCheck; + } else { + // Standard errors: ParseError (-32700) mandates id: null. + // We already validated id is a string (not null) upstream. + // Therefore ParseError with string id is a protocol violation. + if (NULL_ID_ERROR_CODES.has(errObj.code)) return close('T-H'); + + // Standard errors (-32700..-32603) MUST NOT have `data` + if ('data' in errObj) return close('T-H'); + // Closed keys: {code, message} only + for (const key of Object.keys(errObj)) { + if (!ERROR_BODY_STANDARD_KEYS.has(key)) return close('T-H'); + } + } + + // ── Per-method error code restriction ──────────────────────────── + // Application errors are only valid on methods whose frozen per-row + // error set includes them. Standard errors are always allowed. + // The complete map covers all 12 rows — no fallback needed. + if (APPLICATION_CODES.has(errObj.code)) { + if (!METHOD_APPLICATION_ERROR_ALLOW[inFlightEntry.method].has(errObj.code)) { + return close('T-H'); + } + } + + return accept('T-L'); + } + + // ── Success result: method-specific shape validation ─────────────── + const resultCheck = validateResponseResult(value.result, inFlightEntry); + if (resultCheck !== null) return resultCheck; + + return accept('T-L'); +} + +// --------------------------------------------------------------------------- +// Application error data schema validation (per-arm) +// --------------------------------------------------------------------------- + +/** + * Validate the `data` field of an application error against the + * per-arm closed schema (additionalProperties: false). + * + * 5 arms: HandshakeRejected, DeliveryRejected, DomainError, + * DeadlineExpired, SnapshotUnavailable. + * + * Contract seam: DomainError.data.code (MessagingErrorCode) has no + * runtime enum in the contract public surface — only the TypeScript + * type union exists. We validate structure (key + string type) but + * skip enum validation to avoid a second truth source (P15). + * See Sol R3 F1 → Fable escalation. + */ +function validateApplicationErrorData( + code: number, + data: Record, +): DispatchResult | null { + switch (code) { + case HANDSHAKE_REJECTED_CODE: { + // data: { reason: HandshakeRejectReason } — closed + for (const key of Object.keys(data)) { + if (!REASON_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.reason !== 'string') return close('T-H'); + if (!HANDSHAKE_REASONS.has(data.reason)) return close('T-H'); + return null; + } + + case DELIVERY_REJECTED_CODE: { + // data: { reason: DeliveryRejectReason } — closed + for (const key of Object.keys(data)) { + if (!REASON_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.reason !== 'string') return close('T-H'); + if (!DELIVERY_REASONS.has(data.reason)) return close('T-H'); + return null; + } + + case DOMAIN_ERROR_CODE: { + // data: { code: MessagingErrorCode } — closed keys + enum + // MESSAGING_ERROR_CODE_SET from contract-mirror.ts (drift-tested + // against messaging.schema.json enum, Fable ruling on R3 seam). + for (const key of Object.keys(data)) { + if (!CODE_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.code !== 'string') return close('T-H'); + if (!MESSAGING_ERROR_CODE_SET.has(data.code)) return close('T-H'); + return null; + } + + case DEADLINE_EXPIRED_CODE: { + // data: Record — must be empty object + if (Object.keys(data).length !== 0) return close('T-H'); + return null; + } + + case SNAPSHOT_UNAVAILABLE_CODE: { + // data: { reason: SnapshotUnavailableReason } — closed + for (const key of Object.keys(data)) { + if (!REASON_DATA_KEYS.has(key)) return close('T-H'); + } + if (typeof data.reason !== 'string') return close('T-H'); + if (!SNAPSHOT_REASONS.has(data.reason)) return close('T-H'); + return null; + } + + default: + // Unknown application code — should be unreachable since + // APPLICATION_CODES was already checked. Defense-in-depth. + return close('T-H'); + } +} + +// --------------------------------------------------------------------------- +// Response result shape validation (per-method, CLOSED rows only) +// --------------------------------------------------------------------------- + +/** + * Validate the `result` value of a success response against the + * correlated in-flight method's expected result shape. + * + * CLOSED rows: full per-method result shape validation (additionalProperties: false). + * RESERVED rows: no shape contract to validate — only cross-frame oracle applies. + * + * Returns null if valid; DispatchResult (T-H) if invalid. + */ +function validateResponseResult( + result: unknown, + entry: InFlightEntry, +): DispatchResult | null { + const method = entry.method; + const row = WIRE_METHOD_REGISTRY[method]; + + // RESERVED rows: fail closed — no executable result schema exists. + // Row 9 (deliver) has a frozen ack shape; all others → T-H. + if (row.leafClosure !== 'CLOSED') { + return validateReservedRowResult(result, entry); + } + + switch (method) { + case 'host.lifecycle.ping': { + // PingResult: {nonce: string} — additionalProperties: false + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return close('T-H'); + } + const obj = result as Record; + // Closed keys: {nonce} only + for (const key of Object.keys(obj)) { + if (!PING_RESULT_KEYS.has(key)) return close('T-H'); + } + if (typeof obj.nonce !== 'string') return close('T-H'); + // Nonce bounds + const cpLen = [...(obj.nonce as string)].length; + if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) { + return close('T-H'); + } + // Cross-frame oracle: nonce byte-equality (REQUIRED for ping). + // Ping's nonce echo is the fundamental liveness proof — accepting + // a response without verifying the oracle defeats the purpose. + // Missing snapshot is a caller bug; fail-closed, not fail-open. + if (entry.requestSnapshot?.nonce === undefined) return close('T-H'); + if (obj.nonce !== entry.requestSnapshot.nonce) return close('T-H'); + return null; + } + + case 'host.lifecycle.drain': { + // DrainResult: null + if (result !== null) return close('T-H'); + return null; + } + + case 'messaging.subscribe': { + // SubscribeResult: {subscriptionId: string} — additionalProperties: false + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return close('T-H'); + } + const obj = result as Record; + // Closed keys: {subscriptionId} only + for (const key of Object.keys(obj)) { + if (!SUBSCRIBE_RESULT_KEYS.has(key)) return close('T-H'); + } + if (typeof obj.subscriptionId !== 'string') return close('T-H'); + const cpLen = [...(obj.subscriptionId as string)].length; + if (cpLen < SUBSCRIBE_SUBSCRIPTION_ID_MIN_LENGTH || cpLen > SUBSCRIBE_SUBSCRIPTION_ID_MAX_LENGTH) { + return close('T-H'); + } + return null; + } + + case 'messaging.ack': { + // MessagingAckResult: null + if (result !== null) return close('T-H'); + return null; + } + + case 'host.grants.changed': { + // Notification-only — should never be in in-flight. + // Direction gate prevents this; defense-in-depth. + return close('T-H'); + } + + default: { + // Unreachable for CLOSED rows (all covered above). + // Defense-in-depth: unknown method in in-flight → T-H. + return close('T-H'); + } + } +} + +// --------------------------------------------------------------------------- +// Cross-frame oracle for RESERVED row responses +// --------------------------------------------------------------------------- + +/** + * Validate results for RESERVED rows. + * + * P1-2 maintainer requirement: fail closed when no executable result + * schema exists, rather than treating absence of an oracle as validity. + * + * Row 9 (host.messaging.deliver) is the ONE exception among RESERVED + * rows — its acknowledgement result shape {deliveryId} is frozen as a + * closed member set. Validated with deliveryId byte-equality oracle + * AND strict closed-key enforcement. + * + * ALL other RESERVED rows: no executable result schema exists → T-H + * for any result value (fail-closed). + */ +function validateReservedRowResult( + result: unknown, + entry: InFlightEntry, +): DispatchResult | null { + // ── Row 9 deliver: frozen ack shape {deliveryId: string, 1..128 cp} ── + if (entry.method === 'host.messaging.deliver') { + // Fail-closed if snapshot absent or snapshot deliveryId is not a + // legal oracle value (string within 1..128 code points). + const snapId = entry.requestSnapshot?.deliveryId; + if (snapId === undefined) return close('T-H'); + if (typeof snapId !== 'string') return close('T-H'); + const snapLen = [...snapId].length; + if (snapLen < DELIVER_DELIVERY_ID_MIN_LENGTH || snapLen > DELIVER_DELIVERY_ID_MAX_LENGTH) { + return close('T-H'); + } + + // Result must be a non-null object + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return close('T-H'); + } + const resultObj = result as Record; + + // Closed member set: {deliveryId} only — no extras + for (const key of Object.keys(resultObj)) { + if (!DELIVER_RESULT_KEYS.has(key)) return close('T-H'); + } + + // deliveryId must be a string within frozen bounds (1..128 code points) + if (typeof resultObj.deliveryId !== 'string') return close('T-H'); + const resultIdLen = [...(resultObj.deliveryId as string)].length; + if (resultIdLen < DELIVER_DELIVERY_ID_MIN_LENGTH || resultIdLen > DELIVER_DELIVERY_ID_MAX_LENGTH) { + return close('T-H'); + } + + // Byte-equality oracle: result.deliveryId must match snapshot + if (resultObj.deliveryId !== snapId) return close('T-H'); + return null; + } + + // ── All other RESERVED rows: no executable result schema → T-H ──── + // The row's result shape is RESERVED (type = `never`). No legal result + // value exists in v0 — accepting anything would be fail-open. + return close('T-H'); +} + +// --------------------------------------------------------------------------- +// Notification sub-classifier (T-J / T-K) +// --------------------------------------------------------------------------- + +function classifyNotification(value: JsonObject): DispatchResult { + const method = value.method as string; + + // Closed outer keys: {jsonrpc, method, params} only + for (const key of Object.keys(value)) { + if (!NOTIFICATION_ALLOWED_KEYS.has(key)) return close('T-K'); + } + + // Missing params → T-K + if (!('params' in value)) return close('T-K'); + + // Only row 10 (host.grants.changed) is a legal notification in v0 + const isLegalNotification = (NOTIFICATION_METHODS as readonly string[]).includes(method); + if (!isLegalNotification) return close('T-K'); + + // Validate params structure + const params = value.params; + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + return close('T-K'); + } + const paramsObj = params as Record; + + // Closed params keys: {meta, input} only + for (const key of Object.keys(paramsObj)) { + if (!PARAMS_ALLOWED_KEYS.has(key)) return close('T-K'); + } + + // meta.deadlineUnixMs + if (!('meta' in paramsObj)) return close('T-K'); + const meta = paramsObj.meta; + if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { + return close('T-K'); + } + const metaObj = meta as Record; + + // Closed meta keys: {deadlineUnixMs} only + for (const key of Object.keys(metaObj)) { + if (!META_ALLOWED_KEYS.has(key)) return close('T-K'); + } + + const notifDeadline = metaObj.deadlineUnixMs; + if (typeof notifDeadline !== 'number' || !isWireUInt53(notifDeadline)) return close('T-K'); + if (notifDeadline === 0) return close('T-K'); + + // input validation for row 10 (grants.changed) + if (!('input' in paramsObj)) return close('T-K'); + const input = paramsObj.input; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + return close('T-K'); + } + const inputObj = input as Record; + + // Closed input keys: {grantRevision, effectiveGrants} only + for (const key of Object.keys(inputObj)) { + if (!GRANTS_CHANGED_INPUT_KEYS.has(key)) return close('T-K'); + } + + // grantRevision must be WireUInt53 (≥0) + const grantRev = inputObj.grantRevision; + if (typeof grantRev !== 'number' || !isWireUInt53(grantRev)) return close('T-K'); + + // effectiveGrants must pass authorization boundary validation + if (!Array.isArray(inputObj.effectiveGrants)) return close('T-K'); + if (!validateEffectiveGrants(inputObj.effectiveGrants as string[])) return close('T-K'); + + return accept('T-J'); +} + +// --------------------------------------------------------------------------- +// Request sub-classifier (T-E / T-F / T-G / T-I / accept) +// --------------------------------------------------------------------------- + +function classifyRequest( + value: JsonObject, + id: string, + method: string, + inFlight: ReadonlyMap, +): DispatchResult { + // Closed outer keys: {jsonrpc, id, method, params} only + for (const key of Object.keys(value)) { + if (!REQUEST_ALLOWED_KEYS.has(key)) return respondInvalidRequestId(id); + } + + // Missing params → T-F InvalidRequest + if (!('params' in value)) return respondInvalidRequestId(id); + + // params must be an object + const params = value.params; + if (params === null || typeof params !== 'object') { + return respondInvalidParams(id); + } + if (Array.isArray(params)) return respondInvalidParams(id); + + // Method gate: unknown method → T-F MethodNotFound + if (!isWireMethod(method)) return respondMethodNotFound(id); + + // Direction gate: notification-only methods must not appear as requests + const wireMethod = method as WireMethodName; + const row = WIRE_METHOD_REGISTRY[wireMethod]; + if (row.isNotification) return respondInvalidRequestId(id); + + // In-flight collision → T-I + if (inFlight.has(id)) return close('T-I'); + + const paramsObj = params as Record; + + // Closed params keys: {meta, input} only + for (const key of Object.keys(paramsObj)) { + if (!PARAMS_ALLOWED_KEYS.has(key)) return respondInvalidParams(id); + } + + // Validate params.meta structure + if (!('meta' in paramsObj)) return respondInvalidParams(id); + const meta = paramsObj.meta; + if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) { + return respondInvalidParams(id); + } + const metaObj = meta as Record; + + // Closed meta keys: {deadlineUnixMs} only + for (const key of Object.keys(metaObj)) { + if (!META_ALLOWED_KEYS.has(key)) return respondInvalidParamsValue(id); + } + + // deadlineUnixMs must be positive WireUInt53 + const reqDeadline = metaObj.deadlineUnixMs; + if (typeof reqDeadline !== 'number' || !isWireUInt53(reqDeadline)) return respondInvalidParamsValue(id); + if (reqDeadline === 0) return respondInvalidParamsValue(id); + + // Validate params.input exists and is an object + if (!('input' in paramsObj)) return respondInvalidParams(id); + const input = paramsObj.input; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + return respondInvalidParams(id); + } + + // Row-specific value validation + if (row.leafClosure === 'CLOSED') { + const valueResult = validateClosedRowInput(wireMethod, input as Record, id); + if (valueResult !== null) return valueResult; + } else { + // RESERVED rows: input type is `never` → no legal params value exists + // in v0 → any invocation is a value violation (T-G). + // Fable ruling: disposition table has no accept class for Requests; + // ACCEPT_CLASSES = {T-J (notification), T-L (response)} only. + return respondInvalidParamsValue(id); + } + + // Direction gate: plugin SDK only accepts host-to-plugin methods as + // inbound requests. Plugin-to-host methods (rows 1–8) received by + // the plugin are protocol violations → T-F MethodNotFound. + // + // Positioned after all envelope/value checks so that: + // - In-flight collision (T-I) takes precedence (per contract fixtures) + // - RESERVED row rejection (T-G) takes precedence (input type `never`) + // - Only CLOSED plugin-to-host rows with valid params reach here + if (row.direction === 'plugin-to-host') return respondMethodNotFound(id); + + // All checks passed — valid host-to-plugin CLOSED-row request for dispatch + return { disposition: null, outcome: 'accept' }; +} + +// --------------------------------------------------------------------------- +// CLOSED row input validation (T-G detection) +// --------------------------------------------------------------------------- + +function validateClosedRowInput( + method: WireMethodName, + input: Record, + id: string, +): DispatchResult | null { + switch (method) { + case 'host.lifecycle.ping': { + // Closed input keys: {nonce} only + for (const key of Object.keys(input)) { + if (!PING_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } + // nonce must be string, 1..512 code points + if (typeof input.nonce !== 'string') return respondInvalidParamsValue(id); + const cpLen = [...input.nonce].length; + if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + return null; + } + case 'host.lifecycle.drain': { + // Closed input keys: {deadlineUnixMs} only + for (const key of Object.keys(input)) { + if (!DRAIN_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } + // input.deadlineUnixMs must be positive WireUInt53 + const drainDeadline = input.deadlineUnixMs; + if (typeof drainDeadline !== 'number' || !isWireUInt53(drainDeadline)) return respondInvalidParamsValue(id); + if (drainDeadline === 0) return respondInvalidParamsValue(id); + return null; + } + case 'messaging.subscribe': { + // Closed input keys: {handle} only + for (const key of Object.keys(input)) { + if (!SUBSCRIBE_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } + // handle must be string, bounds from contract + if (typeof input.handle !== 'string') return respondInvalidParamsValue(id); + const cpLen = [...input.handle].length; + if (cpLen < SUBSCRIBE_HANDLE_MIN_LENGTH || cpLen > SUBSCRIBE_HANDLE_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + return null; + } + case 'messaging.ack': { + // Closed input keys: {subscriptionId, ackToken} only + for (const key of Object.keys(input)) { + if (!ACK_INPUT_KEYS.has(key)) return respondInvalidParamsValue(id); + } + // subscriptionId + ackToken: string, bounds from contract + if (typeof input.subscriptionId !== 'string') return respondInvalidParamsValue(id); + if (typeof input.ackToken !== 'string') return respondInvalidParamsValue(id); + const subLen = [...input.subscriptionId].length; + const tokenLen = [...input.ackToken].length; + if (subLen < ACK_SUBSCRIPTION_ID_MIN_LENGTH || subLen > ACK_SUBSCRIPTION_ID_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + if (tokenLen < ACK_TOKEN_MIN_LENGTH || tokenLen > ACK_TOKEN_MAX_LENGTH) { + return respondInvalidParamsValue(id); + } + return null; + } + // host.grants.changed (row 10) is notification-only — direction gate + // in classifyRequest rejects it before reaching this function. + default: + // RESERVED rows: skip input validation (shapes are `never`) + return null; + } +} + +// --------------------------------------------------------------------------- +// Main classifier entry point +// --------------------------------------------------------------------------- + +/** + * Classify a decoded NDJSON frame into a disposition class. + * + * Covers T-C through T-L of the frozen disposition table. T-A and T-B + * are handled by the NDJSON frame decoder layer. + * + * @param frame Decoded frame with raw bytes and parsed value. + * @param inFlight Map of in-flight request IDs to their entry metadata. + * @returns The disposition result with class, outcome, and optional error response. + */ +export function classifyFrame( + frame: DecodedNdjsonFrame, + inFlight: ReadonlyMap, +): DispatchResult { + const { raw, value } = frame; + + // ── T-C: canonicality check ────────────────────────────────────────── + // The T-C predicate covers: whitespace, duplicate keys, non-scalar + // strings, non-canonical numbers, and BOM-prefixed frames. + // Byte-equality catches whitespace, duplicate keys, and most non- + // canonical numbers. Three supplementary measures close the gaps: + // 1. ignoreBOM:true — keeps BOM visible so it fails byte-equality. + // 2. containsNonScalarString — lone surrogates roundtrip thru stringify. + // 3. containsExponentNumber — V8-canonical exponent form (≥10^21) + // also roundtrips, but exponent tokens violate the WireUInt53 + // raw decimal-digit-only profile. + // + // Guard: JSON.stringify and the deep-traversal helpers use recursive + // descent. A canonical frame nested thousands of levels deep passes + // V8's iterative JSON.parse but overflows the call stack on stringify. + // The try-catch ensures classifyFrame never throws — stack overflow + // is mapped to T-C (close, no response). + try { + const rawStr = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw); + const canonical = JSON.stringify(value); + if (rawStr !== canonical) return close('T-C'); + if (containsNonScalarString(value)) return close('T-C'); + if (containsExponentNumber(value)) return close('T-C'); + // P1-1: WireUInt53 raw-token grammar — after byte-equality, the raw + // token at each WireUInt53 position is String(parsedValue). Tokens + // like "-1" or "1.5" violate 0|[1-9][0-9]{0,15} → T-C. + if (hasNonCanonicalUInt53Token(value)) return close('T-C'); + } catch { + // Stack overflow from deep nesting, or other canonicality edge case. + return close('T-C'); + } + + // ── Response candidate detection ───────────────────────────────────── + const hasMethod = 'method' in value; + const hasResult = 'result' in value; + const hasError = 'error' in value; + + if (!hasMethod && (hasResult || hasError)) { + return classifyResponseCandidate(value, inFlight); + } + + // ── Structural validity ────────────────────────────────────────────── + if (value.jsonrpc !== '2.0' || typeof value.method !== 'string') { + if ('id' in value) { + const id = validateRequestId(value.id); + if (id !== null) { + return respondInvalidRequestId(id); + } + } + return respondInvalidRequestNull(); + } + + const method = value.method as string; + + // ── Notification vs Request fork ───────────────────────────────────── + if (!('id' in value)) { + return classifyNotification(value); + } + + // ── T-E: profile-invalid id ────────────────────────────────────────── + const id = validateRequestId(value.id); + if (id === null) return close('T-E'); + + // ── Request path ───────────────────────────────────────────────────── + return classifyRequest(value, id, method, inFlight); +} diff --git a/packages/plugin-sdk/tsconfig.test.json b/packages/plugin-sdk/tsconfig.test.json new file mode 100644 index 0000000..60ed0dc --- /dev/null +++ b/packages/plugin-sdk/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "paths": { + "@clowder-ai/plugin-sdk": ["./src/index.ts"] + } + }, + "include": ["src/**/*.test.ts"], + "exclude": ["node_modules", "dist"] +}