phoxal-api 0.21.3

Phoxal API contract tree: current v1 and preview v2 wire bodies + topic builders, generated by phoxal_api_tree! over the phoxal-bus ABI floor.
Documentation

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 mod per contract node holding plain serde structs/enums and their [ContractBody] impls;
  • an api-local topic builder rooted at topic::new().

v1 is the current production surface and may change in place while the pre-stability topology is simplified. v2 is the single preview surface for contracts that have not entered production. 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.

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.