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.
51///
52/// [`phoxal_api_tree!`]: https://docs.rs/phoxal
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
54pub enum TopicRole {
55 /// A control input the owning service subscribes (e.g. `drive/target`).
56 /// Expresses no robot time: a command is a request, not an observation.
57 Command,
58 /// State the owning service publishes at a logical step (e.g.
59 /// `drive/state`).
60 State,
61 /// A sensor observation the owning service publishes with a capture stamp
62 /// (e.g. `component/{instance}/imu/{capability}/sample`).
63 Measurement,
64 /// An output that describes the participant rather than the world (health,
65 /// logs, runtime evidence). Expresses no robot time.
66 Diagnostic,
67 /// A request/response topic the owning service answers (e.g. `map/submap`).
68 Query,
69}
70
71impl TopicRole {
72 /// The lowercase grammar keyword for this role, matching how it is written
73 /// in `phoxal_api_tree!`.
74 pub const fn as_str(self) -> &'static str {
75 match self {
76 TopicRole::Command => "command",
77 TopicRole::State => "state",
78 TopicRole::Measurement => "measurement",
79 TopicRole::Diagnostic => "diagnostic",
80 TopicRole::Query => "query",
81 }
82 }
83}
84
85/// Marker for a body whose topic role is [`TopicRole::State`].
86///
87/// Generated by `phoxal_api_tree!`; it is the bound on
88/// [`StatePublisher`](crate::handle::StatePublisher), whose only publish path
89/// requires a runner- or authority-minted step token.
90pub trait StateContract: ContractBody {}
91
92/// Marker for a body whose topic role is [`TopicRole::Measurement`].
93///
94/// Generated by `phoxal_api_tree!`; it is the bound on
95/// [`MeasurementPublisher`](crate::handle::MeasurementPublisher), whose publish
96/// path requires a capture stamp the driver derived from its device clock.
97pub trait MeasurementContract: ContractBody {}
98
99/// Marker for a body whose topic role is [`TopicRole::Command`].
100///
101/// Generated by `phoxal_api_tree!`; it is the bound on
102/// [`CommandPublisher`](crate::handle::CommandPublisher), which expresses no
103/// robot time at all.
104pub trait CommandContract: ContractBody {}
105
106/// Marker for a body whose topic role is [`TopicRole::Diagnostic`].
107///
108/// Generated by `phoxal_api_tree!`; it is the bound on
109/// [`DiagnosticPublisher`](crate::handle::DiagnosticPublisher), which expresses
110/// no robot time at all.
111pub trait DiagnosticContract: ContractBody {}
112
113/// A version-local wire body: a plain serde type bound to exactly one
114/// [`ApiVersion`] and one contract topic (D61/D1).
115///
116/// Every body declared inside a `phoxal_api_tree!` node gets a generated impl.
117/// Each body carries its own [`Api`](ContractBody::Api) version marker and
118/// version-qualified [`TOPIC`](ContractBody::TOPIC).
119/// Participant `Api` derives record contract bodies field by field, and setup
120/// builders require the requested handle body to be declared by that struct.
121///
122/// The serde encoding of an implementor *is* the wire payload; there is no version
123/// envelope (D62).
124///
125/// **Wire identity is the key, not a hash (D1).** The version is folded into
126/// [`TOPIC`](ContractBody::TOPIC), so `v0.1::drive::Target` and a
127/// hypothetically re-minted `v0.2::drive::Target` publish on different Zenoh
128/// keys and physically cannot collide. There is therefore no `SCHEMA_ID`/`FAMILY`
129/// axis: two participants interoperate on a contract iff they use the exact same
130/// version-qualified name, which is realized on the wire by the key.
131/// A receiver's per-key Zenoh subscription is the
132/// whole fast-reject; the bus decode path validates only the codec.
133pub trait ContractBody:
134 serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static
135{
136 /// The single API version this body belongs to. Two bodies from different
137 /// versions have different `Api`, so the type system keeps them apart.
138 type Api: ApiVersion;
139 /// The version-qualified type identity: the dotted wire revision, then the `::`-joined node path
140 /// (dynamic-node vars are topic params, never type-path segments), then
141 /// the PascalCase type leaf, e.g. `"v0.1::drive::Target"` or
142 /// `"v0.1::component::motor::Command"`. This is the contract's source
143 /// identity (D1) - two contracts interoperate iff they share this exact
144 /// name - as distinct from [`TOPIC`](ContractBody::TOPIC), the resolved
145 /// wire key derived from it. `NAME` is exactly the `"::"`-join of
146 /// [`VERSION`](ContractBody::VERSION) and
147 /// [`CONTRACT`](ContractBody::CONTRACT); it stays available for callers
148 /// that want the whole identity as one string (e.g. display), while
149 /// metadata recording splices the two split consts instead (coherence-gate
150 /// design doc §2 - a joined name is not machine-parseable without
151 /// assuming the version naming scheme).
152 const NAME: &'static str;
153 /// This body's dotted wire revision alone, e.g. `"v0.1"` - equal to
154 /// `<Self::Api as ApiVersion>::ID`, but exposed directly on the body so a
155 /// metadata recorder (`#[derive(phoxal::Api)]`'s linker-section
156 /// splicing) can const-splice it without routing through `Self::Api`.
157 /// Split from [`CONTRACT`](ContractBody::CONTRACT) so consumers (the
158 /// coherence gate and suite generator) record revision and contract as two
159 /// separate fields rather than parsing a joined name.
160 const VERSION: &'static str;
161 /// This body's contract path within its own version: the `::`-joined
162 /// node path (dynamic-node vars excluded, as with `NAME`) plus the
163 /// PascalCase type leaf, e.g. `"drive::Target"`. The **logical
164 /// contract** - stable across a version bump - is this value alone;
165 /// pairing it with [`VERSION`](ContractBody::VERSION) recovers the
166 /// full version-qualified identity (`NAME`).
167 const CONTRACT: &'static str;
168 /// The version-qualified wire key: the dotted wire revision, then the
169 /// `/`-joined node path plus the topic leaf, with each dynamic node
170 /// contributing a `{var}` placeholder, e.g. `"v0.1/drive/state"` or
171 /// `"v0.1/component/{instance}/motor/{capability}/command"`. The concrete
172 /// key is produced by the api-local `topic` builder, which fills the
173 /// placeholders.
174 const TOPIC: &'static str;
175 /// The topic role this body was declared with, which fixes both the side
176 /// brand and the robot time a publisher of it can express.
177 const ROLE: TopicRole;
178}