|
| 1 | +<!-- SPDX-License-Identifier: PMPL-1.0-or-later --> |
| 2 | +<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> --> |
| 3 | + |
| 4 | +# 5. Choose Transport for Elixir REST → Zig Cartridge FFI |
| 5 | + |
| 6 | +Date: 2026-04-18 |
| 7 | + |
| 8 | +## Status |
| 9 | + |
| 10 | +Proposed |
| 11 | + |
| 12 | +## Context |
| 13 | + |
| 14 | +`elixir/` (added 2026-04-18) brings up a Cowboy+Plug HTTP listener that |
| 15 | +serves cartridge metadata but returns `501 invocation-not-yet-wired` for |
| 16 | +`POST /cartridge/:name/invoke`. The remaining Phase 2 work is: given a |
| 17 | +cartridge name, a tool name, and a JSON arg blob, call into the |
| 18 | +cartridge's Zig FFI and return the result. |
| 19 | + |
| 20 | +Each cartridge already ships: |
| 21 | + |
| 22 | +- `cartridges/<name>/ffi/<name>_ffi.zig` with C-ABI exports. |
| 23 | +- `cartridges/<name>/ffi/zig-out/lib/lib<name>.so` produced by |
| 24 | + `zig build` (per `STATE.a2ml §maintenance`: 99/99 built). |
| 25 | +- A documented tool catalogue in `cartridge.json`. |
| 26 | + |
| 27 | +The trunk already has `ffi/zig/src/loader.zig` which handles mount / |
| 28 | +unmount / hash verification of these `.so` files — so cartridge |
| 29 | +**loading** is a solved problem at the Zig layer. |
| 30 | + |
| 31 | +The question this ADR answers is: **how does an Elixir process sitting |
| 32 | +behind the REST router call a function in that loaded `.so`?** |
| 33 | + |
| 34 | +There are three established BEAM ↔ C-ABI transports, each with a |
| 35 | +different safety / latency / complexity trade-off. |
| 36 | + |
| 37 | +### Option A — OS process port (`System.cmd` / `Port.open`) |
| 38 | + |
| 39 | +Spawn a Zig CLI binary per invocation (or a long-lived one pooled in a |
| 40 | +GenServer). The CLI reads `<cartridge> <tool> <json>` on argv/stdin, |
| 41 | +loads the `.so` via `dlopen`, dispatches, emits JSON on stdout. |
| 42 | + |
| 43 | +- **Safety**: a cartridge crash kills the child OS process. BEAM is |
| 44 | + isolated. Strongest fault-containment. |
| 45 | +- **Latency**: ~1–5 ms per shell-out (fork/exec + dlopen overhead). A |
| 46 | + pool of long-lived children amortises this. |
| 47 | +- **Complexity**: one new Zig CLI binary; pool management is a |
| 48 | + standard GenServer pattern. |
| 49 | +- **Licensing / packaging**: the CLI is a small Zig target; no new |
| 50 | + upstream dependencies. |
| 51 | + |
| 52 | +### Option B — NIF (Native Implemented Function) via `zigler` |
| 53 | + |
| 54 | +Compile the cartridge FFI directly into a BEAM NIF and call it |
| 55 | +in-process. |
| 56 | + |
| 57 | +- **Safety**: a segfault in a NIF crashes the entire BEAM VM. No |
| 58 | + schedulers survive. Unacceptable for a server that hosts 99 |
| 59 | + cartridges of varying maturity. |
| 60 | +- **Latency**: sub-microsecond. Fastest possible. |
| 61 | +- **Complexity**: `zigler` adds a toolchain; per-cartridge build |
| 62 | + integration; NIF contract (dirty scheduler, resource management) is |
| 63 | + a real engineering commitment. |
| 64 | +- **Observation**: ADR-0002 positions BoJ to consume `libzig_api.so` |
| 65 | + upstream with the `uapi_gnosis_*` unified handler. If that consolidation |
| 66 | + lands, a single NIF boundary at the gnosis layer (not 99 per-cartridge |
| 67 | + NIFs) becomes attractive — but that is not today. |
| 68 | + |
| 69 | +### Option C — Local HTTP / gRPC to a side-car |
| 70 | + |
| 71 | +Run a Zig-based side-car process (existing `uapi_gnosis` binary or a |
| 72 | +new one) listening on Unix domain sockets. Elixir POSTs invocation |
| 73 | +requests to it. |
| 74 | + |
| 75 | +- **Safety**: side-car crash doesn't kill BEAM, symmetrical to A. |
| 76 | +- **Latency**: ~100 µs per Unix-socket round-trip; faster than A. |
| 77 | +- **Complexity**: requires a protocol definition (HTTP/JSON or gRPC) |
| 78 | + between Elixir and the side-car — architecturally this is the same |
| 79 | + shape the REST server is already solving, just pushed one layer |
| 80 | + down. |
| 81 | +- **Observation**: this is effectively what |
| 82 | + `http-capability-gateway` (ADR-0004) already does at tier 2. Pushing |
| 83 | + the Elixir→Zig bridge into gateway territory would conflate two |
| 84 | + concerns. |
| 85 | + |
| 86 | +## Decision |
| 87 | + |
| 88 | +**Adopt Option A (OS process port) for Phase 2**, with Option B held |
| 89 | +open for a future phase when the `uapi_gnosis` unified handler is |
| 90 | +live. |
| 91 | + |
| 92 | +Concretely: |
| 93 | + |
| 94 | +1. Add `ffi/zig/src/boj_invoke_cli.zig` — a short Zig binary that |
| 95 | + takes `<cartridge> <tool> <json-args>` on argv, loads the cartridge |
| 96 | + `.so` via the existing `loader.zig`, dispatches to the named tool, |
| 97 | + emits the result as JSON on stdout, and exits with a documented |
| 98 | + error-code convention (matching the trunk-wide `IsUnbreakable` |
| 99 | + proof scheme). |
| 100 | +2. Add `elixir/lib/boj_rest/invoker.ex` as a `GenServer`-managed pool |
| 101 | + of long-lived CLI children (not fork-per-request). The pool size |
| 102 | + is configurable; default 4. |
| 103 | +3. Replace the 501 in `elixir/lib/boj_rest/router.ex` with a call to |
| 104 | + `Invoker.dispatch(cartridge, tool, args)`. |
| 105 | +4. Timeouts: 5 s default per invocation, configurable per cartridge |
| 106 | + via `cartridge.json`. |
| 107 | + |
| 108 | +## Consequences |
| 109 | + |
| 110 | +### Positive |
| 111 | + |
| 112 | +- Fault isolation: any cartridge panic stays in the child OS process. |
| 113 | + BEAM's supervisor restarts the pool slot; the REST request returns |
| 114 | + a 502 with the error classification. |
| 115 | +- Incremental path: Phase 2 ships without committing to the NIF |
| 116 | + toolchain, and the CLI binary is reusable by non-Elixir clients |
| 117 | + (CI smoke tests, panic-attacker). |
| 118 | +- No new protocol: the CLI uses argv + stdout JSON, no gRPC/Thrift. |
| 119 | + |
| 120 | +### Negative |
| 121 | + |
| 122 | +- Latency floor ~1 ms per invocation. For tools expected to run |
| 123 | + hundreds of times per second per cartridge, the pool size needs |
| 124 | + tuning. Not every cartridge has that load profile — most are |
| 125 | + interactive (MCP tool call rate is one-per-user-keystroke at |
| 126 | + worst). |
| 127 | +- Double error-code surface: the CLI's exit codes plus the `.so`'s |
| 128 | + return codes. ADR follow-up: map both into a single |
| 129 | + `{:ok, body} | {:error, classified}` Elixir tuple in `Invoker`. |
| 130 | + |
| 131 | +### Migration path |
| 132 | + |
| 133 | +When `uapi_gnosis_set_handler` stabilises upstream (ADR-0002 open |
| 134 | +question #1), re-evaluate: keep the OS-port pool and have it link |
| 135 | +`libzig_api.so` internally, or promote to a single NIF boundary at |
| 136 | +the gnosis handler. That decision will merit its own ADR. |
| 137 | + |
| 138 | +## Non-decisions |
| 139 | + |
| 140 | +- **Federation invocation** (cross-node) is out of scope — it belongs |
| 141 | + to `Boj.Federation` at the trunk ABI, not the Elixir REST layer. |
| 142 | +- **HTTP capability governance** on the REST endpoint itself is |
| 143 | + ADR-0004's territory (`http-capability-gateway` at tier 2). This ADR |
| 144 | + covers only what happens *after* the gateway lets the request through. |
| 145 | +- **Cartridge credential isolation** is `Boj.CredentialIsolation` |
| 146 | + (BJ2) — this ADR does not change that model; the CLI must carry the |
| 147 | + cartridge's vault partition index through to the FFI. |
| 148 | + |
| 149 | +## References |
| 150 | + |
| 151 | +- ADR-0002 (`align-unified-zig-api-stack`) — open question #1. |
| 152 | +- ADR-0004 (`adopt-http-capability-gateway`) — tier 2 attachment |
| 153 | + point; independent of this ADR. |
| 154 | +- `elixir/README.adoc` §Phase plan — Phase 2 description. |
| 155 | +- `docs/practice/DOGFOOD-LOG.adoc` 2026-04-18 entry — original |
| 156 | + "REST API unreachable" finding that motivated the port. |
| 157 | +- `ffi/zig/src/loader.zig` — existing cartridge `.so` loader. |
0 commit comments