diff --git a/sdk/src/tii/encode.test.ts b/sdk/src/tii/encode.test.ts new file mode 100644 index 0000000..a557ae7 --- /dev/null +++ b/sdk/src/tii/encode.test.ts @@ -0,0 +1,95 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { encode } from './encode.js'; +import { EncodeError } from './errors.js'; +import { ParamType } from './paramType.js'; +import type { JsonSchema } from './spec.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +interface AcceptVector { + name: string; + schema: JsonSchema; + value: unknown; + tagged: unknown; +} + +interface RejectVector { + name: string; + schema: JsonSchema; + value: unknown; + reason: string; +} + +interface WireVectors { + components: Record; + accept: AcceptVector[]; + reject: RejectVector[]; +} + +// The shared cross-language oracle for the `TaggedArg` wire form, copied from +// the umbrella's sdk-spec into this SDK's fixtures. +const vectors: WireVectors = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '../../tests/fixtures/wire-vectors.json'), 'utf8'), +); + +const paramType = (schema: JsonSchema): ParamType => + ParamType.fromJsonSchema(schema, vectors.components); + +describe('encode — accept vectors', () => { + it.each(vectors.accept.map((v) => [v.name, v] as const))( + 'encodes %s to its wire form', + (_name, vector) => { + expect(encode(paramType(vector.schema), vector.value)).toEqual(vector.tagged); + }, + ); +}); + +describe('encode — reject vectors', () => { + it.each(vectors.reject.map((v) => [v.name, v] as const))( + 'rejects %s', + (_name, vector) => { + expect(() => encode(paramType(vector.schema), vector.value)).toThrow(EncodeError); + }, + ); +}); + +describe('encode — record field order', () => { + it('follows `required` (declared order), not alphabetical `properties`', () => { + // Meta { tags: List, level: Int } — required = [tags, level], while + // `properties` alphabetizes to [level, tags]. The struct fields must be + // [list, int], not [int, list]. + const schema: JsonSchema = { + type: 'object', + properties: { + level: { type: 'integer' }, + tags: { type: 'array', items: { type: 'integer' } }, + }, + required: ['tags', 'level'], + }; + expect(encode(paramType(schema), { level: 7, tags: [1, 2, 3] })).toEqual({ + struct: { + constructor: 0, + fields: [{ list: [{ int: 1 }, { int: 2 }, { int: 3 }] }, { int: 7 }], + }, + }); + }); +}); + +describe('encode — leaf position', () => { + it('renders a top-level scalar bare', () => { + // A scalar at the top level is sent bare; the resolver coerces it. + expect(encode(paramType({ type: 'integer' }), 5)).toEqual(5); + expect( + encode(paramType({ $ref: 'https://tx3.land/specs/v1beta0/tii#/$defs/Bytes' }), 'cafe'), + ).toEqual('cafe'); + }); + + it('renders a nested scalar tagged', () => { + // The same scalar nested inside a list is tagged. + expect(encode(paramType({ type: 'array', items: { type: 'integer' } }), [5])).toEqual({ + list: [{ int: 5 }], + }); + }); +}); diff --git a/sdk/src/tii/encode.ts b/sdk/src/tii/encode.ts new file mode 100644 index 0000000..c3d8e2c --- /dev/null +++ b/sdk/src/tii/encode.ts @@ -0,0 +1,192 @@ +//! Type-directed argument encoding into the TRP `TaggedArg` wire form. +//! +//! A TRP resolve request carries an **untyped** TIR, so the resolver cannot +//! recover the structure of an aggregate argument (record, list, tuple, map) on +//! its own. The full type lives in the `.tii`, a client-side artifact — so the +//! SDK is authoritative: it walks the resolved `ParamType` alongside the user +//! value and emits the self-describing `TaggedArg` (single-key tagged, +//! recursive — see the `TaggedArg` schema in `core/trp/v1beta0/trp.json` and the +//! SDK spec's `api-surface/args.md`). The resolver then decodes it structurally, +//! without a schema. +//! +//! This is **one** recursive walk over `(type, value)`; scalars are just the leaf +//! cases. A scalar leaf at the top level renders **bare** (the resolver coerces +//! it via the flat TIR type); the same scalar nested inside an aggregate renders +//! **tagged**, because the resolver has no element/field type there. + +import { EncodeError } from './errors.js'; +import type { ParamType, VariantCase } from './paramType.js'; + +/** The JSON shape name of a value, for {@link EncodeError} messages. */ +function shapeOf(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function wrongShape(kind: string, expected: string, got: unknown): EncodeError { + return new EncodeError( + `expected ${expected} for a \`${kind}\` argument, got \`${shapeOf(got)}\``, + ); +} + +/** + * Marshals an argument `value` to its TRP wire form, directed by `param`. + * + * One recursive walk over `(type, value)`. A scalar leaf renders bare at the top + * level — the resolver coerces it via the param's flat type — and tagged when it + * sits inside an aggregate, where the resolver has no element type. Aggregates + * always render to their tagged structural form. + * + * Throws an {@link EncodeError} if `value`'s shape cannot match `param`. + */ +export function encode(param: ParamType, value: unknown): unknown { + return marshal(param, value, false); +} + +/** + * `nested` is true when `value` sits inside an aggregate, where scalar leaves + * must be tagged for the schema-less resolver. + */ +function marshal(param: ParamType, value: unknown, nested: boolean): unknown { + switch (param.kind) { + // Scalar leaves: bare at the top level, tagged when nested. Shape checks here + // are the "reject before sending" pass; the resolver still performs the + // authoritative coercion. + case 'integer': + // number or decimal/hex string. + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return leaf('int', value, nested); + } + throw wrongShape('integer', 'number or decimal/hex string', value); + case 'boolean': + // Accept the lenient forms the resolver coerces (bool, 0/1, "true"/"false"). + if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { + return leaf('bool', value, nested); + } + throw wrongShape('boolean', 'bool', value); + case 'bytes': + // Hex string or a BytesEnvelope object. + if (typeof value === 'string' || isObject(value)) { + return leaf('bytes', value, nested); + } + throw wrongShape('bytes', 'hex string or bytes envelope', value); + case 'address': + if (typeof value === 'string') return leaf('address', value, nested); + throw wrongShape('address', 'bech32 or hex string', value); + case 'utxoRef': + if (typeof value === 'string') return leaf('utxoRef', value, nested); + throw wrongShape('utxoRef', 'txid#index string', value); + + // A unit field has no payload; it lowers to a nullary struct. + case 'unit': + return { struct: { constructor: 0, fields: [] } }; + + case 'list': { + if (!Array.isArray(value)) throw wrongShape('list', 'array', value); + return { list: value.map((v) => marshal(param.inner, v, true)) }; + } + + case 'tuple': { + if (!Array.isArray(value)) throw wrongShape('tuple', 'array', value); + if (value.length !== param.elements.length) { + throw new EncodeError( + `tuple arity mismatch: expected ${param.elements.length} element(s), got ${value.length}`, + ); + } + return { tuple: param.elements.map((t, i) => marshal(t, value[i], true)) }; + } + + case 'map': { + if (!isObject(value)) throw wrongShape('map', 'object', value); + // The `.tii` erases the Tx3 key type (JSON object keys are strings), so keys + // are carried as `string` leaves. Sort by key for a deterministic, + // language-neutral pair order. + const keys = Object.keys(value).sort(); + const pairs = keys.map((k) => [{ string: k }, marshal(param.value, value[k], true)]); + return { map: pairs }; + } + + // A record is constructor 0; a variant resolves its case index. Both emit the + // same positional `struct` form. + case 'record': + return { + struct: { constructor: 0, fields: encodeRecordFields(param.fields, value) }, + }; + + case 'variant': + return encodeVariant(param.cases, value); + + // No wire-leaf form and no element types to drive encoding: pass the value + // through and let the resolver coerce it via the flat type. + case 'utxo': + case 'anyAsset': + case 'unknown': + return value; + } +} + +/** + * Renders a scalar leaf: bare at the top level (the resolver knows the param's + * type), tagged when nested inside an aggregate (it doesn't). + */ +function leaf(tag: string, value: unknown, nested: boolean): unknown { + return nested ? { [tag]: value } : value; +} + +/** + * Encodes a record's fields **positionally** in declared order, mapping the + * user's by-name object. Rejects missing or extra fields up front. + */ +function encodeRecordFields( + fields: ReadonlyArray, + value: unknown, +): unknown[] { + if (!isObject(value)) throw wrongShape('record', 'object', value); + + // Reject any field the record does not declare. + for (const key of Object.keys(value)) { + if (!fields.some(([name]) => name === key)) { + throw new EncodeError(`unknown record field \`${key}\``); + } + } + + return fields.map(([name, ty]) => { + if (!(name in value)) throw new EncodeError(`missing record field \`${name}\``); + return marshal(ty, value[name], true); + }); +} + +/** + * Encodes an externally-tagged variant value `{ "": }` into a + * `struct` whose `constructor` is the case index from the `.tii` `oneOf` order. + */ +function encodeVariant(cases: VariantCase[], value: unknown): unknown { + if (!isObject(value)) throw badVariant(); + const tags = Object.keys(value); + if (tags.length !== 1) throw badVariant(); + const tag = tags[0]; + const payload = value[tag]; + + const index = cases.findIndex((c) => c.tag === tag); + if (index < 0) throw new EncodeError(`unknown variant case \`${tag}\``); + + // A case payload is a record (possibly empty). Encode its fields positionally + // and stamp the case index as the constructor. + const caseFields = cases[index].fields; + const fields = + caseFields.kind === 'record' + ? encodeRecordFields(caseFields.fields, payload) + : // Defensive: a non-record payload encodes as the single field. + [marshal(caseFields, payload, true)]; + + return { struct: { constructor: index, fields } }; +} + +function badVariant(): EncodeError { + return new EncodeError('variant value must be a single-key object naming the case'); +} diff --git a/sdk/src/tii/errors.ts b/sdk/src/tii/errors.ts index bef9785..c90e849 100644 --- a/sdk/src/tii/errors.ts +++ b/sdk/src/tii/errors.ts @@ -23,3 +23,12 @@ export class UnknownProfileError extends TiiError { this.profile = profile; } } + +/** + * An argument value whose shape does not match its declared `ParamType`. + * + * Surfaced **before** the request is sent (the SDK is authoritative for complex + * types), so a malformed complex arg fails fast at the client rather than as an + * opaque resolver error. + */ +export class EncodeError extends TiiError {} diff --git a/sdk/src/tii/index.ts b/sdk/src/tii/index.ts index 7d6468c..4ca8401 100644 --- a/sdk/src/tii/index.ts +++ b/sdk/src/tii/index.ts @@ -1,5 +1,6 @@ export * from './errors.js'; export * from './spec.js'; export * from './paramType.js'; +export * from './encode.js'; export * from './invocation.js'; export * from './protocol.js'; diff --git a/sdk/src/tii/invocation.ts b/sdk/src/tii/invocation.ts index e497f36..f74249f 100644 --- a/sdk/src/tii/invocation.ts +++ b/sdk/src/tii/invocation.ts @@ -1,5 +1,6 @@ import type { ArgMap, TirEnvelope } from '../core/index.js'; import type { ResolveParams } from '../trp/spec.js'; +import { encode } from './encode.js'; import type { ParamMap, ParamType } from './paramType.js'; export class Invocation { @@ -44,9 +45,26 @@ export class Invocation { } intoResolveRequest(): ResolveParams { + // Every arg is marshalled by its `.tii` `ParamType`: top-level scalars come + // back bare (the resolver coerces them via the flat TIR type), aggregates + // tagged into the self-describing `TaggedArg` wire form. An unmapped arg has + // no type, so it passes through untouched. Arg keys are lowercased on set; + // params keep their original case, so match case-insensitively. + const args: ArgMap = {}; + for (const [key, value] of Object.entries(this._args)) { + let paramType: ParamType | undefined; + for (const [name, type] of this._params) { + if (name.toLowerCase() === key) { + paramType = type; + break; + } + } + args[key] = paramType ? encode(paramType, value) : value; + } + return { tir: this.tir, - args: { ...this._args }, + args, }; } } diff --git a/sdk/src/tii/paramType.test.ts b/sdk/src/tii/paramType.test.ts index d46eda6..0f4f38c 100644 --- a/sdk/src/tii/paramType.test.ts +++ b/sdk/src/tii/paramType.test.ts @@ -66,10 +66,30 @@ describe('ParamType.fromJsonSchema', () => { }; expect(ParamType.fromJsonSchema(record)).toEqual({ kind: 'record', - fields: { price: { kind: 'integer' }, live: { kind: 'boolean' } }, + fields: [ + ['price', { kind: 'integer' }], + ['live', { kind: 'boolean' }], + ], }); }); + it('orders record fields by `required` (declared order), not alphabetical `properties`', () => { + // Meta { tags: List, level: Int } — required = [tags, level], while + // `properties` alphabetizes to [level, tags]. The fields must be [tags, level]. + const meta: JsonSchema = { + type: 'object', + properties: { + level: { type: 'integer' }, + tags: { type: 'array', items: { type: 'integer' } }, + }, + required: ['tags', 'level'], + }; + const param = ParamType.fromJsonSchema(meta); + expect(param.kind).toBe('record'); + if (param.kind !== 'record') throw new Error('unreachable'); + expect(param.fields.map(([name]) => name)).toEqual(['tags', 'level']); + }); + it('maps a oneOf to an externally-tagged variant', () => { const variant: JsonSchema = { oneOf: [ @@ -99,7 +119,7 @@ describe('ParamType.fromJsonSchema', () => { expect(param.cases.map((c) => c.tag)).toEqual(['Buy', 'Sell']); expect(param.cases[1].fields).toEqual({ kind: 'record', - fields: { price: { kind: 'integer' } }, + fields: [['price', { kind: 'integer' }]], }); }); @@ -114,7 +134,7 @@ describe('ParamType.fromJsonSchema', () => { const ref: JsonSchema = { $ref: '#/components/schemas/AssetClass' }; expect(ParamType.fromJsonSchema(ref, components)).toEqual({ kind: 'record', - fields: { policy: { kind: 'bytes' } }, + fields: [['policy', { kind: 'bytes' }]], }); }); @@ -151,7 +171,7 @@ describe('paramsFromSchema', () => { const map = paramsFromSchema(params, components); expect(map.get('asset')).toEqual({ kind: 'record', - fields: { policy: { kind: 'bytes' } }, + fields: [['policy', { kind: 'bytes' }]], }); expect(map.get('quantity')?.kind).toBe('integer'); }); diff --git a/sdk/src/tii/paramType.ts b/sdk/src/tii/paramType.ts index 53e898c..d07f349 100644 --- a/sdk/src/tii/paramType.ts +++ b/sdk/src/tii/paramType.ts @@ -6,6 +6,9 @@ export interface VariantCase { fields: ParamType; } +/** A record field, `[name, type]`, kept in declared order. */ +export type RecordField = [name: string, type: ParamType]; + export type ParamType = | { kind: 'bytes' } | { kind: 'integer' } @@ -18,7 +21,11 @@ export type ParamType = | { kind: 'list'; inner: ParamType } | { kind: 'tuple'; elements: ParamType[] } | { kind: 'map'; value: ParamType } - | { kind: 'record'; fields: Record } + // Fields are positional in **declared order** — the schema's `required` array, + // which `tx3c` emits in source-declaration order. (`properties` is alphabetized + // by JSON and must not drive field order; the type-directed encoder relies on + // this order to build the positional `struct` wire form.) + | { kind: 'record'; fields: RecordField[] } | { kind: 'variant'; cases: VariantCase[] } | { kind: 'unknown'; schema: JsonSchema }; @@ -117,19 +124,44 @@ function objectType( } const properties = schema['properties']; if (isSchema(properties)) { - const fields: Record = {}; - for (const [key, value] of Object.entries( - properties as Record, - )) { - fields[key] = isSchema(value) - ? ParamType.fromJsonSchema(value, components) - : ParamType.unknown(schema); - } - return ParamType.record(fields); + return ParamType.record(recordFields(schema, properties, components)); } return ParamType.unknown(schema); } +/** Builds record fields in **declared order**: the schema's `required` array + * first (the order `tx3c` emits, = source declaration), then any remaining + * `properties` (which JSON alphabetizes). The encoder needs this order to + * produce positional `struct` fields. */ +function recordFields( + schema: JsonSchema, + properties: JsonSchema, + components?: Record, +): RecordField[] { + const props = properties as Record; + const toType = (value: unknown): ParamType => + isSchema(value) ? ParamType.fromJsonSchema(value, components) : ParamType.unknown(schema); + + const fields: RecordField[] = []; + const seen = new Set(); + + const required = schema['required']; + if (Array.isArray(required)) { + for (const name of required) { + if (typeof name === 'string' && name in props) { + fields.push([name, toType(props[name])]); + seen.add(name); + } + } + } + + for (const [key, value] of Object.entries(props)) { + if (!seen.has(key)) fields.push([key, toType(value)]); + } + + return fields; +} + export const ParamType = { bytes: (): ParamType => ({ kind: 'bytes' }), integer: (): ParamType => ({ kind: 'integer' }), @@ -142,13 +174,21 @@ export const ParamType = { list: (inner: ParamType): ParamType => ({ kind: 'list', inner }), tuple: (elements: ParamType[]): ParamType => ({ kind: 'tuple', elements }), map: (value: ParamType): ParamType => ({ kind: 'map', value }), - record: (fields: Record): ParamType => ({ + record: (fields: RecordField[]): ParamType => ({ kind: 'record', fields, }), variant: (cases: VariantCase[]): ParamType => ({ kind: 'variant', cases }), unknown: (schema: JsonSchema): ParamType => ({ kind: 'unknown', schema }), + /** Looks up a field type by name in a `record` (any other kind yields + * `undefined`). Field order is preserved separately; this is the by-name + * accessor for callers that don't care about position. */ + field(type: ParamType, name: string): ParamType | undefined { + if (type.kind !== 'record') return undefined; + return type.fields.find(([k]) => k === name)?.[1]; + }, + /** * Interprets a JSON schema node into a {@link ParamType}. Never throws: any * shape it does not recognize — a bare `string`, an unresolved object, an diff --git a/sdk/src/tii/protocol.test.ts b/sdk/src/tii/protocol.test.ts index bdc25f2..b36b686 100644 --- a/sdk/src/tii/protocol.test.ts +++ b/sdk/src/tii/protocol.test.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Protocol } from './protocol.js'; +import { ParamType } from './paramType.js'; import { UnknownTxError, UnknownProfileError, @@ -105,7 +106,7 @@ describe('Protocol', () => { // The component-$ref Record must have resolved its inner Bytes field — this // is the assertion that actually guards the components threading. const asset = params.get('asset'); - expect(asset?.kind === 'record' && asset.fields['policy']?.kind).toBe('bytes'); + expect(asset && ParamType.field(asset, 'policy')?.kind).toBe('bytes'); // The component-$ref Variant must have resolved its cases. const side = params.get('side'); @@ -158,5 +159,26 @@ describe('Protocol', () => { expect(req.args).toBeDefined(); expect(req.args.quantity).toBe(100); }); + + // End-to-end through the path `cshell`/`trix invoke` take (`setArgs` → + // `intoResolveRequest`): an aggregate arg serializes to its tagged wire form + // while top-level scalars stay bare. + test('intoResolveRequest tags aggregate args and leaves scalars bare', async () => { + const complex = await Protocol.fromFile(COMPLEX_FIXTURE); + const req = complex.invoke('complex').setArgs({ + quantity: 100, + flag: true, + recipient: 'addr1', + amounts: [1, 2, 3], + }).intoResolveRequest(); + + // The List aggregate is fully tagged. + expect(req.args.amounts).toEqual({ list: [{ int: 1 }, { int: 2 }, { int: 3 }] }); + + // Scalars stay bare; the resolver coerces them via the flat type. + expect(req.args.quantity).toBe(100); + expect(req.args.flag).toBe(true); + expect(req.args.recipient).toBe('addr1'); + }); }); }); diff --git a/sdk/tests/fixtures/wire-vectors.json b/sdk/tests/fixtures/wire-vectors.json new file mode 100644 index 0000000..e909ddb --- /dev/null +++ b/sdk/tests/fixtures/wire-vectors.json @@ -0,0 +1,164 @@ +{ + "description": "Shared oracle for the argument wire encoding (TRP `TaggedArg`, see core/trp/v1beta0/trp.json). Each `accept` vector pins a `.tii`-typed parameter — its JSON `schema` (the node a ParamType is built from), the flat TIR `type` the resolver sees, the native user `value`, and the `tagged` wire form. SDK encoders MUST turn (schema, value) into `tagged`; the resolver decoder MUST turn (type, tagged) back into the matching ArgValue. `reject` vectors are values an SDK encoder MUST refuse before sending. `components` holds the user-defined record/variant schemas referenced via `#/components/schemas/`; struct field order follows each schema's `required` array (source-declaration order, which tx3c emits — note `properties` is alphabetized and MUST NOT be used for order). Map pairs are emitted sorted by the JSON key string for determinism.", + "components": { + "Meta": { + "type": "object", + "properties": { + "level": { "type": "integer" }, + "tags": { "type": "array", "items": { "type": "integer" } } + }, + "required": ["tags", "level"] + }, + "AssetClass": { + "type": "object", + "properties": { + "policy": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }, + "name": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + }, + "required": ["policy", "name"] + }, + "Side": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["Buy"], + "properties": { "Buy": { "type": "object", "properties": {}, "required": [] } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["Sell"], + "properties": { + "Sell": { + "type": "object", + "properties": { "price": { "type": "integer" } }, + "required": ["price"] + } + } + } + ] + } + }, + "accept": [ + { + "name": "list_of_int", + "schema": { "type": "array", "items": { "type": "integer" } }, + "type": "list", + "value": [1, 2, 3], + "tagged": { "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] } + }, + { + "name": "list_of_bytes", + "schema": { + "type": "array", + "items": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + }, + "type": "list", + "value": ["deadbeef", "0xcafe"], + "tagged": { "list": [{ "bytes": "deadbeef" }, { "bytes": "0xcafe" }] } + }, + { + "name": "empty_list", + "schema": { "type": "array", "items": { "type": "integer" } }, + "type": "list", + "value": [], + "tagged": { "list": [] } + }, + { + "name": "tuple_int_bytes", + "schema": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + ], + "items": false + }, + "type": "tuple", + "value": [42, "cafe"], + "tagged": { "tuple": [{ "int": 42 }, { "bytes": "cafe" }] } + }, + { + "name": "map_int_to_int", + "schema": { "type": "object", "additionalProperties": { "type": "integer" } }, + "type": "map", + "value": { "2": 200, "1": 100 }, + "tagged": { + "map": [ + [{ "string": "1" }, { "int": 100 }], + [{ "string": "2" }, { "int": 200 }] + ] + } + }, + { + "name": "record_asset_class", + "schema": { "$ref": "#/components/schemas/AssetClass" }, + "type": { "custom": "AssetClass" }, + "value": { "name": "0011", "policy": "aabb" }, + "tagged": { + "struct": { "constructor": 0, "fields": [{ "bytes": "aabb" }, { "bytes": "0011" }] } + } + }, + { + "name": "variant_side_buy", + "schema": { "$ref": "#/components/schemas/Side" }, + "type": { "custom": "Side" }, + "value": { "Buy": {} }, + "tagged": { "struct": { "constructor": 0, "fields": [] } } + }, + { + "name": "variant_side_sell", + "schema": { "$ref": "#/components/schemas/Side" }, + "type": { "custom": "Side" }, + "value": { "Sell": { "price": 5 } }, + "tagged": { "struct": { "constructor": 1, "fields": [{ "int": 5 }] } } + }, + { + "name": "meta_record_nested_list", + "comment": "The journey-critical 05-invoke shape: a record nesting a parametric List. Field order is required = [tags, level], NOT alphabetical [level, tags].", + "schema": { "$ref": "#/components/schemas/Meta" }, + "type": { "custom": "Meta" }, + "value": { "level": 7, "tags": [1, 2, 3] }, + "tagged": { + "struct": { + "constructor": 0, + "fields": [{ "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] }, { "int": 7 }] + } + } + } + ], + "reject": [ + { + "name": "record_missing_field", + "schema": { "$ref": "#/components/schemas/Meta" }, + "value": { "tags": [1, 2, 3] }, + "reason": "missing required field 'level'" + }, + { + "name": "record_extra_field", + "schema": { "$ref": "#/components/schemas/Meta" }, + "value": { "tags": [1, 2, 3], "level": 7, "bogus": 1 }, + "reason": "unknown field 'bogus' not declared by the record" + }, + { + "name": "tuple_wrong_arity", + "schema": { + "type": "array", + "prefixItems": [ + { "type": "integer" }, + { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" } + ], + "items": false + }, + "value": [42], + "reason": "tuple arity 1 != declared 2" + }, + { + "name": "variant_unknown_case", + "schema": { "$ref": "#/components/schemas/Side" }, + "value": { "Nope": {} }, + "reason": "variant case 'Nope' not declared by Side" + } + ] +}