Skip to content

Input-Form Descriptor

Every valid validate report carries, beside pipe_io_contracts, an input-form descriptor per pipe: an ordered list of field descriptors a renderer can turn into a fill-in form with no schema heuristics, no hardcoded concept tables, and no description matching. The wire contract is the MTHDS spec docs/specs/mthds-input-form-descriptor.md at the workspace root; this page documents Pipelex's reference derivation of it.

The descriptor exists because the emitted json_schema is a payload contract, and the projection that produces it loses facts a form needs: which concept a node is, what it refines, whether ! or plain was authored, a fixed [N] count, an authored default, a one-member choice list. The descriptor reports those facts from where they still exist — the authored blueprints — and leaves the payload shape to the schema. It is presentation; it never changes what a caller submits.

Where it lives

  • mthds.protocol.input_formthe wire models, declared by the MTHDS standard's Python client and mirroring its input-form-descriptor page: FieldKind, one model per kind (TextField, ProseField, DateField, NumberField, BooleanField, EnumField, DocumentField, ImageField, ObjectField, ListField, UnknownField), their InputFormField union discriminated on kind, the nameless counterpart of each (TextItemUnknownItem) under the InputFormItem union a list's item holds, and PipeInputFormDescriptor. Which union applies is decided by position, never by content: a named position holds an InputFormField, whose name is a required str, and a list's item holds an InputFormItem, which declares no name at all. Pipelex does not declare a second copy of them.
  • pipelex/pipeline/input_form.py — the one public derivation build_input_form(pipes) and the InputFormDeriver behind it, which import and re-export those models. A node's kind IS its model, so the deriver constructs the per-kind model and a consumer narrows a node with isinstance or a match on its class.
  • PipelexValidationReport.input_form (pipelex/pipeline/validation_report.py) — a required field, keyed exactly like pipe_io_contracts. build_validation_report requires it as a keyword: the shared-assembly rule says a report field is populated on every backend or none, so a backend that forgets it fails loudly instead of shipping an empty view.
  • pipelex/pipeline/validate_in_process.py — the in-process assembly derives it inside the validation library's window, right beside build_pipe_io_contracts.
  • pipelex/codegen/native_expansion.pyreflect_structure_class, the faithful-or-absent reflection of a registered structure class into blueprint form, shared with the native consistency probe.

The report always carries the field. Whether it travels on the HTTP wire is the route's decision (absent unless a caller opts in), which belongs to the API.

Fact sources

The derivation reads two things and nothing else:

  • Slot facts from the loaded pipes' StuffSpecs: the authored input order, the three-valued presence marker (plain / optional / force), and the multiplicity including a fixed [N] count. Iterating the same loaded pipes as build_pipe_io_contractsPipeSignature placeholders included — is what makes the two key sets equal by construction.
  • Concept facts from the qualified library crate built from the parsed blueprints: descriptions, refinement links, structure fields with their defaults, choices, required-ness and nested concept refs. Qualified, not normalized: normalization flattens in-crate refinement, and the descriptor must report the refines chain as a list. Native concepts contribute their pinned blueprints; class-backed concepts (structure = "ClassName") are reflected from the class registry, which is why the derivation must run while the validation library is still loaded.

The derivation is total. A node it cannot map honestly reports kind: "unknown", the renderer's raw escape hatch against the sibling json_schema; nothing raises.

Kind assignment

Kinds are decided by chain membership and declared types — never by sniffing a schema shape. Per node, in precedence order:

  1. A concept with an authored structure table anywhere along its refinement chain is an object; its fields are the merged structures along the chain, base fields first, a refining concept overriding its parents'. The table decides even when it is empty — the engine backs an empty [concept.X.structure] with a field-less structured model, not with TextContent, so the descriptor reports object with an empty fields list rather than falling through to rule 4.
  2. Otherwise, the first structure = "ClassName" on the chain decides: a native class name (TextContent, ImageContent, …) maps by identity to that native's row; any other registered class is reflected field by field into an object; a class the registry does not hold — or one that is not a pydantic model — is unknown.
  3. Otherwise, a chain bottoming at a native concept takes that native's row, keeping the concept's own concept_ref, description and refines.
  4. Otherwise — a description-only or string-described concept — prose, with refines carrying the authored chain alone (absent when nothing was authored): a producer states only the links it holds, so no native.Text link is reconstructed — text-valuedness reaches the wire as the prose kind itself.

The crate the deriver reads is the current library's accumulated one, which holds the validated bundle and every library_dirs bundle loaded beside it — so a concept from a library dir, or a local concept refining one, follows the same rules as a local concept. A concept absent from that crate altogether is unknown.

The table and rules above ARE the no-hint kind assignment — stated rules, not heuristics: with no applicable intent hint, a node's kind is exactly what they produce. An applicable authored intent (spec: MTHDS intent-hints.md) feeds that assignment, never competes with it: on a text-valued node — one whose site is a text field, a native.Text-chained or description-only concept, judged per item on plural sites — an effective intent = "prose" yields kind: "prose" and intent = "label" yields kind: "text"; an absent, unknown, or inapplicable intent leaves the no-hint kind untouched. On a number-valued node, rating and quantity never change kind (both are number; the union has no finer kind) — they ride the hints slot for the renderer to honor. A time-formatted text node is not a text-valued site, so no intent word applies to it (and native.Html, being an object node, never reaches the text-valued judgment at all).

Native concept Kind
Text prose
Number number with integer: false
YesNo boolean
Time text with format: "time"
Document document
Image image
Date, Html, Page, TextAndImages, SearchResult object over the pinned blueprint's fields
Dynamic, Anything, JSON unknown

Nested structure fields map by their declared type: texttext; integernumber with integer: true; numbernumber; booleanboolean; datedate; datetimedate with datetime: true; timetext with format: "time"; a field with choicesenum (choices win over type, matching the structure generator); concept → the concept's node, carrying its namespaced concept_ref; listlist whose item comes from item_type / item_concept_ref (a nested list's inner item is inexpressible and reports unknown; the item node carries no name member — a list's item has no authored name, and the index labels entries); dictunknown. The shorthand field = "description" form is a required text. Nested fields take the blueprint field's description and required over the concept's; a scalar flattened at the top level keeps the concept's description.

A reflected class's fields map by their annotation, and each annotation is mapped on its own: a scalar takes its kind from the type, a native content class (ImageContent, DocumentContent, …) takes that native's row by identity, a list[X] is a list whose item is X reflected one layer down, a nested non-native pydantic model is an object the reflection walks into, a RootModel is its root annotation's node (the value it accepts is the root value itself, never an object over a root key), and an annotation with no honest node — a union that is neither X | None nor int | float, a dict, a class already on the path — is unknown at that field, leaving its siblings stated. Partiality is the point: collapsing the whole payload the moment one annotation could not be mapped hid every document and image position under the class, and a consumer preparing inputs from the descriptor then passed a local file path through un-uploaded. A class declaring no field is an object with an empty fields list — a payload that demands nothing, the class-backed twin of an empty authored structure table. A registered class that IS a RootModel is read the same way at the top: the concept's node is its root annotation's, carrying the concept's own concept_ref, refines and description — so a root-valued concept agrees with the json_schema derived from that same class beside it, instead of describing an object over a root key that model_validate would reject. This is deliberately unlike the reflection the native consistency probe performs (pipelex/codegen/native_expansion.py), which stays faithful-or-absent because it is compared against a normative pinned blueprint, where a plausible-looking partial answer would be the failure.

Constraint slots (minimum, exclusive_minimum, min_length, pattern, …) come only from reflected classes: the language cannot author constraint keys (an authored minimum = 0 is rejected as an unknown structure-field key), whereas a registered class's Field(gt=0, max_length=8, pattern=...) is read from the pydantic field metadata and stamped on the matching number or text node. A pydantic default on a reflected class is an authored fact: field_info.is_required() decides the node's required and a non-None default is reported as its default_value — a defaulted field is never required, the same invariant blueprint validation enforces by rejecting the required = true + default_value pair. (Authored hints are a parsed blueprint field with its own leniency rules; see below.)

Hints

Every node carries an optional hints slot: the node's effective MTHDS intent hints — the key-by-key merge of the concept's refinement chain (nearer declaration winning) with the site's own hints (a field's or a slot's, the site layer winning). Everything well-formed rides it, unknown keys and words included (the advisory validation lint has already warned about them; the descriptor preserves them for consumers that know more than this version does). On a plural node the merged hints appear on the list node and its item — the concept_ref duplication precedent: applicability is judged per item, and a renderer reading either node finds the same answer. A node with no effective hints has no hints member, so hint-free methods produce byte-identical descriptors to before hints existed. The slot is flat string → string by contract.

Required, presence and gating

On a top-level field, required is presence != "optional", and gating — whether the run is blocked until the caller provides content — is required and not a variable-multiplicity list. A plain Concept[] slot is therefore required: true, gating: false: its empty form is the legitimate value []. A fixed-count Concept[N] slot is a list with item_count: N that gates like any scalar. Concept[1] is a single node, because the runtime takes one value there (StuffSpec.is_multiple() is count > 1), and the descriptor says what the runtime accepts. Nested fields carry neither presence nor gating; their required is the payload fact.

gating is also what the vacuous-presence lint is stated on. When a gating slot of an entry pipe names an object node whose fields carry no required: true, validation reports the advisory input_presence_vacuous warning: the caller is told the slot must be supplied, but the empty object satisfies it, so nothing is actually demanded. Keying the lint on gating rather than on presence is deliberate — gating is the fact a renderer blocks Run on, so the lint and the form cannot disagree about which slots demand content, and the variable-multiplicity exclusion falls out of the rule above instead of being restated. See Understanding Optionality for the author-facing description and its two remedies.

Wire shape

Inapplicable slots are absent, never JSON null: the report's valid arm is dumped without exclude_none, so a field model owns its wire shape through a serializer that drops None values. Applicable falsy values (required: false, integer: false, gating: false) are kept. Every shape is closed (extra="forbid"), and a kind's own slots are simply that model's required fields — an enum node carries choices, an object node fields, a list node item, a number node integer, a date node datetime — so a node that does not belong to its kind cannot be constructed at all. Two further invariants are checked at construction: a field never carries both required: true and a default_value, and presence and gating are stated on every top-level field and on nothing below it.

Seeing it

pipelex-dev trace-input-semantics captures the descriptors as hop5_input_form.json beside hop5_pipe_io_contracts.json, so an authored fact can be checked on both projections at once — see Tracing Input Semantics. The assignment table is pinned by tests/integration/pipelex/pipeline/test_input_form.py over the committed probe bundle (and the hints behavior over its hinted sibling, hinted_bundle.mthds), the emitted wire's null-free shape by tests/unit/pipelex/pipeline/test_input_form_models.py, the hint merges and kind-feeding by tests/unit/pipelex/pipeline/test_input_form_hints.py, and the escape hatches the library loader keeps unreachable (concept cycles, unregistered classes, a structure class that holds itself) by tests/unit/pipelex/pipeline/test_input_form_deriver.py.

What clients project from it

The descriptor is what a client SDK renders a pipe's fill-in inputs template from — the hosted build routes no longer render one server-side. That projection is written twice, in TypeScript and in Python, and the two are pinned against each other by a shared fixture corpus this engine generates: see Projection Fixture Corpus. The corpus is also where the projection's rules are stated, including the places it is deliberately right where this engine's own inputs-template renderer is not.