Wasm Component Model Gives Plugin Runtimes A Real Boundary
· 73 min read · Views --
Last updated on

Wasm Component Model Gives Plugin Runtimes A Real Boundary

Author: Alex Xiang


Many plugin systems are not originally designed as platforms. They begin as a convenient escape hatch. A customer wants to add a small redaction rule to an AI gateway; the platform team cannot write a custom rule for every company; so it opens a Python-script entry point. The script receives a request, modifies the prompt or payload, and returns it to the main flow.

That path feels good at the start. Many people can write Python. Rules ship quickly. Special fields can be handled immediately. A year later, the host process starts carrying script debt: dependency conflicts, excessive permissions, cold starts, uncontrolled timeouts, sensitive data mixed into logs, and the most painful issue of all: the platform team is afraid to upgrade the runtime because nobody knows which customer’s script will break.

This article does not begin with abstract Wasm concepts. It follows a fictional AI gateway and data-platform migration: moving user-defined redaction plugins from Python scripts to Wasm Component Model. Names, interfaces, domains, and keys are fictional, but the engineering details are intentionally concrete: inventory tables, WIT interfaces, manifests, failure samples, tests, rollout, and rollback.

Wasm Component Model plugin-runtime architecture

The Incident Did Not Start With Wasm

The platform is called Harbor Gateway. It receives enterprise LLM requests, performs authentication, quota checks, auditing, routing, and data redaction, then forwards the request to model providers. Early redaction is simple: phone numbers, email addresses, ID numbers, bank-card numbers. Built-in regular expressions are enough.

Customer demands then become more specific. One company wants to redact internal employee codes. Another wants project code names replaced with internal labels. Some need different rules for a JSON field named doctor_note. Others want response-time restoration after the model returns. Built-in rules cannot keep up, so the platform opens a Python plugin:

def redact(request):
    text = request["messages"][-1]["content"]
    text = text.replace("Project Falcon", "[PROJECT]")
    request["messages"][-1]["content"] = text
    return request

The first implementation is direct: one plugin directory per tenant; the platform loads a script through a restricted interpreter, passes JSON in, and receives JSON back. The documentation says clearly: “Plugins must not access files, must not access the network, and must not log raw text.” But documentation is not a boundary. Code is.

The migration is triggered by an ordinary-looking slow request. One tenant updates its redaction plugin and introduces a third-party package. The package scans local timezone data during first load, then repeatedly retries because the container image lacks a file. The result is not one failed request. A batch of workers sees latency spikes together. SRE traces it back to a tight knot: AI-gateway main process, Python plugin, dependency import, and tenant-specific rule all inside the same failure domain.

The postmortem does not say “Python is bad.” It lists boundary problems:

ProblemConcrete symptomConsequence
Interface by JSON conventionField changes are documented, not type-checkedErrors appear only at runtime
Permission by conventionPlugins should not read files, but runtime does not enforce itCompliance and audit cannot explain the boundary
Uncontrolled dependenciesPlugins bring different packages and versionsHost upgrades become dangerous
Vague resource budgetTimeout, memory, and output size are not uniformly managedOne plugin can slow the gateway
Coarse observabilityOnly plugin latency, no capability use or error taxonomyDebugging depends on guesswork
Non-replayable releaseUser uploads a script and enables it directlyArtifact origin and reproducible build cannot be proven

Wasm Component Model is chosen in this context. Not because it is new, and not because “WebAssembly outside the browser” sounds attractive, but because it gives the team a harder boundary for plugin-host interfaces, capabilities, versions, and resource budgets.

First Decide What Not To Migrate

Harbor Gateway does not migrate every Python plugin in one shot. The team first inventories existing scripts by behavior.

Plugin typeShareBehaviorFirst batch?
Pure text redaction46%Input text, output redacted text and mappingYes
JSON-field redaction24%Rewrite request body by pathYes
External dictionary lookup11%Access customer-owned dictionary serviceNo, move dictionary into host-managed capability first
Audit side channel8%Send hit results to external audit systemNo, replace with host event outlet
Custom routing6%Choose model provider by contentNo, move to policy engine
Other scripts5%Historical experiments and half-abandoned logicDo not migrate; require shutdown or rewrite

This table saves the project from a common trap. Wasm fits plugins with clear boundaries, explicit input/output, and enumerable permissions. If a plugin needs long-lived connections, complex external systems, or large state, an independent service or a host capability may be more natural. Putting every extension into Wasm merely wraps old problems in a new runtime.

The first batch is intentionally narrow: pre-model request redaction and post-model restoration or secondary redaction. Plugins cannot directly access the network, read arbitrary files, or obtain host secrets. If they need dictionaries, they call a host-provided lookup-dictionary capability for platform-managed dictionaries. If they need metrics, they call a host-provided emit-metric capability whose metric names are restricted.

This constraint feels less flexible, but it is what makes the runtime stable.

Start With WIT, Not An SDK

One problem with the old Python plugins is SDK-first design. The platform first gives users a Python helper. Later JavaScript users want to write plugins. Go users ask for the same. SDKs multiply, but the underlying contract is still a drifting JSON document.

Component Model forces the team to write the interface first. The first WIT file is small:

package harbor:redaction@1.0.0;

interface types {
  record message {
    role: string,
    content: string,
  }

  record request-context {
    tenant-id: string,
    request-id: string,
    route: string,
    metadata: list<tuple<string, string>>,
  }

  record redact-input {
    context: request-context,
    messages: list<message>,
  }

  record replacement {
    token: string,
    original-hash: string,
    kind: string,
    start: u32,
    end: u32,
  }

  record redact-output {
    messages: list<message>,
    replacements: list<replacement>,
    diagnostics: list<string>,
  }

  variant redact-error {
    invalid-input(string),
    policy-denied(string),
    resource-exhausted(string),
    internal(string),
  }
}

interface plugin {
  use types.{redact-input, redact-output, redact-error};

  redact: func(input: redact-input) -> result<redact-output, redact-error>;
}

world redaction-plugin {
  export plugin;
}

The interface deliberately does not expose the full HTTP request. The plugin does not receive the Authorization header, provider-routing secrets, or raw connection metadata. context.metadata contains only keys allowed by the host allowlist, such as business unit, data region, and policy version.

replacement.original-hash is also important. The plugin is not allowed to put raw text into diagnostics. If later restoration is needed, the host stores token-to-original mappings. The plugin returns only an irreversible digest for auditing and deduplication. Plugin authors dislike this at first, but it prevents many log-contamination incidents.

With WIT in place, Rust, TinyGo, and JavaScript bindings all derive from the same interface. The documentation no longer says “pass a JSON object roughly shaped like this.” It says, “Implement redact; input and output are defined by the interface; other capabilities must be declared in the manifest.”

The Manifest Is The Plugin’s Contract

WIT handles the function interface. The manifest handles the plugin package and runtime policy. Harbor Gateway does not design a universal marketplace manifest; it covers the first redaction-plugin batch.

schema: harbor.plugin.redaction/v1
name: pii-redactor-basic
version: 1.4.2
component: pii_redactor_basic.wasm
wit: harbor:redaction@1.0.0
entrypoint: harbor:redaction/plugin.redact

publisher:
  type: tenant
  tenant_id: tenant-sandbox-42
  contact: security-owner@example.invalid

capabilities:
  dictionary:
    - name: employee-code
      mode: lookup
  metrics:
    allowed_prefixes:
      - redaction.hit
      - redaction.latency
  network: []
  filesystem: []

resources:
  max_memory_mb: 32
  max_execution_ms: 20
  max_output_bytes: 262144
  max_host_calls: 20

rollout:
  default_mode: shadow
  sample_rate: 0.05
  compare_with: python-plugin:v17

The value of the manifest is that the host can reject a plugin before loading it. If the plugin declares network access and policy forbids it, it is rejected before execution. If the plugin requires harbor:redaction@2.0.0 while production supports only 1.x, loading fails. If the requested resource budget exceeds the tenant plan, the plugin never enters rollout.

The loading error is written for developers:

plugin pii-redactor-basic@1.4.2 rejected:
  capability network is not allowed for redaction plugins.
declared:
  network: ["https://dictionary.example.invalid"]
suggestion:
  move dictionary data to managed dictionary "employee-code",
  or request external-service plugin type.

This looks verbose, but it is far better than a production 500. If a plugin platform tightens boundaries, it must explain rejection clearly. Otherwise users will think the new runtime is merely harder to use.

Host Capabilities Should Be Small, Not A Universal HTTP Client

A Wasm sandbox is not automatically safe. The dangerous part is often the host functions. Give a plugin http_request(url, body), and it regains much of the outside world. Give it read_config(key), and it can start probing configuration it should not see.

Harbor Gateway’s first version exposes only three host capabilities:

package harbor:redaction-host@1.0.0;

interface dictionary {
  lookup: func(name: string, key: string) -> option<string>;
}

interface metrics {
  emit-counter: func(name: string, value: u64);
}

interface clock {
  now-ms: func() -> u64;
}

These capabilities look weak. Precisely because they are weak, permissions can be audited. dictionary.lookup can access only dictionaries declared in the manifest. metrics.emit-counter can write only allowed prefixes. clock.now-ms exists for local diagnostics, not system-time access.

Host-call logs record capability usage without recording sensitive raw arguments:

{
  "plugin": "pii-redactor-basic",
  "version": "1.4.2",
  "request_id": "req_7f4c_example",
  "host_calls": [
    {"capability": "dictionary.lookup", "name": "employee-code", "key_hash": "sha256:7ab..."},
    {"capability": "metrics.emit-counter", "name": "redaction.hit.email"}
  ],
  "elapsed_ms": 8,
  "memory_peak_mb": 11,
  "status": "ok"
}

That changes the audit question from “Did the plugin secretly access something?” to “Which capabilities did it declare, and which did it actually use?” Once boundaries are explicit, security review is no longer just reading scripts.

The First Migrated Plugin: Employee-Code Redaction

The first pilot picks a small but realistic scenario. One tenant writes internal employee codes such as E-102938 in prompts. The model must not see real codes, so the plugin replaces them with [EMPLOYEE_CODE_1]. Some codes should be confirmed through a dictionary to avoid false positives.

The old Python plugin roughly looks like this:

import re

def redact(request):
    mapping = {}
    idx = 1
    for msg in request["messages"]:
        def repl(match):
            nonlocal idx
            code = match.group(0)
            if not lookup_employee_code(code):
                return code
            token = f"[EMPLOYEE_CODE_{idx}]"
            mapping[token] = code
            idx += 1
            return token
        msg["content"] = re.sub(r"E-[0-9]{6}", repl, msg["content"])
    request["_redaction_mapping"] = mapping
    return request

After migrating to Wasm, the plugin no longer stores raw mappings itself and cannot call an external service. It returns token, position, kind, and original hash. The real mapping is stored by the host in short-lived secure storage according to tenant policy.

The pseudocode becomes:

fn redact(input: RedactInput) -> Result<RedactOutput, RedactError> {
    let mut messages = input.messages;
    let mut replacements = Vec::new();
    let mut index = 1;

    for message in messages.iter_mut() {
        let rewritten = replace_codes(&message.content, |code, start, end| {
            if dictionary::lookup("employee-code", code).is_none() {
                return None;
            }

            let token = format!("[EMPLOYEE_CODE_{}]", index);
            index += 1;
            replacements.push(Replacement {
                token: token.clone(),
                original_hash: sha256(code),
                kind: "employee-code".to_string(),
                start,
                end,
            });
            Some(token)
        });
        message.content = rewritten;
    }

    Ok(RedactOutput {
        messages,
        replacements,
        diagnostics: vec![],
    })
}

The example is small, but it exercises the new boundary: the plugin only sees allowed input; dictionary access goes through the host; raw text is not written into diagnostics; output size and host-call count are limited; and the same component can run through the same interface tests in a local runner, staging, and production.

Shadow Mode: Do Not Let The New Plugin Own Requests Immediately

The easiest migration mistake is “it compiled, so ship it.” Harbor Gateway does not do that. Every Wasm plugin first runs in shadow mode: production requests still use the old Python plugin; the Wasm plugin runs in parallel; its output is used only for comparison and does not affect forwarding.

The comparator does not save raw text. It stores structured differences:

{
  "request_id": "req_shadow_example",
  "plugin": "pii-redactor-basic",
  "python_version": "v17",
  "wasm_version": "1.4.2",
  "result": "mismatch",
  "diff": {
    "message_count_equal": true,
    "replacement_count": {"python": 3, "wasm": 2},
    "first_mismatch": {
      "kind": "employee-code",
      "position_bucket": "message[1]:120-160"
    }
  }
}

Shadow mode has explicit exit criteria:

MetricGate for canary
Result consistencyAbove 99.5% for 7 consecutive days
Plugin p95 latencyNot slower than Python and below 20 ms
Resource-limit violations0 for 7 consecutive days
Unclassified errors0
Sensitive raw-text log scan0 hits

Consistency is not blindly maximized. Some mismatches show that the Wasm plugin fixed a false positive in the old Python implementation. The team classifies differences into three buckets: compatibility issue, old-logic defect, and new-logic defect. Only then does it decide whether to change the plugin, update golden cases, or notify the tenant about a behavior change.

Failure Sample One: Regex Semantics Missed A Chinese Boundary

The first shadow run quickly finds a difference in the employee-code plugin. The Python version uses one regex library; \b behaves differently from the Rust side in some mixed Chinese-English text. The sample is:

请把张三E-102938的审批意见发给我

The Python plugin matches E-102938; the Wasm plugin does not. At first the platform treats it as a bug. After discussion, the team decides that this behavior should not depend on language-specific regex boundary semantics. Interface tests add an explicit sample, and the implementation changes to scan E- followed by six digits, then apply explicit preceding and following character rules.

The new test case looks like this:

case: employee-code-adjacent-to-cjk
input:
  messages:
    - role: user
      content: "请把张三E-102938的审批意见发给我"
dictionary:
  employee-code:
    E-102938: valid
expected:
  content: "请把张三[EMPLOYEE_CODE_1]的审批意见发给我"
  replacements:
    - kind: employee-code
      token: "[EMPLOYEE_CODE_1]"

This problem is not caused by Wasm itself, but migration exposes hidden behavior. What looked like a simple “redact employee codes” business rule actually contained library semantics, Unicode boundaries, historical false positives, and user expectations. Shadow mode turns those arguments into samples.

Failure Sample Two: The Plugin Had No Network, But The Host Dictionary Added Latency

The second issue is closer to runtime design. The plugin has no network capability, but it calls host dictionary.lookup. One day the dictionary service has latency spikes, and plugin latency rises with it. Users only see that the Wasm plugin is slow, and the team nearly misdiagnoses the runtime.

After review, the team adds two constraints to the host capability:

ConstraintDescription
Single host-call timeoutdictionary.lookup returns resource-exhausted after 2 ms
Per-request cacheThe same key is looked up only once in one request

The manifest also gets budget fields:

resources:
  max_execution_ms: 20
  max_host_calls: 20
  host_call_timeout_ms:
    dictionary.lookup: 2
fallback:
  on_resource_exhausted: use_previous_plugin

This failure is a useful reminder: the Wasm sandbox isolates plugin code, but it does not automatically isolate tail latency from host capabilities. Once a plugin can call the host, each host capability needs timeouts, caching, circuit breaking, and observability like any external dependency.

Testing: Beyond Component Tests, Test Load Rejection

Harbor Gateway eventually splits plugin tests into six groups.

TestPurposeExample
WIT compatibilityConfirm that the plugin implements the expected interfacewasm-tools component wit validates the world
Golden caseFixed input/output to prevent rule driftEmployee code, email, JSON-path redaction
Differential testCompare old and new plugins on the same samplesPython v17 versus Wasm 1.4.2
Capability testUndeclared capability must failPlugin calls unauthorized dictionary and is rejected
Resource testTimeout, memory, and output size are controlledLarge text and repeated host calls
Load-rejection testInvalid manifest fails before executionNetwork capability, missing signature, incompatible version

The local runner is critical to developer experience. Plugin authors can run:

harbor-plugin run \
  --manifest plugin.yaml \
  --input fixtures/employee-code.json \
  --trace \
  --limits production

The output lists resources and capabilities, not just success or failure:

status: ok
elapsed: 7.4ms
memory_peak: 10.8MiB
host_calls:
  dictionary.lookup: 3 calls, 1 cache hit
output_bytes: 1842
diagnostics: 0

If the plugin oversteps permissions, local execution fails:

status: rejected
reason: capability_denied
detail: plugin called dictionary.lookup("vip-code") but manifest only grants ["employee-code"].

This is much better than “local is permissive, production is strict.” A plugin platform that people keep using must move restrictions earlier. Otherwise developers will keep using the oldest script entry point.

Release Pipeline: Users Upload A Traceable Package, Not A Bare Wasm File

The old system lets users paste Python scripts into the console. The new system does not accept bare Wasm uploads. A plugin package must go through build, scanning, signing, and registration.

The package layout is ordinary:

pii-redactor-basic-1.4.2/
  plugin.yaml
  pii_redactor_basic.wasm
  wit/
    harbor-redaction-1.0.0.wit
  fixtures/
    employee-code.yaml
  sbom.json
  signature.sig

The release pipeline is:

StepAction
BuildGenerate component from source repository; record compiler and dependency versions
ValidateCheck WIT, manifest, resource budget, and forbidden capabilities
TestRun golden cases, resource tests, and load-rejection tests
SignSign with tenant or platform publishing identity
RegisterWrite immutable version into internal plugin registry
Canaryshadow -> 5% -> 25% -> 100%

Production hosts load only signed versions from the registry. In the console, users see plugin version, signer, capability declaration, resource budget, current traffic percentage, and recent errors. They do not just see an “upload file” button.

Rollback is version-based:

runtime_policy:
  tenant_id: tenant-sandbox-42
  plugin: pii-redactor-basic
  active_version: 1.4.2
  previous_version: python-plugin:v17
  mode: canary
  traffic_percent: 25
  rollback_if:
    error_rate_gt: 0.5%
    p95_latency_ms_gt: 20
    mismatch_rate_gt: 1.0%

When rollback triggers, the gateway does not restart and does not delete the plugin package. It simply switches the tenant policy back to the previous version. Failure samples remain for later review.

Observability: Treat A Plugin Call Like A Small Request

A Wasm plugin is not a black box. Harbor Gateway records each plugin call as a span with more fields than the old Python version:

FieldExample
plugin.namepii-redactor-basic
plugin.version1.4.2
plugin.runtimewasm-component
wit.versionharbor:redaction@1.0.0
tenant.id_hashsha256:...
input.bytes18420
output.bytes17302
elapsed.ms8.1
memory.peak_mb11
host_calls.count4
capabilities.useddictionary.lookup,metrics.emit-counter
statusok/resource_exhausted/policy_denied/internal

There is no raw text, no full tenant ID, and no user prompt. The most ironic redaction-plugin incident would be a redaction plugin logging the original text. The platform later adds a CI check: if plugin diagnostics contain input fragments, golden tests fail.

Dashboards do not show only “overall Wasm success rate.” They break down by plugin, version, tenant policy, and error type. When a version fails, SRE can tell whether the issue is one plugin, a host capability, one tenant dictionary, or the whole runtime.

Migration Does Not Eliminate Python; It Downgrades The Script Entry Point

Harbor Gateway does not delete the Python runtime. It downgrades Python from the default extension method to a compatibility runtime. Existing plugins can continue to run, but no new high-permission capability is accepted there. New plugins default to Wasm Component Model. Extensions that truly need external network access or long-lived connections become external service plugins with separate authentication and quota.

The final extension model has three layers:

TypeSuitable forBoundary
Built-in rulesCommon PII, platform-maintained logicHost code, released with the platform
Wasm componentShort-lived logic with clear input/output and enumerable permissionsWIT plus manifest plus sandbox plus resource budget
External serviceLong tasks, complex dependencies, network or state requirementsHTTP/gRPC interface plus independent deployment plus tenant authentication

This layering is healthier than “Wasm everything.” Wasm Component Model is a strong plugin boundary, but it is not the only answer to every extension. It is best at putting user-defined small logic into an auditable, limitable, versioned box.

The Engineering Conclusions That Remain

This migration leaves several practical rules.

Interfaces should stabilize before SDKs. SDKs can change language and implementation; the WIT contract cannot drift every day. Once a plugin platform has an ecosystem, interface evolution should be treated like public API evolution.

Capabilities should be granted by business meaning, not raw system power. lookup-dictionary is narrower than http_request; emit-counter is more controllable than “write logs.” The more business-shaped the host capability is, the easier it is to audit and limit.

Resource budgets belong in the manifest, not hidden runtime defaults. Plugin authors should know their memory, time, and host-call budgets. A budget is not punishment. It is part of the platform contract.

Reject loading before runtime failure. Version mismatch, missing signature, excessive permissions, and resource over-budget should be blocked at registration or loading time. Quietly letting invalid plugins enter production and fail later is the bad experience.

Shadow mode is more reliable than a one-time cutover. Plugin migrations often expose historical behavior differences, especially in text processing, Unicode, JSON paths, null values, and error handling. Running old and new implementations side by side turns arguments into samples.

What makes Wasm Component Model compelling is not merely “you can write plugins in many languages.” Multi-language support is useful, but the more important point is that it lets plugin systems discuss boundaries seriously: what the interface is, what capabilities exist, how versions evolve, how failure is explained, who can publish, and how to roll back.

If your platform still executes user scripts directly, you do not necessarily need to migrate to Wasm tomorrow. But you can start with one useful exercise: list every input, output, permission, dependency, resource, and error that scripts actually use. If that table cannot be written clearly, the problem is not runtime selection. The plugin boundary has not really been designed yet.