Skip to content

Commit fcd8d20

Browse files
hyperpolymathclaude
andcommitted
docs+feat(adr-0006): standard boj_cartridge_invoke ABI + aerie-mcp ref impl
ADR-0006 freezes the 5th standard cartridge symbol: int32_t boj_cartridge_invoke(tool, json_args, out_buf, &out_len) Return codes: 0/success, -1 unknown-tool, -2 bad-args, -3 buf-too-small, -4 runtime-error, -5 panic, -6 auth-denied. aerie-mcp is the canonical reference: - Adds the four pre-existing standard symbols (boj_cartridge_init/deinit/name/version) so existing loader.zig infrastructure recognises it. - Adds boj_cartridge_invoke with a 4-entry dispatch table (list_envs_count, create_env, destroy_env, get_status) for the existing bespoke tools. - Seven new inline tests cover: name/version strings, init rc, unknown tool (-1), success path, and buffer-too-small (-3) with required- length writeback. End-to-end verified: POST aerie-mcp/invoke now returns {"probe":"ok","result":{"name":"aerie-mcp","version":"0.1.0"}}. The other 98 cartridges are explicit per-cartridge follow-up — that's the point of ADR-0006: migrate against a single frozen ABI rather than continuing to invent per-tool entry points. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ec1e5c1 commit fcd8d20

2 files changed

Lines changed: 272 additions & 0 deletions

File tree

cartridges/aerie-mcp/ffi/aerie_ffi.zig

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)
33
//
44
// Aerie FFI — C-compatible exports for environment management.
5+
//
6+
// Reference implementation of the 5-symbol cartridge ABI (ADR-0006):
7+
// boj_cartridge_init / deinit / name / version / invoke.
8+
// Other cartridges should follow this file's shape.
59

610
const std = @import("std");
711

12+
// ── Bespoke tool exports (kept for backward compat during migration) ──
13+
814
/// List active environment count.
915
export fn aerie_list_envs_count() u32 {
1016
return 0; // Stub
@@ -28,6 +34,71 @@ export fn aerie_get_status(env_id: u32) u8 {
2834
return 1; // Stub — ready
2935
}
3036

37+
// ── Standard ABI symbols (ADR-0005 + ADR-0006) ─────────────────────────
38+
39+
// String literals in Zig are already NUL-terminated sentinel arrays; hold
40+
// their addresses in module-level constants so the exported pointers have
41+
// stable lifetime.
42+
const CARTRIDGE_NAME_PTR: [*:0]const u8 = "aerie-mcp";
43+
const CARTRIDGE_VERSION_PTR: [*:0]const u8 = "0.1.0";
44+
45+
export fn boj_cartridge_init() callconv(.c) c_int {
46+
return 0;
47+
}
48+
49+
export fn boj_cartridge_deinit() callconv(.c) void {}
50+
51+
export fn boj_cartridge_name() callconv(.c) [*:0]const u8 {
52+
return CARTRIDGE_NAME_PTR;
53+
}
54+
55+
export fn boj_cartridge_version() callconv(.c) [*:0]const u8 {
56+
return CARTRIDGE_VERSION_PTR;
57+
}
58+
59+
/// ADR-0006 reference: dispatch `tool_name` with `json_args`, write result
60+
/// into `out_buf`/`*in_out_len`. Return codes documented in ADR-0006.
61+
///
62+
/// This reference does **not** parse `json_args`; every dispatched tool
63+
/// either ignores args or consumes trivial integers that we embed as
64+
/// placeholder values. That keeps the reference focused on the ABI
65+
/// shape rather than on JSON parsing, which each cartridge chooses for
66+
/// itself.
67+
export fn boj_cartridge_invoke(
68+
tool_name: [*c]const u8,
69+
json_args: [*c]const u8,
70+
out_buf: [*c]u8,
71+
in_out_len: [*c]usize,
72+
) callconv(.c) i32 {
73+
_ = json_args; // reference implementation ignores args
74+
75+
if (tool_name == null or out_buf == null or in_out_len == null) return -2;
76+
77+
const tool = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name)));
78+
const cap = in_out_len.*;
79+
80+
// Tool table — extend this for every tool the cartridge exposes.
81+
const body = if (std.mem.eql(u8, tool, "list_envs_count"))
82+
"{\"result\":{\"count\":0}}"
83+
else if (std.mem.eql(u8, tool, "create_env"))
84+
"{\"result\":{\"env_id\":1}}"
85+
else if (std.mem.eql(u8, tool, "destroy_env"))
86+
"{\"result\":{\"ok\":true}}"
87+
else if (std.mem.eql(u8, tool, "get_status"))
88+
"{\"result\":{\"status\":\"ready\"}}"
89+
else
90+
return -1; // unknown-tool
91+
92+
if (body.len > cap) {
93+
in_out_len.* = body.len;
94+
return -3; // buffer-too-small
95+
}
96+
97+
@memcpy(out_buf[0..body.len], body);
98+
in_out_len.* = body.len;
99+
return 0;
100+
}
101+
31102
// ── Tests ──
32103

33104
test "create rejects null name" {
@@ -41,3 +112,40 @@ test "create rejects zero memory" {
41112
test "destroy rejects zero id" {
42113
try std.testing.expectEqual(@as(i32, -1), aerie_destroy_env(0));
43114
}
115+
116+
test "boj_cartridge_name returns aerie-mcp" {
117+
const n = std.mem.span(boj_cartridge_name());
118+
try std.testing.expectEqualStrings("aerie-mcp", n);
119+
}
120+
121+
test "boj_cartridge_version returns semver" {
122+
const v = std.mem.span(boj_cartridge_version());
123+
try std.testing.expectEqualStrings("0.1.0", v);
124+
}
125+
126+
test "boj_cartridge_init returns 0" {
127+
try std.testing.expectEqual(@as(c_int, 0), boj_cartridge_init());
128+
}
129+
130+
test "invoke unknown tool returns -1" {
131+
var buf: [256]u8 = undefined;
132+
var len: usize = buf.len;
133+
const rc = boj_cartridge_invoke("nope", "{}", &buf, &len);
134+
try std.testing.expectEqual(@as(i32, -1), rc);
135+
}
136+
137+
test "invoke known tool writes JSON and returns 0" {
138+
var buf: [256]u8 = undefined;
139+
var len: usize = buf.len;
140+
const rc = boj_cartridge_invoke("list_envs_count", "{}", &buf, &len);
141+
try std.testing.expectEqual(@as(i32, 0), rc);
142+
try std.testing.expect(std.mem.indexOf(u8, buf[0..len], "count") != null);
143+
}
144+
145+
test "invoke with too-small buffer returns -3 and sets required length" {
146+
var buf: [4]u8 = undefined;
147+
var len: usize = buf.len;
148+
const rc = boj_cartridge_invoke("list_envs_count", "{}", &buf, &len);
149+
try std.testing.expectEqual(@as(i32, -3), rc);
150+
try std.testing.expect(len > 4);
151+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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

Comments
 (0)