Content Component Contract

A Content component is a WebAssembly module that accepts bounded bytes, performs one finite operation, and returns bounded bytes. A host writes the input into module memory, calls render, and reads the output range that call returns. Inputless generators use the same call with an input size of zero. Converters, validators, formatters, and document or image renderers fit this contract.

The interface is small so a browser, CLI, server, or native app can host the same module with little setup and in its own language. It needs no WASI implementation or JavaScript runtime. A normal Content component has no imports and cannot read an ambient clock, filesystem, network, or environment. The host keeps those services, authorization, and presentation; it passes only selected bytes and optional numeric uniforms. This narrow boundary supports reproducible calls, but authors must still manage internal state and hosts must still set resource limits. WASI serves broader programs and remains pre-1.0; its milestone releases can change interfaces. WASI roadmap

How a host calls a component #

Host                                Content component
  │                                         │
  ├─ inspect input/output capacities once ─→│
  ├─ write input at input_ptr ─────────────→│ exported memory
  ├─ set optional numeric uniforms ────────→│
  ├─ call render(input_size) ──────────────→│
  │←──────── output pointer and byte count ─┤
  └─ read exactly that output range ───────→│ exported memory

The host may reuse an instance and its memory. For each transform call, it checks input_size against the declared capacity, writes the current input at input_ptr, applies uniforms, calls render(input_size), and reads only the returned range on success. A generator accepts no input; the host applies uniforms and calls render(0). The host may instead instantiate per call. With fixed Wasm memory, it can budget memory once up front and reuse it across renders.

Exports #

A component that accepts input and returns output is a “transform”. One that accepts no input and only has output is a “generator”.

ExportRule
memoryLinear memory shared with the host.
input_ptr() -> i32Required when the component accepts input; location where the host writes it.
input_utf8_cap() -> i32 or input_bytes_cap() -> i32Required with input_ptr; exactly one, declaring maximum input bytes and their encoding rule.
input_content_type_ptr() -> i32 and input_content_type_size() -> i32 (optional)Only for components that accept input; an exact input MIME type. Export both or neither.
output_utf8_cap() -> i32 or output_bytes_cap() -> i32Exactly one; maximum output bytes and their encoding rule.
output_content_type_ptr() -> i32 and output_content_type_size() -> i32 (optional)An exact output MIME type. Export both or neither.
render(input_size: i32) -> i64Returns the current output pointer and byte count, or a recoverable rejection.
failure_modes_per_input_offset() -> i32 (optional)Required only if render can return recoverable rejection; declares the number of failure modes per input offset.

A generator omits all input exports; a partial input interface is invalid. utf8 means valid UTF-8; bytes allows arbitrary bytes. Inputless generators can start a pipeline but cannot follow another stage. The former output_i32_cap export is not part of this contract; encode numeric collections in a documented byte format.

Every pointer, size, capacity, and failure getter in this contract is a zero-argument function returning i32, not an exported global. Its body may contain only i32.const or global.get of an immutable module-constant i32 global, then end: no calls, loops, branches, locals, or memory/table operations. Values cannot depend on input, uniforms, or earlier renders. The full transform input region (input_ptr through its capacity) must be in initial memory and disjoint from active data segments. These rules let hosts inspect the ABI before running component logic.

Render result #

Interpret the i64 as unsigned bits:

     63 62                           32 31                             0
     +-+-------------------------------+--------------------------------+
     |0|         output pointer        |       output byte count        | success
     +-+-------------------------------+--------------------------------+
     |1|        reserved (zero)        |    optional failure detail     | rejection
     +-+-------------------------------+--------------------------------+

On success, the pointer must be below 0x80000000; the complete range must be in memory and the size must not exceed the declared output capacity. The size may use all 32 bits. A zero size is a successful empty output. Each call returns its own pointer and size; there is no last-output getter.

On rejection, the host stops the pipeline and reads no output.

Failures and errors #

Some operations have no expected error for any input within their declared capacity. Base64 encoding is one example: every byte string has an encoding. Such a component returns success for conforming calls and omits failure_modes_per_input_offset().

Other operations must reject some validly supplied bytes. A Base64 decoder, for example, can receive invalid characters or padding. It returns an error by setting the rejection bit in the render result. The host can report it and reuse the instance for another call. A component may report only that the input was rejected, or spend extra work to identify a position and failure mode. Tracking detail can cost code size or runtime work, so it is a choice for each component.

A trap means a precondition or internal invariant was violated: for example, the host passed more bytes than the declared capacity, supplied invalid UTF-8 to a UTF-8 input, or an assertion detected corrupt internal state. Malformed Base64 within the declared input domain is an ordinary decoding error. After a trap, memory may hold partial output; the host reads none of it and discards the instance. A data-preserving transform should trap instead of silently truncating output.

Optional failure detail

If failure_modes_per_input_offset() returns zero, the low 32 result bits must be zero. If it returns N > 0, the component defines N failure modes per input byte offset. On rejection:

input_offset = failure_detail / N
failure_mode = failure_detail % N

The offset is in 0..input_size, inclusive; input_size means the position after the last byte. Modes are component-specific integers in 0..N - 1. Every possible offset and mode must encode in 32 bits.

Optional content-type metadata #

A component can declare an exact input or output MIME type using the optional getter pairs in the export table. Omit a pair for unknown or generic content. In particular, generic UTF-8 needs no text/plain and generic bytes need no application/octet-stream; those declarations would needlessly narrow pipeline matching. Use metadata for specific formats such as text/markdown or image/ktx2. See Formats and Encodings.

Except for the forms below, the value is one lowercase media type with no whitespace, media ranges, lists, or parameters. Hosts compare ordinary types byte for byte; they do not trim or normalize them. Pointer, size, and bytes are module constants. In the strict artifact profile, each getter has the constant form described above, and the bytes occupy initial memory in one non-overlapping active data segment. A start function or render must not assemble them. Tooling can then read the type from Wasm sections without instantiating the module.

Multipart form data

The multipart parameterized type has exactly this form: multipart/form-data;boundary=uuid-00000000-0000-0000-0000-000000000000. The uuid- prefix is fixed; the following 36 bytes are a canonical lowercase UUID (8-4-4-4-12, hexadecimal digits and hyphens). The declaration is unquoted, has no extra whitespace or parameters, and its initial UUID is in the active data segment. Other multipart types and parameters are invalid, except for multipart related below.

Before render, the host may replace exactly those 36 bytes in exported memory. It leaves the pointer, size, prefix, and other bytes unchanged. A producer reads the current output slot when writing delimiters; a consumer reads its current input slot when parsing them. To connect them, the host copies the producer UUID into the consumer slot and updates the pipeline's tracked MIME type. An external boundary with a different shape or length needs an ingress adapter or another contract.

For uuid-<uuid>, delimiters start --uuid-<uuid>\r\n and end --uuid-<uuid>--\r\n; the two leading hyphens are not part of the MIME parameter. Components must use the slot on every call, not an inlined copy. A producer rejects a part body that contains a delimiter line for the current boundary. Tests must replace the default UUID and check both the declared type and the delimiters. The slot is the sole exception to static MIME bytes, not a general string uniform. The repository's application/wasm/wasm-read-input-content-type.wasm reads this metadata; it traps on an invalid static declaration.

multipart/related (RFC 2387) carries one root document and the resources it depends on, such as an SVG and its fonts. QIP permits exactly this form:

multipart/related;type="<type>/<subtype>";boundary=uuid-00000000-0000-0000-0000-000000000000

The type value is the root part's bare lowercase media type, in double quotes, with no parameters; it must not itself be multipart/*. It is the only quoted parameter value QIP allows. boundary is always last, so the final 36 bytes are the UUID slot, and it follows every rule of the form-data slot above: the host may patch it, and producers and consumers read it on every call. One boundary covers the whole body because the parts do not nest. There are no start or start-info parameters.

The body follows a narrow profile:

  • The first part is the root. Its Content-Type must match the type parameter's media type.
  • Every part has a Content-Type header in QIP's canonical form, which may carry parameters. Other part headers, including Content-ID, are optional and carry no meaning for the host.
  • Parts are not themselves multipart.
  • For CLI input, -i supplies the root and each typed -F file supplies a later part. -F alone still builds multipart/form-data.
  • A producer rejects a part body that contains a delimiter line for the current boundary, and a consumer bounds the part count and each part's size by its input capacity.

Hosts compare multipart/related declarations like any other parameterized type: the media type must match, and so must the type value (compared without its quotes) when both sides carry it. A multipart/related;type="image/svg+xml" consumer therefore rejects input whose root is text/html.

KTX2 profile metadata

QIP permits these image/ktx2 declarations for profile matching inside a pipeline:

image/ktx2; vkFormat=R8G8B8A8_SRGB; colorPrimaries=BT709; transferFunction=SRGB
image/ktx2; vkFormat=B8G8R8A8_SRGB; colorPrimaries=BT709; transferFunction=SRGB
image/ktx2; vkFormat=R32G32B32A32_SFLOAT; colorPrimaries=BT709; transferFunction=LINEAR
image/ktx2; vkFormat=R32G32B32A32_SFLOAT; colorPrimaries=DISPLAYP3; transferFunction=LINEAR
image/ktx2; vkFormat=R32G32B32A32_SFLOAT; colorPrimaries=DISPLAYP3; transferFunction=SRGB

The parameters are fixed module metadata. MIME parameter names are case-insensitive, but QIP exports use the spelling, order, and spacing above. Values are case-sensitive; no other KTX2 parameters are allowed. They must agree with the output's KTX2 vkFormat and Data Format Descriptor, or with the input profile the component accepts. See QIP KTX2 profiles for the remaining image constraints. IANA registers image/ktx2 with no optional parameters, so these are QIP pipeline annotations. Use bare image/ktx2 when sending the image through HTTP or another general MIME interface. IANA registration

When both stages declare a profile, their parameters must match. Bare image/ktx2 carries no profile guarantee. A pipeline may still run an older component that declares only the bare type, but the receiving component must check the KTX2 header and reject an unsupported profile. A host must not infer profile compatibility from a bare declaration. A component that accepts several profiles also declares the bare type and checks the header on each call.

Pipeline composition #

Recipes specifies the step-by-step validation algorithm and the messages every host prints; this section summarises the rules.

The host validates arbitrary bytes before they enter input_utf8_cap; encoding a native string as UTF-8 also establishes the guarantee. A UTF-8 component can rely on valid input. Its output_utf8_cap promises valid output, which a known-valid next stage can use without rescanning. UTF-8 output can enter a bytes input. Bytes output can enter a UTF-8 input only after host validation or an explicit validator. Debug or compliance hosts may check component output to detect a broken promise.

The host also tracks an optional MIME type:

BoundaryRule
Initial inputA caller-provided type is authoritative. Direct stdin or -i input to qip run has no separate type channel; without an initial type, the first stage is permitted.
Stage inputA declared type must match the tracked type exactly, except for KTX2 profile matching above. Without metadata, UTF-8 input accepts any valid UTF-8 and bytes input accepts any bytes.
Stage outputA declared type replaces the tracked type. Otherwise UTF-8-to-UTF-8 and output through output_bytes_cap preserve it; bytes-to-UTF-8 makes it unspecified.

Repeated renders and memory #

On every call, a transform reads the current bytes at input_ptr and validates input_size inside the component, even if the host checked it. A generator may treat nonzero input_size as a caller violation. Return only the current output's length and pointer. Keep caches and scratch state consistent when input or uniforms change. Reset every public uniform to its authored default before each normal return, including recoverable rejection.

If a transform writes output, its output buffer must be disjoint from input. It may return an immutable slice of the current input instead, but then it must not modify that input and the complete slice must fit within input_size. Reserve scratch space explicitly; unused input capacity is not scratch space. Build fixed memory with an explicit maximum; see Hard Limits and Writing QIP Components In Zig.

Known and untrusted components #

An application may trust a component it built, tested, or admitted through a controlled artifact process. Its wrapper can rely on that component's export, output-range, and MIME promises, while still checking caller-controlled input size or rejecting input supplied to a generator.

Core Wasm validation does not prove the Content contract. A host that accepts arbitrary modules must check exports and signatures, inspect the input region before copying, and check the returned size and range before reading. It must also enforce memory and execution limits. Do these checks where arbitrary modules enter the application; a validated artifact can then use the direct call flow. Bounded Output Proofs describes an optional static check of the output-size promise.

When to use it #

Use Content when one call finishes the job. For example, this pipeline renders Markdown and wraps the HTML:

npx @qip.dev/qipx qip.dev run text/markdown/commonmark.0.31.2.wasm \
  text/html/html-page-wrap.wasm < page.md

Use Time and Events when a retained instance must react to scheduled updates or input. Keep database, request, and filesystem work in the host. Streaming operations or ones that need many host callbacks may fit another interface better. See QIP Component Patterns for implementation examples.