The single API layer (D60/D61/D1).
This crate is the versioned 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::v1 as api;, so
phoxal_api::v1, phoxal_api::ApiVersion, and phoxal_api::ContractBody
are the canonical paths.
Stable and preview API versions
An API version is a conventional vN module generated by
[phoxal_api_tree!]. Each version module carries:
- a zero-variant marker
enum Api {}implementing [ApiVersion], whose [ApiVersion::ID] is the module name ("v1"or"v2"); - 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().
v1 is frozen. v2 is the single evolving preview surface: new and changed
contracts are edited in that module until the complete API is ready to
freeze. Preview work does not mint a new public version for each change.
v2 is authored as preview version v2 { … } and is available when the
preview-v2 Cargo feature is enabled. [ApiVersion::IS_PREVIEW] records the
lifecycle without changing topics or wire bytes. Once v2 is promoted it
becomes immutable; later breaking work starts in a new preview v3.
Per-field versions and per-contract identity
A participant declares its bus surface with a companion
#[derive(phoxal::Api)] handle struct.
Each handle field names its own version-qualified contract type, so one
participant may freely mix fields from modules such as v1 and
v2.
The derive records each field's resolved [ContractBody::VERSION] and
[ContractBody::CONTRACT] in the participant's embedded metadata and does
not declare a participant-wide API version.
Across the graph, compatibility is name identity (D1) - two participants
interoperate on a contract iff they use the exact same version-qualified name
(v1::drive::Target), which is real on the wire because the version
is folded into the key ([ContractBody::TOPIC]). There is no schema_id: a
stable contract type is immutable, so the name alone is the whole identity.
Plain serde wire bodies, provenance in metadata
A wire body is just its serde encoding - there is no {"v":…} envelope or any
other version tag inside the payload (D62). Identity lives entirely in the
Zenoh key (the version-qualified [ContractBody::TOPIC]); the bus metadata
alongside the encoded body carries only provenance (source + logical time) and
the codec that produced the bytes - never schema/family/version. Keeping
identity out of both the payload and the metadata means the body bytes for an
unchanged contract are identical across codecs, and a receiver's per-key
subscription is the whole fast-reject.
Topic
[ContractBody::TOPIC] is derived from the contract node's path in the tree,
never written by hand: the version, then the /-joined node path plus the
topic leaf, with each dynamic node contributing a {var} placeholder, e.g.
v1/component/{instance}/motor/{capability}/command. A fully static path
has a literal key (v1/drive/state). Folding the version into the key
(D1) is what makes two differently-versioned contracts physically distinct
Zenoh keys - they cannot collide, so there is no SCHEMA_ID/FAMILY needed to
disambiguate them.
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 version-qualified key v1/drive/state, and
api::topic::new().component("base").motor("left").command() fills the dynamic
segments to produce v1/component/base/motor/left/command. Because the
builder is generated from the same tree as 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.