|
| 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 | +# 6. Standard `boj_cartridge_invoke` Dispatch ABI |
| 5 | + |
| 6 | +Date: 2026-04-18 |
| 7 | + |
| 8 | +## Status |
| 9 | + |
| 10 | +Proposed |
| 11 | + |
| 12 | +## Context |
| 13 | + |
| 14 | +ADR-0005 picked an OS-process transport (`boj-invoke` CLI + Elixir |
| 15 | +`Invoker`) for the Elixir REST → Zig cartridge bridge. The skeleton is |
| 16 | +merged and an end-to-end request (`POST /cartridge/aerie-mcp/invoke` → |
| 17 | +CLI → `dlopen` → classified JSON error → HTTP 501) is verifiably working |
| 18 | +as of 2026-04-18. |
| 19 | + |
| 20 | +What the skeleton **cannot** do is dispatch a tool by name. The Zig |
| 21 | +loader (`ffi/zig/src/loader.zig`) currently requires four symbols per |
| 22 | +cartridge: |
| 23 | + |
| 24 | +- `boj_cartridge_init() -> c_int` |
| 25 | +- `boj_cartridge_deinit() -> void` |
| 26 | +- `boj_cartridge_name() -> *const c_char` |
| 27 | +- `boj_cartridge_version() -> *const c_char` |
| 28 | + |
| 29 | +None of the 99 cartridge shared libraries today expose these four |
| 30 | +symbols, let alone a fifth dispatch symbol. Each cartridge exports its |
| 31 | +own bespoke per-tool entries (`aerie_create_env`, `aws_*`, etc.) with |
| 32 | +non-uniform signatures. That is the ABI gap this decision closes. |
| 33 | + |
| 34 | +## Decision |
| 35 | + |
| 36 | +Adopt a **single fifth standard symbol** per cartridge: |
| 37 | + |
| 38 | +```c |
| 39 | +int32_t boj_cartridge_invoke( |
| 40 | + const char *tool_name, // NUL-terminated, caller-owned |
| 41 | + const char *json_args, // NUL-terminated JSON object, caller-owned |
| 42 | + char *out_buf, // caller-owned output buffer |
| 43 | + uintptr_t *in_out_len // in: buf capacity; out: bytes written (excl. NUL) |
| 44 | +); |
| 45 | +``` |
| 46 | + |
| 47 | +### Return codes |
| 48 | + |
| 49 | +| Code | Meaning | |
| 50 | +|-------|----------------------------------------------------------------| |
| 51 | +| `0` | Success. `out_buf[0..*in_out_len]` contains JSON result. | |
| 52 | +| `-1` | Unknown tool name for this cartridge. | |
| 53 | +| `-2` | Invalid JSON arguments (parse failure or wrong shape). | |
| 54 | +| `-3` | Output buffer too small; `*in_out_len` set to required size. | |
| 55 | +| `-4` | Runtime error inside the tool; `out_buf` holds error JSON. | |
| 56 | +| `-5` | Invariant violation / panic (cartridge should be unloaded). | |
| 57 | +| `-6` | Authorisation denied (cartridge-internal policy — see BJ2). | |
| 58 | + |
| 59 | +Return code semantics are frozen by this ADR. New failure modes must |
| 60 | +compose existing codes via the error-JSON body; the integer surface |
| 61 | +must not grow without a follow-up ADR. |
| 62 | + |
| 63 | +### Output format |
| 64 | + |
| 65 | +`out_buf` on success is a JSON object with at minimum a `result` key. |
| 66 | +Cartridges may add cartridge-specific keys but SHOULD NOT emit top-level |
| 67 | +`error` on a `0` return (that is reserved for the error-code path). |
| 68 | + |
| 69 | +On `-4` the output buffer carries: |
| 70 | + |
| 71 | +```json |
| 72 | +{"error": "<classification>", "message": "<human>", "details": { ... }} |
| 73 | +``` |
| 74 | + |
| 75 | +### Memory ownership |
| 76 | + |
| 77 | +- Caller allocates `out_buf` at a known size (default 64 KiB in the |
| 78 | + `boj-invoke` CLI; override via env `BOJ_INVOKE_BUFLEN`). |
| 79 | +- Cartridge writes at most `*in_out_len` bytes and updates the pointer |
| 80 | + to the actual length. |
| 81 | +- On return, buffer is caller's to free. |
| 82 | + |
| 83 | +No heap ownership crosses the FFI boundary. This matches the trunk-wide |
| 84 | +"no allocator leaks" invariant and keeps the cartridge free to use any |
| 85 | +internal allocator. |
| 86 | + |
| 87 | +### Relationship to existing four symbols |
| 88 | + |
| 89 | +All five symbols are required once a cartridge adopts this ABI. The |
| 90 | +loader will be updated to look up `boj_cartridge_invoke` as a required |
| 91 | +symbol **with a migration window**: existing cartridges without the |
| 92 | +symbol keep loading but are marked `dispatch=unavailable` in the |
| 93 | +catalog. The Elixir `Invoker` already classifies this as |
| 94 | +`missing_symbol`; that classification becomes the migration signal. |
| 95 | + |
| 96 | +## Consequences |
| 97 | + |
| 98 | +### Positive |
| 99 | + |
| 100 | +- Unified 5-symbol ABI collapses 99 bespoke surfaces into one. |
| 101 | +- Generic `Invoker` GenServer pool can target every cartridge without |
| 102 | + per-cartridge glue. |
| 103 | +- Error classification is an integer enum — cheap to log, easy to |
| 104 | + aggregate, no string parsing on the hot path. |
| 105 | +- Cartridge-internal JSON dispatch is a well-understood pattern |
| 106 | + (standard OTP `:gen_server` `handle_call/3` shape, sumcheck-style |
| 107 | + tool tables in Zig). |
| 108 | + |
| 109 | +### Negative |
| 110 | + |
| 111 | +- Every cartridge needs an internal dispatch table (tool name → Zig |
| 112 | + function) and JSON parsing. Smaller cartridges (stubs especially) |
| 113 | + gain maybe 60–120 lines of boilerplate. |
| 114 | +- Cartridge authors must learn the five-symbol contract and the |
| 115 | + seven-code return convention. The boilerplate lives in a shared Zig |
| 116 | + helper (`ffi/zig/src/cartridge_shim.zig`, new) to minimise copy-paste. |
| 117 | + |
| 118 | +### Alternatives rejected |
| 119 | + |
| 120 | +- **Per-tool entries without dispatch.** This is the status quo. |
| 121 | + Rejected because it forces every client (CLI, Elixir Invoker, |
| 122 | + future gateway) to know the exact symbol name + signature of every |
| 123 | + tool of every cartridge. Doesn't scale. |
| 124 | +- **Dynamic tool registration at init.** Cartridge calls back into the |
| 125 | + loader to register tool handlers. Requires a loader→cartridge callback |
| 126 | + ABI and a per-process registry. More moving parts than the 5-symbol |
| 127 | + table, and harder to reason about statically. |
| 128 | +- **JSON-RPC over stdin/stdout per cartridge.** Turns every cartridge |
| 129 | + into a server process. Dramatically higher process count and |
| 130 | + fork-overhead for an estate with ~100 cartridges. |
| 131 | + |
| 132 | +## Reference implementation |
| 133 | + |
| 134 | +`aerie-mcp` is the canonical reference (smallest non-trivial cartridge, |
| 135 | +four existing bespoke tools). This ADR is accompanied by commit |
| 136 | +`<pending>` which: |
| 137 | + |
| 138 | +1. Adds the four existing symbols (`boj_cartridge_init/deinit/name/version`) |
| 139 | + to `cartridges/aerie-mcp/ffi/aerie_ffi.zig`, so the stub-level FFI |
| 140 | + becomes probe-able via `boj-invoke probe`. |
| 141 | +2. Adds a minimal `boj_cartridge_invoke` that dispatches three tools |
| 142 | + (`create_env`, `destroy_env`, `get_status`) against the existing |
| 143 | + bespoke implementations, using a small tool-name string-table. |
| 144 | +3. Does **not** touch the other 98 cartridges. Migration is follow-up |
| 145 | + per-cartridge work, blocked on this ADR being accepted. |
| 146 | + |
| 147 | +## Non-decisions |
| 148 | + |
| 149 | +- Credential isolation (BJ2): unchanged. `boj_cartridge_invoke` is a |
| 150 | + per-cartridge entry; the vault partition index is per-cartridge, so |
| 151 | + nothing here weakens isolation. |
| 152 | +- Federation dispatch: still lives at `Boj.Federation`. This ADR |
| 153 | + concerns only in-process cartridge dispatch. |
| 154 | +- Tool-level auth: still the cartridge's responsibility. Return code |
| 155 | + `-6` signals auth denial but does not standardise the auth mechanism. |
| 156 | + |
| 157 | +## References |
| 158 | + |
| 159 | +- ADR-0005 — transport choice (OS-process port). |
| 160 | +- `ffi/zig/src/loader.zig` — the four-symbol loader interface this |
| 161 | + extends. |
| 162 | +- `ffi/zig/src/boj_invoke_cli.zig` — consumer of this ABI. |
| 163 | +- `elixir/lib/boj_rest/invoker.ex` — Elixir caller classification. |
| 164 | +- `docs/specification/cartridges/README.md` — cartridge spec (narrative). |
0 commit comments