Follow-on to #83. This is the piece that makes the Swift-to-Zig migration incremental instead of a cutover.
The problem it solves
Zig serves 1 of the 106 actions CraftApp.swift handles. Without a hand-off, every phase before the last would ship an app missing most of its API — which makes each phase unshippable and the whole migration a single risky landing.
With it, an action Zig has not taken over is passed to the host shim, which answers it exactly as it does today. Each later phase moves actions from the shim arm to the Zig arm and the page sees no difference.
One reply path, deliberately
The shim decides what the answer is, never how it gets there. It produces a payload and hands it back through the exported craft_ios_deliver_result, so the wire format, the request id and the escaping all stay on the Zig side.
This is the whole design constraint. Two components replying to one page by two different routes is precisely how this codebase accumulated five incompatible envelopes; the hand-off is shaped so a sixth cannot appear.
page ── postMessage {t,a,d,i} ──▶ ios_dispatch
│
┌───────────┴───────────┐
Zig serves it Zig doesn't
│ │
│ CraftSwiftShim
│ │
│ craft_ios_deliver_result
└───────────┬──────────┘
▼
sendResultToJS — one format, one id, one escapeThe shim is found by name at runtime rather than linked, so a Zig-only app — the fixture, or any app that has finished migrating — carries no dependency on it existing. objc_getClass returning null is the normal answer there.
Two details that would have failed quietly
The request id is read at hand-off time and passed explicitly, because the shim may answer asynchronously and request_context.current() is empty by then. Losing it drops the reply to action-name matching, which hands one caller's answer to another whenever two calls of the same action are in flight.
Only UnknownAction is handed on. Any other error means a handler ran and failed, and it has already decided what the real answer is — asking the shim to retry would replace a specific failure with whatever it happens to say. asBridgeError narrows the rest, so a handler error the protocol has no word for arrives as NativeCallFailed rather than as silence.
Verification
On an iPhone 17 Pro simulator, iOS 26.5:
ok: page -> native (i=1 seen 1x)
ok: native -> page, round trip closed (i=2 seen 1x)
ok: no feedback loop
ok: user script ran at document start
ok: unserved action reached the dispatcher (i=4 seen 1x)
ok: hand-off to host shim closed (i=5 seen 1x)
PASSi=2 and i=5 prove different things. i=2 cannot come from a stub: only a real UIKit process reports systemName "iOS". i=5 cannot come from Zig alone: servedBy is a string only the shim writes, delivered back through Zig's own reply path.
Building without the shim fails i=5 while i=4 still passes — which is both the non-vacuity check and the proof that a shimless app degrades correctly rather than crashing.
Plus unit tests for the no-shim path, the error narrowing, and the id round-trip.
Note on the fixture shim
It is Objective-C rather than Swift on purpose. The contract Zig depends on is a runtime one — objc_getClass plus a message send — and a @objc Swift class presents exactly that. Writing it in ObjC keeps the harness to a single clang invocation with no swiftc, while testing the identical mechanism.
Wiring the real CraftApp.swift up to this seam is mechanical follow-on work: it needs an @objc entry point on the Coordinator and its reply helpers pointed at craft_ios_deliver_result instead of window.craft._resolveCallback.
Checks run locally
zig build, zig build test, build-ios, build-ios-simulator, build-android, zig fmt --check src/ build.zig, verify:cimports, bun run lint — all pass.