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 modper contract node holding plain serde structs/enums and their [ContractBody] impls; - an api-local
topicbuilder rooted attopic::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.
A not-yet-promoted generation is authored as
preview version y2026_N extends ...; it still lives at the final module path
(phoxal_api::y2026_N) but is available only when the matching
preview-y2026_N Cargo feature is enabled. The generated module docs call out
the preview status, and [ApiVersion::IS_PREVIEW] records the lifecycle without
changing schema ids, topics, or wire bytes.
One API version per participant; per-contract compatibility 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 (D60). Across the graph, participants may mix generations:
compatibility is proven per contract by schema_id agreement (#16) - a
contract whose transitive wire shape is unchanged hashes identically across
generations, so its users keep interoperating.
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.