Skip to content

ADR 0004 — One Rust core, one C ABI, thin wrappers

Status: accepted (2026-08-28)

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.

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) over libseyd; wheels bundle the library.
  • sdks/ros2/seyd_ros — rclcpp component over the C ABI.
  • seydd — the daemon is itself a Rust binary on seyd-core, and its UDP publisher-control sink is one implementation of the same on_requested_config callback the library exposes.

Rules:

  1. If a wrapper contains a header parser, an FEC table, a NAT heuristic or a signaling message, stop and move it into a crate.
  2. Callbacks run on one Seyd thread and must not block; seyd_push_* never blocks (single-slot per channel, SEYD_ERR_BACKPRESSURE is a hint).
  3. The ABI is versioned; additions are appended, never reordered, until SEYD_ABI_VERSION is bumped.

The surface is sketched in PLAN.md §2.2 and frozen when seyd-ffi ships.

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 in seydd’s main(). seyd-ffi needs every line of it, and a copy would have missed the next longevity fix. It is now seyd_core::agent::Agent, and seydd and seyd-ffi are both hosts of it. A host owns only what differs by form factor; anything that must be identical everywhere belongs to Agent.
  • seyd_push_nal is 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 a push_nal that secretly buffered to a frame boundary would have promised a latency property the core does not yet have. Only seyd_push_frame exists; adding seyd_push_nal when 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.

  • 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 libseyd for manylinux_2_28 x86_64 and aarch64; done with cross in CI.