# phoxal
A production-oriented framework for autonomous robots, shipped as one crate.
Phoxal gives a robot a small, strongly-typed core: a contract bus over Zenoh, version-qualified contracts, and a derive-based participant authoring model.
The framework owns the awkward parts - argument parsing, bus connection, scheduling, query serving, shutdown, and health - so the code you write is the robot's behavior, not its plumbing.
```toml
[dependencies]
phoxal = "=0.36.0"
```
## The authoring model
A participant is a `Config` struct, an `Api` struct of typed bus handles, a state struct, and one annotated inherent impl.
The `Api` struct declares the contracts it uses (as handle fields); the impl declares the lifecycle.
```rust,ignore
use phoxal::{api, prelude::*};
#[derive(serde::Deserialize, phoxal::Config)]
struct Config {
cruise_linear_x_mps: f32,
}
#[derive(phoxal::Api)]
struct Api {
state: Latest<api::drive::State>,
target: Publisher<api::drive::Target>,
}
#[phoxal::service(id = "avoid-obstacles")]
struct AvoidObstacles {
cruise_linear_x_mps: f32,
}
#[phoxal::behavior]
impl AvoidObstacles {
#[setup]
async fn setup(ctx: &mut SetupContext<Self>, config: Self::Config) -> Result<(Self, Self::Api)> {
Ok((
Self { cruise_linear_x_mps: config.cruise_linear_x_mps },
Self::Api {
state: ctx.latest(api::topic::new().drive().state()).await?,
target: ctx.publisher(api::topic::new().drive().target()).await?,
},
))
}
#[step(hz = 50)]
async fn step(&mut self, api: &mut Self::Api, step: StepContext) -> Result<()> {
// Read inputs and publish version-local bodies. `drive/target` is a
// command, so it carries no production instant at all; a state or
// measurement publish instead takes `&step`, which is the only proof
// this participant reached the instant it is stamping.
api.target.send(api::drive::Target {
linear_x_mps: self.cruise_linear_x_mps,
angular_z_radps: 0.0,
curvature_limit_radpm: None,
})?;
Ok(())
}
}
fn main() -> phoxal::Result<()> { phoxal::run::<AvoidObstacles>() }
```
Key rules the example shows:
- Import `phoxal::{api, prelude::*}`. The locked framework train selects the
complete concrete API behind the facade; official participants do not mix
revisions field by field.
- Handle fields use version-qualified bodies: `Publisher<api::drive::Target>`, `Latest<api::drive::State>`.
`#[derive(phoxal::Api)]` requires every field to name a `ContractBody`, so a non-contract type is a **compile error**.
- Topics are api-local: `api::topic::new().drive().state()`.
- The wire body is the plain payload, and contract identity lives only in the version-qualified topic key.
Bus metadata carries the codec and provenance - the producing process, its sample sequence, and the robot instant the content was produced at when the sample expresses robot time at all - but no API version or contract family.
Normal participants never open Zenoh - the runner opens the launch-selected bus profile before `#[setup]`.
- `config` is for **user participants only**.
Official participants take no `config` param and read the robot model through `ctx.robot()`.
The runner also owns the rest of the lifecycle: `#[step(hz = ...)]` is the
scheduled control loop, optional `#[reset]` clears prior simulation-execution
state, `#[server]` / `#[server_snapshot]` serve queries, and `#[shutdown]` runs
graceful park/stop/flush before the bus closes. `#[reset]` is serialized with
steps and exclusive servers and receives `ResetContext`. Clockless operator
input needs no marker to survive it: a command carries no production instant, so
it belongs to no timeline, and what bounds it is its `Lease` rather than the
world boundary.
Every process carries a `ProducerId` - minted by the supervisor and passed
through the launch contract, or minted by the process itself for an ad hoc run.
It rides bus metadata and forms the participant's exact Liveliness key,
`<robot-root>/liveliness/participants/<participant-id>/<producer-id>`.
Observers use that exact key for spawn readiness and aggregate all live
producers of a participant id when they need stable present/not-present state.
Because a fresh process is always a fresh producer whose sequence restarts at
zero, a restart is structurally distinguishable from a replay - there is no
sequence-reset rule to get wrong.
It measures handler duration/lateness/missed ticks plus the exact
version-qualified typed-bus buffers declared by the participant and emits one
bounded portable runtime-performance rollup per host-monotonic grid interval.
The setup-declared rows persist for the process lifetime; interval counters are
best-effort boundary samples rather than a transactional queue snapshot. A
participant writes no telemetry code, unscheduled participants report step
timing as not applicable, and no per-process CPU/RSS sampler is involved.
`#[phoxal::tool]` is intentionally outside the robot clock. Tools use managed
host/event loops and `LocalInstant` - the host's suspend-aware boot clock - for
cadence and freshness. They express no robot time: a tool's publications are
commands and diagnostics, which carry no production instant.
The macro rejects `#[step]`, the setup context exposes no clock, and
the tool process launch has no clock option. The normal embedding API likewise
accepts no clock argument; deterministic clock injection is restricted to typed
graph participants. Official tool sources are additionally checked against
importing or subscribing to simulation-clock surfaces; user-authored tools must
preserve the same boundary when using the privileged raw bus. A service that
consumes asynchronous tool input keeps a bounded latest value with
consumer-local monotonic arrival time and samples it at its own logical step.
## Modules
A participant depends on the `phoxal` facade.
| [`phoxal::api`] | Alias of the train-selected `phoxal_api::latest` concrete revision. `phoxal-api` retains concrete modules such as `v0_1` for compatibility adapters. |
| [`phoxal::prelude`] | Everything a participant author imports with `use phoxal::prelude::*;`: the handle types, `SetupContext`/`StepContext`, and `Result`. |
| [`phoxal::bus`] | Re-export of the `phoxal-bus` crate: the key scheme `<namespace>/robots/<robot-id>/<version-qualified-topic>`, the named-field MessagePack codec, provenance-only `BusMetadata`, and the body-typed handles `Publisher`/`Subscriber`/`Latest`/`Querier`. |
| [`phoxal::participant`] | The static metadata traits the macros target, `SetupContext`/`StepContext`/`ShutdownContext`, the clock (`RealClock`/`TestClock`) + scheduler, `ParticipantLaunch`, and the runner (`run` / `tokio::run`). |
| [`phoxal::model`] | Authored manifest schemas (`robot.yaml`, `structure.urdf`, `component.yaml`, …). |
## Authoring the API tree
The `phoxal_api` modules (in the `phoxal-api` crate) are generated by `phoxal_api_tree!` from a tree of **nested nodes**.
A node is `name { … }` (static) or `name(var) { … }` (dynamic, where `var` is a key segment filled at build time), nestable to any depth.
A node body holds any mix of `struct`/`enum` type declarations, `topic` declarations, and child nodes.
Each topic declares a **role**: `topic <leaf>: command <Body>;` (a control input the owning service subscribes), `topic <leaf>: state <Body>;` (telemetry the owning service publishes), or `topic <leaf>: query <Req> => <Resp>;` (request/response).
`command` and `state` are both pub/sub on the wire; the role selects the side brand of the generated builders (L1), so the public client builder (`api::topic::new()`) and the `internal` owner builder (`api::topic::internal::new(cap)`) return different branded topics (`Publish`/`Subscribe`) and taking the wrong side does not compile.
The owner builder additionally requires the runner-minted `OwnerCap` (L2): pass `ctx.owner_capability()`, so owning a topic is a deliberate, capability-gated opt-in rather than something that can happen by accident.
There are no key-template strings and no topic parameter lists; dynamism comes entirely from the `(var)` nodes on the path.
```rust,ignore
phoxal_api_tree! {
version v0_1 {
drive { // static node
struct Target { linear_x_mps: f32, angular_z_radps: f32 }
topic target: command Target; // key v0.1/drive/target
struct State { /* … */ }
topic state: state State; // owner-published telemetry
}
component(instance) { // literal "component" + var {instance}
motor(capability) { // literal "motor" + var {capability}
enum Command { Velocity(f32), Torque(f32), Stop }
topic command: command Command;
}
}
}
latest v0_1;
}
```
Everything a topic exposes is **derived from the node path** `n1 … nk` to its leaf (each `ni` has a `name` and an optional `var`):
- **Topic key** (`ContractBody::TOPIC`, version-qualified): the `v<major>_<minor>` module is the first segment; each node then emits `name`, or `name/{var}` for a dynamic node, joined by `/`, followed by `/<leaf>`.
So `drive` → `target` is `v0.1/drive/target`, and `component(instance)` → `motor(capability)` → `command` is `v0.1/component/{instance}/motor/{capability}/command`.
- **Module path / type location**: each node becomes a nested `pub mod name`, and a node's types + their `ContractBody` impls live in that module.
Variables never appear in the module path, so the body above is `phoxal_api::v0_1::component::motor::Command`.
- **Contract identity** (`ContractBody::NAME`): the version-qualified Rust path, e.g. `v0.1::drive::Target` or `v0.1::component::motor::Command`.
- **Builder**: `api::topic::new().n1(var?)…nk(var?).leaf()`.
A dynamic node's method takes its var as `impl Display` (`*` / `**` stay valid for subscribe); the leaf method yields the typed `Topic`.
So `api::topic::new().component("front_left").motor("drive").command()` builds the key `v0.1/component/front_left/motor/drive/command`.
Each topic node is **self-contained**: it declares its own request, response,
and state bodies, with **no `super::`** and no shared/common module. This is
required because one body has one `ContractBody` identity and therefore one
topic. The narrow exception is a plain, non-topic protocol value declared by a
parent node and referenced from children through an absolute crate path. For
example, `v0.1::tool::Cursor` carries the same retention position in the separate
`tool::log` and `tool::bus` protocols, while each child still owns its distinct
`SnapshotRequest`, `Snapshot`, and `Follow` topic bodies.
Because the node path disambiguates, topic-body names are path-local -
`component::imu::Sample` and `component::range::Sample` are distinct types that
may safely repeat field names. Duplicating an identical topic body across
sibling nodes is intentional, not a smell.
Published concrete revisions are immutable. A new revision uses one-parent
`extends`; unchanged definitions are materialized under the child's concrete
identity, while `replace` and `remove` make breaking deltas explicit. Exactly
one final `latest <revision>;` selects the facade for the train.
## Entrypoints
- Default (blocking): `fn main() -> phoxal::Result<()> { phoxal::run::<R>() }`.
- Advanced (async): `phoxal::tokio::run::<R>().await` for custom Tokio mains.
## Official service set
The platform participant set ships alongside this crate in the workspace `service/` tree (`drive`, `motion`, `navigation`, `localize`, `map`, …).
They are full official participants authored on exactly this surface, and they double as worked reference reading.
Neutral teaching examples live in `phoxal/examples/` (`runtime_control_loop`, `runtime_query_server`, `runtime_snapshot_server`, `runtime_async_entrypoint`).
The matching GitHub train also ships the official simulator controller binaries.
Release packaging requires those controllers to link their native simulator
runtime; metadata-only test builds are never published as runnable artifacts.
## Status
Pre-1.0, building in public.
There is no per-participant API version ceiling; the wire body is the plain payload and the version identity rides the version-qualified key.
Compatibility is checked at build/check time (compiled-in artifact metadata + `phoxal-cli check`), never by introspecting a running binary.
## License
AGPL-3.0-only.
A commercial license is available - see the repository.