Hard Limits

QIP components run behind a byte-oriented interface. The host writes input into linear memory, calls a known export, and reads the output. A component does not receive a filesystem, network connection, clock, DOM, cookies, environment variables, GPU access, or application secrets unless the host explicitly passes that information in.

This boundary is useful only if its resource limits are also explicit. A module that cannot access the network but can allocate without limit or run forever is still difficult to operate safely.

QIP therefore separates three controls:

ControlWhat it limitsHow QIP applies it
Host accessWhat the component can observe or changeNo WASI; only documented contract imports
Linear memoryHow much memory the component can declareFixed by default; optional --max-memory cap
Execution timeHow long a CLI stage may run--timeout-ms

The host-access boundary and fixed-memory policy are the normal component model. A host may permit memory growth explicitly, but it must also set a byte cap.

Where The CLI Enforces Each Rule #

This page is the canonical map of Wasm validation in the CLI:

Ruleqip runqip dry runqip comply
Component ABI and pipeline compatibilityBefore executionYes, using the same pipeline plannerBase ABI and static contract checks for the implementation
Reject memory.growEvery pipeline component, by defaultEvery pipeline component, by defaultNot applied to the implementation
Enforce declared memory boundsWith --max-memoryWith --max-memoryNot applied to the implementation
Strict Wasm artifact structureOnly when wasm-strict-profile.wasm is executed on input bytesValidates the checker pipeline but does not inspect input bytesNot currently applied
Recognizable bounded loopsOnly when wasm-bounded-loops.wasm is executed on input bytesValidates the checker pipeline but does not inspect input bytesNot currently applied
Recognizable bounded render outputOnly when wasm-bounded-output.wasm is executed on input bytesValidates the checker pipeline but does not inspect input bytesNot currently applied
Declarative Compliance checkerNot applicableNot applicableWith --declarative-checkers, applied to every --with checker

qip run and qip dry run share the Go module-policy validator. The strict artifact and bounded-loop checks are currently executable QIP components, not implicit modes of that validator. Dry run never reads or executes the input being checked, so it cannot certify an artifact merely by planning the checker pipeline.

Apply A Runtime Policy #

The same policy controls work with text, binary, and image components:

qip run --timeout-ms 1000 --max-memory 67108864 components/utf8/trim.wasm
qip bench -i input.txt --timeout-ms 1000 --max-memory 67108864 components/utf8/trim.wasm
qip image -i in.png -o out.png --timeout-ms 1000 --max-memory 8388608 components/rgba/invert.wasm

Browser hosts expose the memory controls as attributes:

<qip-edit max-memory="67108864">
  <source src="/components/text/markdown/commonmark.0.31.2.wasm" type="application/wasm" />
</qip-edit>

Use allow-memory-grow only with an explicit cap:

<qip-edit allow-memory-grow max-memory="16777216">
  <source src="/components/example.wasm" type="application/wasm" />
</qip-edit>

Input and output have separate bounds. The component advertises buffer capacities through its ABI exports; the host rejects data that does not fit. Those checks prevent a host copy from crossing the advertised buffer. They do not impose a useful total-memory budget by themselves.

The Strict Wasm Profile #

For components that need a statically inspectable execution shape, QIP defines a stricter subset of WebAssembly:

Recognizable loop bounds are a separate check. They depend on conservative proof patterns in compiler output rather than only on structural facts in the artifact.

Cost Of Structural Validation

Let B be the module size in bytes, I the decoded instruction count, V the number of defined functions, and E the number of direct calls between defined functions.

RuleTimeAdditional space
Reject importsO(B) overallO(1)
Validate the memory count, kind, sharing, and declared maximumO(B) overallO(1)
Reject a start functionO(B) overallO(1)
Reject memory.growO(I)O(1)
Reject atomic instructionsO(I)O(1)
Reject indirect and reference-based callsO(I)O(1)
Detect recursive direct-call cyclesO(V + E)O(V + E)
Resolve content-type getters and verify their initial dataO(B)O(S) for active data segments

Every call-graph edge comes from a decoded call instruction, so V + E is bounded by the module size. The complete structural check is therefore O(B) time. The call graph accounts for its linear additional memory.

The policy checker can read the module bytes once, checking every local rule while it decodes and collecting direct-call edges. Once decoding finishes, it runs a depth-first search over that graph. This is one pass over the module bytes plus one graph traversal, not two passes over the Wasm file. A strictly streaming constant-memory implementation is not sufficient for recursion: functions may call functions defined later, so the graph cannot generally be finalized while each function body is being read.

This policy checker assumes its input is already a valid WebAssembly module. Full specification validation—section order and uniqueness, types, indices, limits, constant expressions, and instruction typing—is a separate concern. The policy reader still fails closed if it cannot safely decode an instruction body, but that is not a substitute for the specification's validation algorithm.

Compliance components use a separate declarative checker rule, enabled with qip comply --declarative-checkers. They may import only the documented oracle functions from the qip host module. The implementation under test remains a separate instance and is not imported or memory-linked into the checker. Ordinary QIP components still use the import-free artifact profile above.

Run the artifact checkers as a two-stage pipeline:

qip run -i component.wasm -- \
  components/application/wasm/wasm-strict-profile.wasm \
  components/application/wasm/wasm-bounded-loops.wasm

wasm-strict-profile checks imports, memory shape, banned instructions, indirect calls, recursion, and statically readable content-type metadata. wasm-bounded-loops checks loop bounds. Keeping these checks separate allows a host to enforce the structural profile while using a runtime mechanism for execution, such as a timeout or fuel meter.

Bounded Output Proofs #

wasm-bounded-output certifies that every successful exit from a Content component's render(i32) -> i32 returns no more than its static output_utf8_cap() or output_bytes_cap(). Run it independently after normal Wasm and QIP contract validation:

qip run -i component.wasm -- \
  components/application/wasm/wasm-bounded-output.wasm

The checker is deliberately narrower than whole-program range analysis. It accepts a constant result within capacity or a final compiled-Wasm epilogue equivalent to:

local.get $output_size
i32.const OUTPUT_CAP
i32.gt_u
if
  unreachable
end
local.get $output_size

The capacity operand may instead be a global.get of an immutable constant used by the capacity export. The checked local must be returned unchanged, and no earlier return or branch may escape to the function label. These rules make the proof local and mechanically checkable: every normal exit crosses the guard, while an excessive value traps inside the component.

This certificate covers the returned byte count only. It does not prove that the component wrote meaningful output, stayed within the output buffer while writing, or returned a valid output pointer. Those remain QIP contract and component-correctness concerns. Modules without the recognized proof may still be correct; the checker fails closed when it cannot establish the property.

For a readable inspection report, use:

qip score component.wasm

fixed_bound_loops: PASS means every backedge matched the verifier's accepted patterns. WARN means the verifier could not establish a bound. It does not prove that the module loops forever.

What The Loop Checker Can Prove #

A source-level loop bound is irrelevant if the compiler removes or obscures it. The checker works on the final Wasm binary. It looks for a local counter that:

  1. changes monotonically inside the loop; and
  2. participates in an exit comparison in the same direction.

Accepted updates include adding or subtracting a constant, division or shifts toward zero, and those operations routed through a temporary local. The checker also recognizes the common parser stride i += 1 + len when len is a known non-negative narrow value.

Exit evidence may use signed or unsigned 32-bit or 64-bit comparisons against a constant, local, global, or computed value. Countdown tests with i32.eqz are also accepted. The exit may branch out of the loop, return from the function, or pass through blocks whose paths all leave the loop or function.

Any unrecognized write to the candidate counter disqualifies it. For example, i = next_pos might move the counter away from the bound. An unconditional loop { br 0 } has no counter or exit and is rejected.

This is a deliberately conservative analysis, not a general termination proof. If a safe loop compiles into an unsupported shape, simplify its counter, add an explicit step budget, or run the component under a runtime execution limit. Provable Loops contains verified source-level rewrites for the common cases.

Build With A Memory Budget #

Choose the budget before selecting compiler flags. Account for input, output, scratch space, stack, static data, and any allocator arena. Then inspect the final .wasm; source code and linker settings do not establish the whole policy.

For small transforms, static buffers are usually the simplest arrangement. If an API requires an allocator, put it over a fixed buffer. Treat overflow as an error or trap rather than silently requesting more memory.

Zig

Set an explicit initial and maximum memory when both should be 1 MiB:

zig build-exe component.zig \
  -target wasm32-freestanding \
  -O ReleaseSmall \
  -fno-entry \
  -rdynamic \
  --stack 65536 \
  --initial-memory=1048576 \
  --max-memory=1048576 \
  -femit-bin=component.wasm

The repository Makefile supplies --max-memory=$(ZIG_WASM_MAX_MEMORY) and allows individual modules to override the value. std.heap.FixedBufferAllocator is available when an allocator-shaped interface is useful without a growable heap.

C And Clang

LLVM's Wasm linker can make maximum memory equal initial memory:

clang --target=wasm32 -nostdlib -Oz component.c \
  -Wl,--no-entry \
  -Wl,--no-growable-memory \
  -Wl,-z,stack-size=65536 \
  -Wl,--export=render \
  -Wl,--export=input_ptr \
  -Wl,--export=input_utf8_cap \
  -Wl,--export=output_ptr \
  -Wl,--export=output_utf8_cap \
  -Wl,--export-memory \
  -o component.wasm

The explicit equivalent is:

-Wl,--initial-memory=1048576 -Wl,--max-memory=1048576

The same linker flags work through zig cc. Avoid malloc and libc features that assume a larger runtime unless you have provided and budgeted their memory. See the LLVM lld WebAssembly options for the linker semantics.

Rust

Rust's wasm32-unknown-unknown target uses dlmalloc as its default allocator. Allocator-using programs may therefore contain memory.grow. The linker option --no-growable-memory only makes maximum memory equal initial memory; it does not remove that instruction from allocator code.

For fixed-memory components, prefer wasm32v1-none, no_std, and either static buffers or an allocator backed by a fixed static region. Set the memory shape at link time, then let QIP inspect the final artifact:

RUSTFLAGS="-C link-arg=--initial-memory=1048576 -C link-arg=--max-memory=1048576" \
  cargo build --release --target wasm32v1-none

qip run target/wasm32v1-none/release/component.wasm

If an existing Rust component depends on a growable allocator, give it an explicit host cap:

qip run --allow-memory-grow --max-memory 16777216 component.wasm

The wasm32-unknown-unknown target notes describe its default allocator. The wasm32v1-none target provides core and alloc without std; using alloc still requires a component-supplied allocator.

WebAssembly Text

For hand-written WAT, declare equal initial and maximum page counts and omit memory.grow:

(memory (export "memory") 16 16)

A WebAssembly page is 64 KiB, so 16 pages is 1 MiB.

When This Boundary Does Not Fit #

The restrictions buy a component that is easier to inspect, test, benchmark, and run in different hosts. They do not prove correctness or make untrusted output safe to consume. The host must still validate output according to how it will be used.

QIP is a reasonable boundary for content transforms, validators, image filters, and small interactive renderers with known input sizes. It is a poor fit for work that genuinely needs open-ended allocation, operating-system services, threads, recursive or highly irregular control flow, or a large managed runtime. Keep that work in the host, or use a less restrictive execution model with its own resource controls.

For production components:

  1. Set a memory maximum at link time.
  2. Run the final artifact with --max-memory in CI; fixed memory is the default.
  3. Keep ambient capabilities in the host and pass required data as bytes.
  4. Use --timeout-ms for untrusted or expensive CLI stages.
  5. Benchmark with qip bench after the contract and limits are stable.