phoxal-api 0.19.2

Phoxal API contract tree: the dated API-version modules (y2026_1, …) of version-local wire bodies + topic builders, generated by phoxal_api_tree! over the phoxal-bus ABI floor.
Documentation

The single API layer (D60/D61).

This crate is the dated API contract tree. It depends only on the phoxal-bus ABI floor (the contract primitive traits and the typed-topic builders) and the phoxal-macros proc-macros; it does not depend on the phoxal engine. A participant depends on both crates and imports the API tree directly with use phoxal_api::y2026_1 as api;, so phoxal_api::y2026_1, phoxal_api::ApiVersion, and phoxal_api::ContractBody are the canonical paths.

Dated API versions

An API version is a dated module (phoxal_api::y2026_1, …) generated by [phoxal_api_tree!]. Each version module carries:

  • a zero-variant marker enum Api {} implementing [ApiVersion], whose [ApiVersion::ID] is the dated module name ("y2026_1");
  • the version-local wire bodies, one pub mod per contract node holding plain serde structs/enums and their [ContractBody] impls;
  • an api-local topic builder rooted at topic::new().

Versions can build on earlier ones with version y2026_2 extends y2026_1: every inherited node and type is re-emitted fresh under the new module - same [ContractBody::FAMILY] and [ContractBody::TOPIC], a different Api - so an unchanged contract stays wire-identical while the compile-time Api bound stays distinct. See [phoxal_api_tree!] for the inheritance and shape-identity rules.

One API version per participant, one per graph

A participant authors against exactly one version module (use phoxal_api::y2026_1 as api;) and declares it on the derive (#[phoxal(api = y2026_1)]). Every handle body is bound ContractBody<Api = R::Api>, so passing a body from another API version is a compile error (D59/D60). At deploy time a single graph runs a single API version: all of its participants share one Api, so contracts on the bus are mutually consistent by construction (D59).

Plain serde wire bodies, schema in metadata

A wire body is just its serde encoding - there is no {"v":…} envelope or any other version tag inside the payload (D62). The four pieces of routing identity travel as bus metadata alongside the encoded body, not in it:

  • the schema id ([ContractBody::SCHEMA_ID]), the compatibility key,
  • the version ([ApiVersion::ID], e.g. "y2026_1"), informational,
  • the family ([ContractBody::FAMILY], e.g. "drive::State"),
  • the codec that produced the bytes.

Keeping these out of the payload means the body bytes for an unchanged contract are identical across versions and codecs; the envelope/metadata layer owns schema id, version, and family.

Family and topic

Both identifiers are derived from the contract node's path in the tree, never written by hand:

  • [ContractBody::FAMILY] is the ::-joined node path plus the body type name, e.g. component::motor::Command. It excludes dynamic variables, so every instance of a per-instance node shares one family.
  • [ContractBody::TOPIC] is the versionless /-joined key with each dynamic node contributing a {var} placeholder, e.g. component/{instance}/motor/{capability}/command. A fully static path has a literal key (drive/state).

The api-local topic builder

Each version module exposes a topic builder that mirrors the node tree: api::topic::new() returns a root, one method per top-level node walks down the tree, a dynamic node's method takes its variable as impl Display, and a leaf method binds the topic's side-branded kind to its version-local body. For example api::topic::new().drive().state() yields a Topic<Subscribe<drive::State>> (the CLIENT observes the owner's state) over the static key drive/state, and api::topic::new().component("base").motor("left").command() fills the dynamic segments to produce component/base/motor/left/command. Because the builder is generated from the same tree as FAMILY/TOPIC, the built key and the documented key stay in lockstep.

Owner side: topic::internal

The PUBLIC topic::new()... chain above is the client side. The matching owner side lives at api::topic::internal::new(cap)... (L1 + L2, plan #00): the same node tree and keys, but the leaf brands flip so the owner gets the side it must take - api::topic::internal::new(cap).drive().state() is Topic<Publish<drive::State>> (the owner publishes its telemetry), and api::topic::internal::new(cap).drive().target() is Topic<Subscribe<drive::Target>> (the owner reads its command input). A query owner reaches its ServeQuery brand the same way. The internal chain is the deliberate, greppable owner opt-in; a participant acquires the topics of its OWN node through it and everything it consumes through the public chain.

The internal::new entry requires the runner-minted owner capability (OwnerCap, Layer 2): a participant obtains it from phoxal::SetupContext::owner_capability() and passes it in. On the documented surface, owning a topic therefore cannot happen by accident - only with a capability the runner mints.