Skip to content

Commit 3077b4f

Browse files
feat(ffi/zig): formal ABI verification — comptime proofs + boundary tests (#247)
## Summary - **`ffi/zig/src/abi_axioms.zig`** — standalone file (no imports) declaring every ABI-crossing numeric constant as `comptime_int` and proving invariants via `@compileError`. Pure compile-time step: `zig build abi-axioms`. - **`ffi/zig/src/abi_verify.zig`** — 40 tests: §A comptime cross-module proofs (live Zig types vs axioms), §B–E exhaustive C-ABI boundary tests (catalogue, invocation shim, hash constants, safety), §F thread-safety spot check. Run with `zig build abi-verify`. - **Zig 0.16 mutex migration** — `std.Thread.Mutex` was removed; 9 FFI modules patched with a local `Mutex` wrapper over `std.atomic.Mutex` + `spinLoopHint()`. Call sites unchanged. - **`docs/zig-ffi-verification.adoc`** — documents the verification methodology, evidence taxonomy ([STATIC]/[CROSS]/[RUNTIME]/[ASSUMED]), trust chain (Idris2 → axioms → comptime → tests), known gaps, and how to run. ### Verification results ``` zig build abi-axioms abi-verify Build Summary: 6/6 steps succeeded; 40/40 tests passed ``` ### Trust taxonomy | Tag | Meaning | |---|---| | [STATIC] | `comptime` in same file — binary cannot violate it | | [CROSS] | `comptime` cross-module in `abi_verify.zig` — fires at compile time | | [RUNTIME] | Exhaustive boundary test at run time | | [ASSUMED] | Cross-language contract (Idris2↔Zig) underprovable by either tool alone | ### Known gaps (documented in §A.6 / docs) - **Loader import conflict**: `loader.zig` uses `@import("catalogue.zig")` (relative path), preventing it from being imported alongside `catalogue` in `abi_verify.zig`. Fix: change to `@import("catalogue")`. - **C header alignment**: `generated/abi/boj_catalogue.h` `#define` values are not auto-checked against axioms. Manual cross-check confirms parity at time of writing. ## Test plan - [ ] `zig build abi-axioms` — compile-time proofs green - [ ] `zig build abi-verify` — 40/40 tests pass - [ ] `zig build seams shim` — existing tests unaffected (38/38 pass) - [ ] Review `docs/zig-ffi-verification.adoc` for accuracy 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01F8pqMfJViUaKabWKNQ9wUg --- _Generated by [Claude Code](https://claude.ai/code/session_01F8pqMfJViUaKabWKNQ9wUg)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40f145a commit 3077b4f

13 files changed

Lines changed: 1630 additions & 9 deletions

docs/zig-ffi-verification.adoc

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
// SPDX-License-Identifier: CC-BY-SA-4.0
2+
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
4+
5+
= Zig FFI Formal Verification
6+
:toc:
7+
:toclevels: 3
8+
:sectnums:
9+
10+
== Overview
11+
12+
This document describes the formal verification approach for the BoJ Server Zig FFI
13+
layer. No standard Zig verifier exists; instead the methodology exploits Zig's
14+
`comptime` evaluation as a sound proof mechanism: any `comptime {}` block that reaches
15+
`@compileError` is a *compile-time theorem* — the binary cannot be built unless the
16+
theorem holds.
17+
18+
=== Trust Chain
19+
20+
----
21+
Idris2 proofs (src/abi/)
22+
│ [ASSUMED] cross-language ABI contract (unverifiable by either tool)
23+
24+
abi_axioms.zig — compile-time theorems about every ABI constant
25+
│ [CROSS] comptime cross-module assertions in abi_verify.zig
26+
27+
abi_verify.zig — live Zig enum/constant values match the axioms
28+
│ [RUNTIME] exhaustive boundary tests at every C-ABI entry point
29+
30+
catalogue.zig, safety.zig, cartridge_shim.zig — the live FFI layer
31+
----
32+
33+
=== Evidence Taxonomy
34+
35+
Every claim in this verification is tagged with one of four evidence levels:
36+
37+
[cols="1,4",options="header"]
38+
|===
39+
| Tag | Meaning
40+
41+
| [STATIC]
42+
| `comptime` assertion in the *same* file. A compiling binary cannot violate it.
43+
44+
| [CROSS]
45+
| `comptime` assertion in `abi_verify.zig` that imports a live module and
46+
compares its value against the axiom. Fires at compile time of the test binary;
47+
a build failure here means the live module and the axiom have diverged.
48+
49+
| [RUNTIME]
50+
| Exhaustive boundary test exercised at run time. Proves the C-ABI function
51+
*behaves* as specified, not just that types align.
52+
53+
| [ASSUMED]
54+
| Cross-language invariant that neither Zig nor Idris2 can prove in isolation
55+
(e.g., layout agreement between the C header and both implementations).
56+
Documented as an axiom; any violation would be discovered by runtime tests or
57+
by a mismatch caught in CI.
58+
|===
59+
60+
== Instruments
61+
62+
=== `ffi/zig/src/abi_axioms.zig`
63+
64+
A *standalone* file with no imports. Every ABI-relevant numeric constant is
65+
declared here as an untyped `comptime_int`, enabling universal coercion across
66+
types without casting. Each section follows its constant declarations with one
67+
or more `comptime {}` blocks that call `@compileError` on any violation.
68+
69+
This file is the *single source of truth* for all ABI numeric values visible to
70+
the Zig layer.
71+
72+
==== Sections
73+
74+
[cols="1,3",options="header"]
75+
|===
76+
| Section | Contents
77+
78+
| §1 Invocation ABI
79+
| `RC_*` return codes (ADR-0006): `RC_SUCCESS=0`, `RC_UNKNOWN_TOOL=-1`, ...,
80+
`RC_AUTH_DENIED=-6`. Theorems ax.1.1–ax.1.4: success is zero, all errors are
81+
strictly negative, the range is dense in `[-6,-1]`, and `RC_CODE_COUNT` is
82+
consistent.
83+
84+
| §2 Catalogue ABI
85+
| Status codes, protocol range, domain range, tier values, capacity limits,
86+
buffer bounds, and catalogue return codes. Critical theorem ax.2.2:
87+
`MOUNT_GATE_STATUS == STATUS_READY`, i.e., only `STATUS_READY` cartridges
88+
may be mounted. Theorem ax.2.7: `CAT_OK == RC_SUCCESS` (both are 0).
89+
90+
| §3 Loader ABI
91+
| `HASH_LEN=32`, `HASH_HEX_LEN=64` (SHA-256 hex digest), path limits, and
92+
loader return codes. Critical theorem ax.3.2: `LOADER_MATCH=1 ≠ CAT_OK=0`
93+
— the loader's "match" result must *not* be conflated with "success" in
94+
catalogue conventions.
95+
96+
| §4 Safety ABI
97+
| `SAFETY_SAFE=1`, rejection codes `[-8,-1]`, buffer size limits.
98+
Theorem ax.4.6: `SAFETY_SAFE=1 ≠ RC_SUCCESS=0` — a "safe" result must not
99+
be conflated with "success" in the invocation convention.
100+
|===
101+
102+
==== Running
103+
104+
----
105+
zig build abi-axioms
106+
----
107+
108+
A successful build proves every `comptime` theorem in the file holds. There are
109+
no runtime tests in this step; it is purely a compile-time proof.
110+
111+
=== `ffi/zig/src/abi_verify.zig`
112+
113+
Cross-checks the *live* Zig modules against the axioms, then exercises every
114+
C-ABI entry point exhaustively at the boundaries declared in `abi_axioms.zig`.
115+
116+
==== Section A — Comptime Cross-Module Proofs [CROSS]
117+
118+
Each sub-section imports a live module and checks that its enum integer values
119+
equal the corresponding axiom constants via `@compileError`:
120+
121+
[cols="1,4",options="header"]
122+
|===
123+
| Sub-section | Live module | Claims verified
124+
125+
| A.1 | `catalogue` | `CartridgeStatus` enum: `.development=0`, `.ready=1`,
126+
`.deprecated=2`, `.faulty=3` match `STATUS_*` axioms.
127+
128+
| A.2 | `catalogue` | `ProtocolType` enum: MCP=1 through REST=9 match `PROTO_*`
129+
axioms. Count theorem: exactly 9 protocols.
130+
131+
| A.3 | `catalogue` | `CapabilityDomain` enum: spot-checks on CLOUD=1, FLEET=12,
132+
NESY=13.
133+
134+
| A.4 | `catalogue` | `MenuTier` enum: TERANGA=0, SHIELD=1, AYO=2 match
135+
`TIER_*` axioms.
136+
137+
| A.5 | `cartridge_shim` | `RC_*` constants in the shim match §1 axioms.
138+
139+
| A.6 | _(gap)_ | `loader.zig` cannot be imported as a named module because it
140+
uses `@import("catalogue.zig")` (relative path), which conflicts with the named
141+
`catalogue` module in a shared compilation. `HASH_LEN` / `HASH_HEX_LEN`
142+
coverage is instead provided by §D axiom-level tests. *Fix:* update
143+
`loader.zig` to `const catalogue = @import("catalogue");` (named module import).
144+
145+
| A.7 | `safety` | `SafetyError` enum: `shell_injection=-1`, `json_unsafe=-8`
146+
match `SAFETY_*` axioms.
147+
|===
148+
149+
==== Section B — Catalogue Boundary Tests [RUNTIME]
150+
151+
Exhaustive black-box tests via `catalogue`'s `pub export fn` symbols:
152+
153+
[cols="1,3",options="header"]
154+
|===
155+
| Test | What it proves
156+
157+
| B.1 | `boj_catalogue_init()` returns `CAT_OK=0`; state is clean.
158+
159+
| B.2 | `boj_catalogue_deinit()` is idempotent.
160+
161+
| B.3 | Registering exactly `MAX_CARTRIDGES=128` entries succeeds; the 129th
162+
returns `CAT_ERR=-1`.
163+
164+
| B.4 | `boj_catalogue_mount()` succeeds only for `STATUS_READY` cartridges;
165+
returns `-1` for development/deprecated/faulty. Directly exercises the
166+
mount-gate invariant (ax.2.2).
167+
168+
| B.5/B.6 | Name boundary: 64-byte name accepted; 65-byte name rejected.
169+
170+
| B.7/B.8 | Version boundary: 16-byte version accepted; 17-byte version rejected.
171+
172+
| B.9–B.11 | Mount/unmount/is-mounted return conventions.
173+
174+
| B.12 | `MAX_ORDER_SIZE=16` slot boundary.
175+
176+
| B.13/B.14 | Backend string boundary: 32 bytes accepted; 33 bytes rejected.
177+
178+
| B.15 | `boj_catalogue_version()` returns a non-null, non-empty string.
179+
|===
180+
181+
==== Section C — Invocation ABI Tests [RUNTIME]
182+
183+
Tests via `cartridge_shim`'s public Zig functions:
184+
185+
* `RC_SUCCESS=0` on successful invocation.
186+
* `RC_UNKNOWN_TOOL=-1` for an unknown tool name.
187+
* `RC_BAD_ARGS=-2` for a malformed argument buffer.
188+
* `RC_BUFFER_TOO_SMALL=-3` when the output buffer is undersized.
189+
* `RC_RUNTIME_ERROR=-4`, `RC_PANIC=-5`, `RC_AUTH_DENIED=-6`.
190+
* Two-phase call pattern (C.8): probe with zero-length buffer to discover
191+
required size, then allocate and call with the real buffer.
192+
193+
==== Section D — Hash Constant Boundary Tests [RUNTIME]
194+
195+
Axiom-level verification that does not import `loader.zig` (see §A.6 GAP):
196+
197+
* `HASH_HEX_LEN == HASH_LEN * 2` (SHA-256 hex digest size relationship).
198+
* `LOADER_MATCH=1 ≠ CAT_OK=0` (trap: loader's "match" must not be confused with
199+
catalogue "ok").
200+
201+
==== Section E — Safety Boundary Tests [RUNTIME]
202+
203+
Tests via `extern fn` declarations (because `safety.zig` exports its functions
204+
without `pub`, so they are only accessible via C linkage):
205+
206+
----
207+
extern fn boj_safety_check_shell_arg(ptr: [*]const u8, len: usize) c_int;
208+
extern fn boj_safety_check_sql_value(ptr: [*]const u8, len: usize) c_int;
209+
extern fn boj_safety_check_path(ptr: [*]const u8, len: usize) c_int;
210+
extern fn boj_safety_check_url_scheme(ptr: [*]const u8, len: usize) c_int;
211+
extern fn boj_safety_check_json_string(ptr: [*]const u8, len: usize) c_int;
212+
----
213+
214+
Boundary exercises (E.2/E.3): `MAX_SHELL_ARG=4096` bytes is accepted;
215+
4097 bytes is rejected.
216+
217+
==== Section F — Thread-Safety Spot Check [RUNTIME]
218+
219+
Eight threads × 4 catalogue operations each (32 total concurrent operations).
220+
Verifies that the module-level `Mutex` wrapper provides mutual exclusion: no
221+
race conditions or assertion failures under concurrent load.
222+
223+
==== Running
224+
225+
----
226+
zig build abi-verify
227+
----
228+
229+
All 40 tests must pass. A failure indicates either a live-module value has
230+
diverged from an axiom (§A, compile-time), or a C-ABI boundary contract has
231+
been violated (§B–§F, runtime).
232+
233+
== Known Gaps and Assumptions
234+
235+
=== [ASSUMED] C Header / Zig Alignment
236+
237+
The `generated/abi/boj_catalogue.h` constants (e.g., `BOJ_STATUS_READY=1`,
238+
`BOJ_PROTO_MCP=1`) are declared to match the Idris2 ABI but are not
239+
automatically cross-checked against `abi_axioms.zig`. A divergence between
240+
the C header and the Zig axioms would go undetected until runtime.
241+
242+
*Mitigation:* The axioms and the C header constants were cross-checked manually
243+
at the time of writing. Future: add a CI step that extracts the `#define` values
244+
from the header and diffs them against the axiom constants.
245+
246+
=== [ASSUMED] Idris2 / Zig Integer Layout Agreement
247+
248+
The Idris2 `statusToInt`, `protocolToInt`, and `domainToInt` functions produce
249+
values that this layer *assumes* match the `STATUS_*`, `PROTO_*`, and
250+
`DOMAIN_*` axioms. This is a cross-language boundary that neither side can
251+
prove without a shared proof obligation.
252+
253+
=== [GAP] Loader Module Import Conflict (§A.6)
254+
255+
`loader.zig` uses `@import("catalogue.zig")` (relative path), which prevents
256+
it from being imported as a named module alongside `catalogue` in the same
257+
compilation unit. The `HASH_LEN` / `HASH_HEX_LEN` constants in `loader.zig`
258+
are therefore verified only at the axiom level (§D), not by cross-module
259+
comptime assertion.
260+
261+
*Fix:* Change `loader.zig` line 181 from `@import("catalogue.zig")` to
262+
`@import("catalogue")`. Once applied, add `abi_verify_mod.addImport("loader", loader_mod)`
263+
in `build.zig` and a §A.6 cross-module check in `abi_verify.zig`.
264+
265+
=== [ASSUMED] Safety Private Constants
266+
267+
`MAX_SHELL_ARG_LEN` and `MAX_PATH_LEN` in `safety.zig` are private (not `pub`).
268+
The axioms declare `MAX_SHELL_ARG=4096` and `MAX_PATH=4096` as assumed values;
269+
if `safety.zig` is changed, the axioms will not detect the divergence.
270+
271+
*Mitigation:* §E tests exercise the boundary directly, so a size change in
272+
`safety.zig` will cause E.2/E.3 to fail.
273+
274+
== Zig 0.16 Mutex Migration
275+
276+
`std.Thread.Mutex` was removed in Zig 0.16 as part of the `Io`-based async
277+
redesign. The replacement (`std.Io.Mutex`) requires an `Io` context for every
278+
`lock()` call, which is incompatible with synchronous C-ABI exports.
279+
280+
The nine affected FFI modules (`catalogue.zig`, `loader.zig`, `sla.zig`,
281+
`coprocessor.zig`, `community.zig`, `sdp.zig`, `guardian.zig`, `verisimdb.zig`,
282+
`federation.zig`) were migrated to a local `Mutex` wrapper:
283+
284+
----
285+
const Mutex = struct {
286+
state: std.atomic.Mutex = .unlocked,
287+
pub fn lock(m: *Mutex) void {
288+
while (!m.state.tryLock()) std.atomic.spinLoopHint();
289+
}
290+
pub fn unlock(m: *Mutex) void {
291+
m.state.unlock();
292+
}
293+
};
294+
----
295+
296+
`std.atomic.Mutex` is a compare-and-swap TAS lock; `spinLoopHint()` emits a
297+
CPU pause hint on x86-64 to reduce power consumption and memory bus traffic
298+
during contention. This is appropriate for the FFI layer's usage pattern:
299+
all critical sections are sub-microsecond struct field updates with very low
300+
contention probability.
301+
302+
If a blocking (sleep-based) OS mutex is needed in future, the recommended
303+
migration path on Linux is `std.c.pthread_mutex_t` with `link_libc = true`
304+
in each module's `build.zig` entry.
305+
306+
== How to Run All Verification Steps
307+
308+
----
309+
# Step 1: Compile-time ABI axiom proofs (pure comptime, no runtime)
310+
zig build abi-axioms
311+
312+
# Step 2: Cross-module proofs + exhaustive boundary tests (40 tests)
313+
zig build abi-verify
314+
315+
# Run both together
316+
zig build abi-axioms abi-verify
317+
----
318+
319+
Expected output:
320+
321+
----
322+
Build Summary: 6/6 steps succeeded; 40/40 tests passed
323+
abi-axioms success
324+
+- run test success
325+
abi-verify success
326+
+- run test 40 pass (40 total)
327+
----

ffi/zig/build.zig

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,54 @@ pub fn build(b: *std.Build) void {
308308
const shim_step = b.step("shim", "Run cartridge_shim tests (ADR-0006 invoke helpers)");
309309
shim_step.dependOn(&run_shim_tests.step);
310310

311+
// --- ABI Axioms module (standalone comptime proofs, no runtime deps) ---
312+
//
313+
// abi_axioms.zig declares every numeric constant that crosses the C-ABI
314+
// boundary and proves static invariants at compile time. Running the test
315+
// binary exercises those comptime blocks; a build failure here means an
316+
// invariant was violated.
317+
const abi_axioms_mod = b.addModule("boj_abi_axioms", .{
318+
.root_source_file = b.path("src/abi_axioms.zig"),
319+
.target = target,
320+
.optimize = optimize,
321+
});
322+
323+
const abi_axioms_tests = b.addTest(.{
324+
.root_module = abi_axioms_mod,
325+
});
326+
const run_abi_axioms_tests = b.addRunArtifact(abi_axioms_tests);
327+
328+
const abi_axioms_step = b.step("abi-axioms", "Compile ABI axiom static theorems (comptime proofs only)");
329+
abi_axioms_step.dependOn(&run_abi_axioms_tests.step);
330+
331+
// --- ABI Verification module (cross-checks axioms + exhaustive boundary tests) ---
332+
//
333+
// abi_verify.zig cross-checks that the live Zig types (enums, constants) match
334+
// the axiom declarations at compile time, then exhaustively exercises every
335+
// C-ABI boundary at runtime. This is the formal FFI verification instrument.
336+
const abi_verify_mod = b.addModule("boj_abi_verify", .{
337+
.root_source_file = b.path("src/abi_verify.zig"),
338+
.target = target,
339+
.optimize = optimize,
340+
});
341+
abi_verify_mod.addImport("catalogue", catalogue_mod);
342+
// loader is intentionally excluded: loader.zig imports catalogue.zig via relative
343+
// path, which conflicts with the named `catalogue` module in a shared compilation.
344+
// The loader's HASH_LEN/HASH_HEX_LEN constants are cross-checked via axioms alone.
345+
// See abi_verify.zig §A.6 GAP note.
346+
abi_verify_mod.addImport("safety", safety_mod);
347+
abi_verify_mod.addImport("cartridge_shim", shim_mod);
348+
// abi_axioms.zig is imported via relative path @import("abi_axioms.zig") in
349+
// abi_verify.zig — no addImport needed since both files share src/.
350+
351+
const abi_verify_tests = b.addTest(.{
352+
.root_module = abi_verify_mod,
353+
});
354+
const run_abi_verify_tests = b.addRunArtifact(abi_verify_tests);
355+
356+
const abi_verify_step = b.step("abi-verify", "Run ABI formal verification: comptime proofs + runtime boundary tests");
357+
abi_verify_step.dependOn(&run_abi_verify_tests.step);
358+
311359
// --- End-to-end order-ticket tests ---
312360
const e2e_mod = b.addModule("boj_e2e_order", .{
313361
.root_source_file = b.path("src/e2e_order.zig"),
@@ -375,6 +423,8 @@ pub fn build(b: *std.Build) void {
375423

376424
// --- Test step runs all ---
377425
const test_step = b.step("test", "Run all FFI + protocol tests");
426+
test_step.dependOn(&run_abi_axioms_tests.step);
427+
test_step.dependOn(&run_abi_verify_tests.step);
378428
test_step.dependOn(&run_catalogue_tests.step);
379429
test_step.dependOn(&run_loader_tests.step);
380430
test_step.dependOn(&run_readiness_tests.step);

0 commit comments

Comments
 (0)