ADR 0004 — One Rust core, one C ABI, thin wrappers
Status: accepted (2026-08-28)
Context
Section titled “Context”Customers integrate on the robot in C++, Python, ROS 2 (rclcpp), Go, or by running a daemon. Every one of those needs the same transport, FEC, NAT traversal, QoS and signaling behaviour, and they must never drift.
Decision
Section titled “Decision”All protocol logic lives in Rust crates under packages/. seyd-ffi exposes a
C ABI (sdks/c/include/seyd.h, generated by cbindgen, SEYD_ABI_VERSION).
Every other form factor is a wrapper that contains no protocol logic:
sdks/cpp/seyd.hpp— header-only RAII over the C ABI.sdks/python— cffi (ABI mode) overlibseyd; wheels bundle the library.sdks/ros2/seyd_ros— rclcpp component over the C ABI.seydd— the daemon is itself a Rust binary onseyd-core, and its UDP publisher-control sink is one implementation of the sameon_requested_configcallback the library exposes.
Rules:
- If a wrapper contains a header parser, an FEC table, a NAT heuristic or a signaling message, stop and move it into a crate.
- Callbacks run on one Seyd thread and must not block;
seyd_push_*never blocks (single-slot per channel,SEYD_ERR_BACKPRESSUREis a hint). - The ABI is versioned; additions are appended, never reordered, until
SEYD_ABI_VERSIONis bumped.
The surface is sketched in PLAN.md §2.2 and frozen when seyd-ffi ships.
As built (2026-09-03)
Section titled “As built (2026-09-03)”seyd-ffi ships at SEYD_ABI_VERSION = 1. Two things were settled in the
building that the sketch left open:
- The lifecycle moved below the ABI. Rule 4 above said the daemon sits on
seyd-core, but the ~450 lines that turn a configuration into a running robot — sockets, discovery, certificate, endpoint, engine, signaling, lease renewal, cert rotation, network-change re-gather — lived inseydd’smain().seyd-ffineeds every line of it, and a copy would have missed the next longevity fix. It is nowseyd_core::agent::Agent, andseyddandseyd-ffiare both hosts of it. A host owns only what differs by form factor; anything that must be identical everywhere belongs toAgent. seyd_push_nalis not in ABI 1. The sketch had both the sub-frame and the whole-frame entry point, but the engine still packs per frame (PLAN.md §1.1 is Milestone B). Shipping apush_nalthat secretly buffered to a frame boundary would have promised a latency property the core does not yet have. Onlyseyd_push_frameexists; addingseyd_push_nalwhen the engine emits per FEC block is an append under rule 3, not a break.
Beyond the sketch, ABI 1 also carries seyd_channel_id (look a channel up by
name), seyd_session_count, seyd_counters, seyd_init_logging,
seyd_abi_version/seyd_version for the compatibility check every wrapper
makes at load, and seyd_last_error (thread-local) so a status code can be
explained. on_state/on_link_quality from the sketch are not implemented;
signaling reachability is reported through on_signal_state and link quality
is available through seyd_counters until a proper callback is designed.
Callbacks are delivered on one dedicated OS thread (not a runtime worker), so
rule 2’s “must not block” costs the caller event latency and never the media
path. Panics are caught at every entry point and turned into
SEYD_ERR_INTERNAL rather than unwinding into C.
Conformance is sdks/c/examples/abi-smoke.c and sdks/python/tests, which
assert the same properties from both sides of the boundary, plus the CI check
that sdks/c/include/seyd.h matches what cbindgen generates from the crate.
Consequences
Section titled “Consequences”- A single conformance suite (FEC/wire vectors, netem e2e, pilot smoke) covers every form factor.
- Adding a language is a few hundred lines, not a port.
- The Python SDK’s wheel matrix must build
libseydformanylinux_2_28x86_64 and aarch64; done withcrossin CI.