A WebAssembly module can compute anything and touch nothing. That's the entire security story in one sentence: the module gets a slab of linear memory, a stack machine, and exactly the functions the host chose to hand it.

Which raises the obvious question for what we do — running compiled Swift inside an interpreter inside an iOS app, as an OTA update: how does that code read UserDefaults? Log? Fetch a URL? The answer is host functions — native Swift closures imported into the module, which we call bridges — and because the bridge layer of our SDK is public source, we can walk through a real one end to end.

(If "OTA code on iOS" just made your 2017 JSPatch reflex fire: fair. The one-line version is that patches are interpreted WASM under the DPLA's interpreted-code clause, the signed binary never changes, and compliance is yours to own — the full rules write-up is here. This post is about the mechanism.)

Four WASM types — so how do you pass a string?

A host function is a native function the embedding app imports into a WebAssembly module — the only way sandboxed WASM code can reach the outside world. And in the WebAssembly our toolchain emits, a function signature can only mention the four core numeric types: i32, i64, f32, f64. (The spec has since grown v128 and opaque reference types like externref, and the GC proposal adds struct references — but none of them carry a Swift String across the boundary, and our pipeline doesn't use them.) Everything richer is encoded as bytes in the module's linear memory, plus integers describing where.

So before you can bridge anything, you need an ABI both sides agree on. Ours:

ValueEncoding
String / blob intwo i32 args: (ptr, len) into guest linear memory
String / blob outone packed i64: `(ptr << 32) \
nil0
Structured dataJSON bytes (MessagePack on hot paths)

That's the whole ABI. Everything else is convention layered on it.

A real host function: reading UserDefaults from a WASM module

Say a function we've shipped OTA needs a value the native app stored in UserDefaults — a feature flag, a setting. The guest can't touch UserDefaults; it's an OS API on the other side of the sandbox. Bridge it.

Host side — this is the shipping code, verbatim, from Bridges.swift (the UserDefaultsBridge):

swift
imports.host(module, "defaults_get", [.i32, .i32], [.i64], store: store) { caller, args in
    let ctx = BridgeContext(caller: caller)
    let key = try ctx.readString(ptr: args[0].i32, len: args[1].i32)
    return [try ctx.packedResult(defaults.string(forKey: key))]
}

BridgeContext is our (ptr,len) ↔ bytes helper: readString does a bounds-checked copy out of guest memory; packedResult allocates inside guest memory, writes the result bytes, and packs (ptr &lt;&lt; 32) | len — or returns 0 for nil.

Guest side — the compiled Swift declares the import. In raw form it's a WASM import in module "patch"; in Swift (6+, behind the experimental Extern feature — .enableExperimentalFeature("Extern") in swiftSettings) it looks like this, lightly simplified from what our CLI generates:

swift
@_extern(wasm, module: "patch", name: "defaults_get")
func patch_defaults_get(_ keyPtr: Int32, _ keyLen: Int32) -> Int64

func defaultsString(forKey key: String) -> String? {
    var key = Array(key.utf8)
    let packed = key.withUnsafeMutableBufferPointer { buf -> Int64 in
        let addr = UInt(bitPattern: buf.baseAddress)        // wasm32: pointers are 32-bit
        return patch_defaults_get(Int32(bitPattern: UInt32(truncatingIfNeeded: addr)),
                                  Int32(buf.count))
    }
    guard packed != 0 else { return nil }
    return readGuestString(ptr: UInt32(truncatingIfNeeded: packed >> 32),
                           len: UInt32(truncatingIfNeeded: packed))   // copy, then free
}

Read the key out of guest memory, do the native work, write the answer back into guest memory, return one integer. A bridge for haptics, logging, or locale lookup is the same dance with different native work in the middle. Our SDK ships 110 of these host functions across ~45 bridgesgrep -c "imports.host" Sources/PatchSDK/*.swift if you'd like to check us — and the full list is enumerable in the public source, so what a patch can reach is auditable, not asserted.

The sharp edges (each of these cost us something)

1. Bounds-check every (ptr, len) the guest hands you

The guest can't read host memory — that's the WebAssembly model's guarantee, and WasmKit, a memory-safe Swift interpreter with no JIT, is what stands behind it — but a corrupt or hostile module can absolutely hand you a garbage (ptr, len) aimed outside its own linear memory. Skip the check and you get the classic out of bounds memory access trap at best, a silent bad read at worst. Every read and every write in our bridges is bounds-checked against the memory's actual size, which is also why we bounds-check despite the model's guarantee: defense in depth is cheaper than trusting any single layer.

2. Who frees guest memory? Allocation ownership across the boundary

Writing a result into guest memory means calling the guest's exported allocator. Our rule is symmetry: whoever allocates on an error path must free on that error path. Our bug: when a write-path bounds check failed after we'd already allocated the result buffer, we threw — and leaked the buffer, one per failed call. Slow guest-memory exhaustion, only under error conditions, which is exactly when nobody's looking. The fix (visible in Bridges.swiftfreeGuest runs before the throw) made the error path free everything the happy path would have.

3. NaN is not valid JSON: decide your float policy at the boundary

A Double computed in the guest can be NaN or Infinity, and bare nan is not valid JSON — serialize it naively and the other side's decoder rejects the payload at runtime, on a user's device. We learned this from a crash, not a code review. Our policy now: every float that crosses the boundary is checked, and non-finite values map to a documented finite fallback (0) in byte-identical logic on both sides. That is a real tradeoff, not a free fix — a NaN that becomes 0 is information lost — but for the marshalling layer's job (never emit an unparseable payload) we chose the defined value over the runtime crash, and wrote the policy down where both sides can see it.

4. The import list is the sandbox: WASM's capability model

Inverted from native code, where a library can call anything the process can: the module can only call what was imported into it. "Escape the import list" isn't an operation the instruction set defines. It also means secrets stay native: when OTA code fetches a URL, the guest asks the bridge, and the host's URLSession does TLS, cookies, and credentials — the guest sees response bytes, never a keychain. (Async bridges like that one need a continuation trick and an executor pump — that's its own post.)

And when a bridge call fails — bad pointer, missing key, thrown error — the call fails closed rather than trapping the app: module-level failures walk the SDK's fallback chain, current → previous → bundled native code, each rung validated by actually instantiating it, with "run no OTA module at all" as the floor. That chain (FallbackManager in the public source) is the answer to "what happens when the patch itself is the bug," and it's a better answer than any amount of "trust us."

What we're deliberately not showing

The bridge, honestly, is the easy half — everything above is readable in the public SDK today. The hard half is the question that comes before it: given an arbitrary Swift codebase, which functions are safe to ship as WASM at all, and which must stay native — where one false positive means a broken patch on someone's production app? That's a static-analysis engine with one non-negotiable bias: if it can't prove a function is safe to ship, the function stays native, and a patch that can't be proven safe is demoted rather than shipped. That part we keep proprietary.

Three questions you're probably about to ask, answered honestly: the SDK adds about 1.5 MB to your app, dead-stripped. A pure interpreter runs guest code one to two orders of magnitude slower than native — fine for the event-driven logic patches ship, wrong for per-frame work, which stays native anyway; proper benchmarks are a post of their own. And patched-code failures surface as recoverable errors plus the fallback chain above, not process crashes — the docs cover how to exercise a patch locally before anything ships.

But the boundary itself — four numeric types, two conventions, bounds-check everything, free what you allocate, decide your float policy on purpose — that part you can have.


Patch ships OTA code updates for native Swift iOS apps — patchrelease.com. The SDK, all 110 host functions included, is public: github.com/patch-release/patch-swift. Related: Swift async/await inside WebAssembly · Compiling Swift to WebAssembly.