Skip to content

Commit 2e833de

Browse files
hyperpolymathclaude
andcommitted
docs(decisions): ADR-0005 — Elixir REST to Zig FFI transport
Proposes OS-process-port (Option A) for Phase 2 of the Elixir REST port. Rejects NIF (Option B) on fault-isolation grounds — a segfault in any of 99 cartridge FFIs would kill the entire BEAM VM — and rejects Unix-socket side-car (Option C) as duplicating work the REST server is already doing one layer up. Holds NIF open as a future option once uapi_gnosis_set_handler lands upstream and the boundary consolidates to a single gnosis handler rather than 99 per-cartridge surfaces. Status: Proposed. Consequence and migration-path sections make the Phase 2 implementation concrete (boj_invoke_cli.zig + Invoker GenServer pool, 5s timeout default, pool size 4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 664521f commit 2e833de

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)