Bounded Bridge
View SourceCrosswake exposes one typed, versioned, request/reply-only bridge. It stays deliberately small even after Phase 5, and its public framing is family-first rather than command-first:
app.info.gethaptics.impactpermissions.statusfiles.picktransfer.downloadtransfer.exporttransfer.importtransfer.upload.prepare
The public families behind that posture are app_info, haptics, permissions.status, and, later,
share. This bounded bridge contract stays family-first. Concrete bridge commands remain lower-level protocol details. Everything
else is denied. The bridge is not navigation authority, not render synchronization,
and not a generic plugin bus. deep_link remains manifest-first shell activation truth, not route-local bridge or navigation authority.
Route policy declares the family ("haptics"); the bridge dispatches the command
("haptics.impact"). These stay two distinct vocabularies on purpose — one names
what a route is authorized to do, the other names what goes over the wire. A route
that still declares the older dotted command id as its capability keeps authorizing
indefinitely (no compile-time warning, no removal); mix crosswake.doctor names
any route still doing so, alongside the family id to write instead.
The Adopter API
You never build a wire envelope by hand. A LiveView that has attached the bridge calls
Crosswake.Bridge.push/3 with a capability family and receives a typed
Crosswake.Bridge.Reply in its own handle_info/2.
Attach first — push/3 raises on a socket that never did
Crosswake.Bridge.push/3 raises Crosswake.Bridge.NotMountedError when the socket
never called Crosswake.Bridge.attach/1. Crosswake never guesses a route id, so this
is a named, loud, install-time failure rather than a silent no-op. It is the one new
failure surface every adopter hits exactly once.
def mount(_params, _session, socket) do
socket =
socket
|> assign(crosswake_manifest: MyAppWeb.Crosswake.Policy.manifest(), crosswake_route_id: "saas-approval")
|> Crosswake.Bridge.attach()
{:ok, socket}
endattach/1 requires :crosswake_manifest and :crosswake_route_id to already be
assigned. on_mount: Crosswake.Bridge works too, provided it runs after whatever hook
assigns those two — on_mount hooks in a live_session run in declared order.
push/3 — the only entry point
# Fire-and-forget: no `ref:`, so no reply is delivered and no handle_info clause is needed.
Crosswake.Bridge.push(socket, "haptics", payload: %{"style" => "light"})
# Correlated: `ref:` is an opaque handle echoed back on delivery.
Crosswake.Bridge.push(socket, "file_picker", ref: {:pick, upload_id}, payload: %{"transfer_id" => id})Options are :ref, :payload, and :timeout (default 10_000 ms; pass :infinity to
opt a human-in-the-loop control out of the server-side backstop). push/3 returns the
socket immediately and is chainable; the reply always arrives later.
There is deliberately no shell-presence predicate on this API — nothing you can ask
before pushing. A pre-check invites if shell_there?, do: push, else: fallback: a
three-way branch by the back door that reintroduces exactly the branching this contract
exists to collapse. Push unconditionally; the reply tells you what happened.
dispatched/2 — read back the envelope, do not rebuild it
Crosswake.Bridge.dispatched/2 returns the wire envelope push/3 actually built for a
ref (or nil when no ask carrying that ref is in flight). Use it when a route needs
to render evidence of what was dispatched:
socket = Crosswake.Bridge.push(socket, "haptics", ref: :tap, payload: %{"style" => "light"})
assign(socket, dispatch: Crosswake.Bridge.dispatched(socket, :tap))This exists so you never hand-assemble a second copy of the envelope to display; a
hand-copied summary is free to drift from the manifest the seam actually resolved
against. It is not a shell-presence predicate. It reports what this LiveView asked for
and has not yet resolved, says nothing about whether a shell exists, and branching on it
cannot skip the reply — push/3 resolves to exactly one typed reply regardless.
handle_info/2 — where every reply lands
def handle_info({:crosswake_bridge, {:pick, upload_id}, %Crosswake.Bridge.Reply{} = reply}, socket) do
case reply do
%{status: :ok, payload: payload} ->
{:noreply, attach_picked_files(socket, upload_id, payload)}
%{status: :deny, denial: %Crosswake.Shell.Denial{} = denial} ->
{:noreply, put_flash(socket, :error, denial.message)}
end
endThere is no configuration in which a push resolves to silence. No shell, an unwired
hook, and a shell refusal each deliver exactly one typed reply, collapsed onto the same
Crosswake.Shell.Denial shape at status and distinguished only at reason. The
:shell_unreachable reason carries a details.failing_moment naming which of
:no_transport, :hook_not_wired, :reply_timeout, or :transport_error happened.
resolve/2 — when two answer sources race
When a native reply and an on-page fallback click can both answer the same ask, call
Crosswake.Bridge.resolve/2 from the fallback handler. It is an atomic
compare-and-delete — safe because a LiveView is one serialized process — so whichever
answer arrives first wins and the other finds nothing to resolve.
def handle_event("picked_in_fallback", params, socket) do
{:noreply, socket |> Crosswake.Bridge.resolve({:pick, params["id"]}) |> apply_fallback(params)}
endDo NOT route both answer sources into the same event name to "deduplicate" them — that
guarantees the same mutation runs twice. resolve/2 is the only mechanism, and a
second call for the same ref is a no-op that never raises.
Wiring the client half
The bridge needs the library-owned hook on the page. mix crosswake.install patches the
endpoint's static plug and prints the rest; mix crosswake.gen.bridge_hook prints all
three fragments on demand. See
guides/install.md.
Reconnects — an in-flight ask does NOT survive one
Say this plainly to yourself before designing any ask-shaped UI: an in-flight ask does not survive a LiveView reconnect. There is no durability here and none is promised.
A LiveView reconnect is a fresh mount/3, which means a fresh attach/1, which mints a
new epoch. Correlation state lives in the socket, so the previous epoch's in-flight table
is gone with the old process. A reply minted under the previous epoch is dropped as
foreign-epoch rather than replayed into a LiveView that never asked for it — delivering it
would hand a new page a stale answer to a question it never posed, which is precisely the
silently-wrong outcome this project exists to prevent.
The consequence for your UI: rebuild from assigns, do not resurrect the ask. If a user
was mid-answer when the socket dropped, the recovery path is the on-page fallback UI
rendered from server-held assigns, and the user answers again there. Phase 155 ships the
generated fallback components for this, with resolve/2 already wired in. Until then,
render your own fallback from assigns and call resolve/2 from its handler.
Do not build a UI that assumes an answer will come back after a reconnect. It will not.
Request Envelope
Every request carries:
protocol:crosswake.bridgeversion: bridge protocol versioncommand: one of the bounded commands abovecapability: must match the command's manifest-backed capability idroute_id: requested route identityactive_route_id: current active route identityorigin: caller originnative_runtime_version: shipped shell runtime versioncorrelation_id: request/reply correlation idcapabilities: capability versions available in the shellinstalled_packs: installed pack versions available in the shellpayload: command payload
Enforcement
Before any side effect runs, Crosswake checks:
- The active route matches
route_id - The route exists in the manifest
- The origin is allowlisted for the route
- The bridge protocol and native runtime versions are compatible
- The command is in the bounded Phase 3 allowlist
- The route declares the capability
- The manifest capability registry provides the capability version
- The shell exposes that capability version
- The route's declared packs are compatible with the shell
If any check fails, Crosswake returns a typed denial reply and executes no side effect.
Transfer Boundary
The transfer commands stay semantic and route-local.
transfer.importmeans the route explicitly asked to import user-chosen media or files.transfer.exportmeans the route explicitly asked to hand owned content out.transfer.downloadmeans the route explicitly asked for a download seam.transfer.upload.preparemeans staged local media is ready to enter a foreground-first upload path.
Transfer execution is foreground-first. States remain explicit: queued, preparing,
transferring, awaiting_network, verifying, complete, failed, and canceled.
Crosswake does not promise silent background reconciliation or generic file authority.
The bridge examples that remain honest in this posture are family-first:
app_infofor one-shot app metadata readshapticsfor low-frequency confirmation signalspermissions.statusfor one-shot prerequisite checks scoped to thenotificationsalias onlyshareas a future semantic handoff family once Crosswake publishes a truthful route-local share contract beyond compatibility-only command seams
Denial Reasons
Bridge denials reuse the shared shell denial vocabulary:
compatibility_mismatchundeclared_capabilityunavailable_capabilityorigin_deniedinactive_routepack_incompatibleshell_unreachable
shell_unreachable is minted only by Crosswake core, on the server, when no shell answer
could be obtained. It has no companion Finding axis and a companion must never return it
— see guides/compatibility.md. Its
details.failing_moment names which of :no_transport, :hook_not_wired,
:reply_timeout, or :transport_error happened. One reason, four moments: adopter code
branches on the reason, an operator reads the moment.
Guarantee Strength: What Is Structural And What Is CI-Caught
Two of this seam's guarantees have a strong form and a weaker true form. The weaker one is the true one. Written here rather than buried in a guard's moduledoc, because the difference changes what you should still review for.
| Guarantee | True strength | What that actually means |
|---|---|---|
| Every capability declares an interaction class | Structurally impossible to violate | Crosswake.Manifest.Capability lists :rebuild and :interaction in @enforce_keys. A capability missing either does not compile. No reviewer, no CI job, no discipline is involved. |
| The bounded bridge stays bounded (route-local, low-frequency, zero external SDK, semantically bounded, fails closed, backend-authoritative) | CI-caught, not structural | Crosswake.Bridge.CatalogGuard is a merge-blocking structural test. It proves no dynamic-registration function exists, no streaming seam exists, no external SDK is aliased into the bridge tree, and the native command enums match in both directions. It does not stop a maintainer adding forty controls one honest string at a time. A gate that is caught in CI is a gate a maintainer can still walk through deliberately. |
| Every push resolves to exactly one typed reply at the adopter boundary | CI-caught | Asserted across no-shell, unwired-hook, timeout, and refusal. It is a claim about the shape your handle_info/2 sees, not a claim that one vocabulary travels on the wire — shipped native binaries still emit strings outside the closed reason set, which the server normalizes on arrival. See guides/compatibility.md. |
The guard also labels its own six criteria honestly, including the two it can only prove in
the negative and the one it inherits rather than re-derives. Read
Crosswake.Bridge.CatalogGuard's moduledoc before you assume a criterion is airtight.
Reply Shape
Successful replies return:
protocolversioncommandroute_idcorrelation_idstatus: "ok"payload
Denied replies return the same fields with status: "deny" plus a nested typed denial payload containing the stable reason, code, message, route id, and optional hint.