Skip to main content

phoxal_bus/
contract.rs

1//! The contract primitive traits (D60/D61): the API-version marker and the
2//! version-local wire body.
3//!
4//! These are the two traits the bus client is generic over - the ABI floor every
5//! contract body and api-version marker implements. The concrete versioned API
6//! versions (`phoxal::api`, …) and the `phoxal_api_tree!` macro that
7//! generates their `ApiVersion` / `ContractBody` impls live in the `phoxal-api`
8//! crate, which re-exports these traits - so they are reachable as
9//! `phoxal_api::ApiVersion` / `phoxal_api::ContractBody`. The `phoxal` engine
10//! re-exports this bus crate at `phoxal::bus`, so they are also reachable as
11//! `phoxal::bus::ApiVersion` / `phoxal::bus::ContractBody`.
12
13/// Marker trait identifying one API version (D60).
14///
15/// Implemented only by the zero-variant `enum Api {}` that
16/// [`phoxal_api_tree!`] generates inside each revision module. The [`ID`] is the
17/// dotted wire revision (`"v0.1"`) and is carried in bus metadata as
18/// informational provenance, never in the wire body or the topic key (D62).
19///
20/// [`ID`]: ApiVersion::ID
21/// [`phoxal_api_tree!`]: https://docs.rs/phoxal
22pub trait ApiVersion: 'static {
23    /// The dotted wire-revision identifier, e.g. `"v0.1"` (the corresponding
24    /// Rust module is `v0_1`).
25    const ID: &'static str;
26}
27
28/// The semantic **and temporal** role a topic plays in its owning service's
29/// contract.
30///
31/// Every topic in a [`phoxal_api_tree!`] declares one of these (D63, plan #00).
32/// The role records *intent*, separate from the wire shape: a `Command` and a
33/// `State` topic are both pub/sub on the wire, but the owner subscribes a
34/// `Command` (it is the service's control input) and publishes a `State` (it is
35/// the service's telemetry output).
36///
37/// The role drives two things:
38///
39/// - the **side branding** (L1): the api tree's builders read it to pick each
40///   leaf's side-branded topic kind
41///   (`Publish`/`Subscribe`/`AskQuery`/`ServeQuery`), so taking the wrong side
42///   of a topic is a compile error;
43/// - the **temporal capability** (#952 section D): the role decides which robot
44///   time a publisher of that contract can express at all. A `State` is
45///   published at a logical step, a `Measurement` carries a capture stamp, and
46///   a `Command` or `Diagnostic` expresses no robot time. The generated body
47///   implements exactly one of [`StateContract`] / [`MeasurementContract`] /
48///   [`CommandContract`] / [`DiagnosticContract`], and each publisher handle is
49///   bounded by its own marker, so reaching for the wrong publisher is a
50///   compile error rather than a review question. One topic is the sole
51///   exception: the framework's own `world_clock`-role `simulation::Clock`
52///   wire-brands and reports `TopicRole::State` exactly like an ordinary state
53///   topic, but implements the disjoint [`WorldClockContract`] instead of
54///   `StateContract` - see that trait's docs for why.
55///
56/// [`phoxal_api_tree!`]: https://docs.rs/phoxal
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
58pub enum TopicRole {
59    /// A control input the owning service subscribes (e.g. `drive/target`).
60    /// Expresses no robot time: a command is a request, not an observation.
61    Command,
62    /// State the owning service publishes at a logical step (e.g.
63    /// `drive/state`).
64    State,
65    /// A sensor observation the owning service publishes with a capture stamp
66    /// (e.g. `component/{instance}/imu/{capability}/sample`).
67    Measurement,
68    /// An output that describes the participant rather than the world (health,
69    /// logs, runtime evidence). Expresses no robot time.
70    Diagnostic,
71    /// A request/response topic the owning service answers (e.g. `map/submap`).
72    Query,
73}
74
75impl TopicRole {
76    /// The lowercase grammar keyword for this role, matching how it is written
77    /// in `phoxal_api_tree!`.
78    pub const fn as_str(self) -> &'static str {
79        match self {
80            TopicRole::Command => "command",
81            TopicRole::State => "state",
82            TopicRole::Measurement => "measurement",
83            TopicRole::Diagnostic => "diagnostic",
84            TopicRole::Query => "query",
85        }
86    }
87}
88
89/// Marker for a body whose topic role is [`TopicRole::State`].
90///
91/// Generated by `phoxal_api_tree!` for every ordinary `state` topic. Deliberately
92/// NOT implemented for the framework's own world-clock contract
93/// (`phoxal::api::simulation::Clock`) - see [`WorldClockContract`] for why that
94/// exclusion is the enforcement mechanism, not an oversight.
95pub trait StateContract: ContractBody {}
96
97/// Marker for the framework's single world-clock contract
98/// (`phoxal::api::simulation::Clock`, generated by `phoxal_api_tree!`'s
99/// `world_clock` topic role).
100///
101/// Deliberately a SIBLING of [`StateContract`], not a subtrait of it: if the
102/// world clock also implemented `StateContract`, it would still satisfy the
103/// ordinary, unrestricted `state_publisher` builder every participant has,
104/// which would make "only a simulator can mint world steps" an unenforced
105/// convention rather than a compiler rule.
106/// Excluding it from `StateContract` is what makes that builder reject it at
107/// compile time, forcing every caller through the world-authority-gated
108/// `SetupContext::world_clock_publisher` in the `phoxal` crate
109/// instead (`Self: world-authority surface`).
110///
111/// Bounds [`WorldClockPublisher`](crate::handle::WorldClockPublisher), a
112/// dedicated handle type separate from
113/// [`StatePublisher`](crate::handle::StatePublisher) even though both publish
114/// at a logical step with the same [`StepStamp`](crate::handle::StepStamp)
115/// path: sharing one generic handle type across both traits would force
116/// `StatePublisher`'s bound onto a common supertrait, which would blur an
117/// ordinary participant's "wrong contract for `StatePublisher`" compile error
118/// (today a precise `B: StateContract` message with the real `state` topics
119/// listed as candidates) into a less legible one naming an internal plumbing
120/// trait instead. Two small handle types keep that diagnostic exact.
121pub trait WorldClockContract: ContractBody {}
122
123/// Marker for a body whose topic role is [`TopicRole::Measurement`].
124///
125/// Generated by `phoxal_api_tree!`; it is the bound on
126/// [`MeasurementPublisher`](crate::handle::MeasurementPublisher), whose publish
127/// path requires a capture stamp the driver derived from its device clock.
128pub trait MeasurementContract: ContractBody {}
129
130/// Marker for a body whose topic role is [`TopicRole::Command`].
131///
132/// Generated by `phoxal_api_tree!`; it is the bound on
133/// [`CommandPublisher`](crate::handle::CommandPublisher), which expresses no
134/// robot time at all.
135pub trait CommandContract: ContractBody {}
136
137/// Marker for a body whose topic role is [`TopicRole::Diagnostic`].
138///
139/// Generated by `phoxal_api_tree!`; it is the bound on
140/// [`DiagnosticPublisher`](crate::handle::DiagnosticPublisher), which expresses
141/// no robot time at all.
142pub trait DiagnosticContract: ContractBody {}
143
144/// A version-local wire body: a plain serde type bound to exactly one
145/// [`ApiVersion`] and one contract topic (D61/D1).
146///
147/// Every body declared inside a `phoxal_api_tree!` node gets a generated impl.
148/// Each body carries its own [`Api`](ContractBody::Api) version marker and
149/// version-qualified [`TOPIC`](ContractBody::TOPIC).
150/// Participant setup builders accept these bodies directly and preserve the
151/// contract type through each typed handle.
152///
153/// The serde encoding of an implementor *is* the wire payload; there is no version
154/// envelope (D62).
155///
156/// **Wire identity is the key, not a hash (D1).** The version is folded into
157/// [`TOPIC`](ContractBody::TOPIC), so `v0.1::drive::Target` and a
158/// hypothetically re-minted `v0.2::drive::Target` publish on different Zenoh
159/// keys and physically cannot collide. Two participants interoperate on a
160/// contract iff they use the exact same version-qualified name, which is
161/// realized on the wire by the key.
162/// A receiver's per-key Zenoh subscription is the
163/// whole fast-reject; the bus decode path validates only the codec.
164pub trait ContractBody:
165    serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static
166{
167    /// The single API version this body belongs to. Two bodies from different
168    /// versions have different `Api`, so the type system keeps them apart.
169    type Api: ApiVersion;
170    /// The version-qualified type identity: the dotted wire revision, then the `::`-joined node path
171    /// (dynamic-node vars are topic params, never type-path segments), then
172    /// the PascalCase type leaf, e.g. `"v0.1::drive::Target"` or
173    /// `"v0.1::component::motor::Command"`. This is the contract's source
174    /// identity (D1) - two contracts interoperate iff they share this exact
175    /// name - as distinct from [`TOPIC`](ContractBody::TOPIC), the resolved
176    /// wire key derived from it. `NAME` is exactly the `"::"`-join of
177    /// [`VERSION`](ContractBody::VERSION) and
178    /// [`CONTRACT`](ContractBody::CONTRACT); it stays available for callers
179    /// that want the whole identity as one string (e.g. display), while
180    /// consumers that must reason about the revision and the contract
181    /// independently use the two split consts instead - a joined name is not
182    /// machine-parseable without assuming the version naming scheme.
183    const NAME: &'static str;
184    /// This body's dotted wire revision alone, e.g. `"v0.1"` - equal to
185    /// `<Self::Api as ApiVersion>::ID`, but exposed directly on the body so a
186    /// metadata or diagnostics recorder can const-splice it without routing
187    /// through `Self::Api`.
188    /// Split from [`CONTRACT`](ContractBody::CONTRACT) so a consumer can read
189    /// the revision without parsing a joined name.
190    const VERSION: &'static str;
191    /// This body's contract path within its own version: the `::`-joined
192    /// node path (dynamic-node vars excluded, as with `NAME`) plus the
193    /// PascalCase type leaf, e.g. `"drive::Target"`. The **logical
194    /// contract** - stable across a version bump - is this value alone;
195    /// pairing it with [`VERSION`](ContractBody::VERSION) recovers the
196    /// full version-qualified identity (`NAME`).
197    const CONTRACT: &'static str;
198    /// The version-qualified wire key: the dotted wire revision, then the
199    /// `/`-joined node path plus the topic leaf, with each dynamic node
200    /// contributing a `{var}` placeholder, e.g. `"v0.1/drive/state"` or
201    /// `"v0.1/component/{instance}/motor/{capability}/command"`. The concrete
202    /// key is produced by the api-local `topic` builder, which fills the
203    /// placeholders.
204    const TOPIC: &'static str;
205    /// The topic role this body was declared with, which fixes both the side
206    /// brand and the robot time a publisher of it can express.
207    const ROLE: TopicRole;
208}