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 dated API
6//! versions (`phoxal_api::y2026_1`, …) 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 dated API version (D60).
14///
15/// Implemented only by the zero-variant `enum Api {}` that
16/// [`phoxal_api_tree!`] generates inside each version module. The [`ID`] is the
17/// dated module name (`"y2026_1"`) and is carried in bus metadata as
18/// informational provenance, never in the wire body or the topic key (D62).
19/// [`IS_PREVIEW`] records authoring lifecycle only; it has no wire effect.
20///
21/// [`ID`]: ApiVersion::ID
22/// [`IS_PREVIEW`]: ApiVersion::IS_PREVIEW
23/// [`phoxal_api_tree!`]: https://docs.rs/phoxal
24pub trait ApiVersion: 'static {
25 /// The dated API-version identifier, equal to the version module name, e.g.
26 /// `"y2026_1"`.
27 const ID: &'static str;
28 /// Whether this generated API version is still in the preview lifecycle.
29 ///
30 /// This is control-plane metadata only. It is not encoded in bus payloads,
31 /// topics, schema ids, or encoding strings.
32 const IS_PREVIEW: bool = false;
33}
34
35/// The semantic role a topic plays in its owning service's contract.
36///
37/// Every topic in a [`phoxal_api_tree!`] declares one of these (D63, plan #00).
38/// The role records *intent*, separate from the wire shape: a `Command` and a
39/// `State` topic are both pub/sub on the wire, but the owner subscribes a
40/// `Command` (it is the service's control input) and publishes a `State` (it is
41/// the service's telemetry output). `Query` is the request/response role.
42///
43/// The role drives the side branding (L1): the api tree's builders read it to pick
44/// each leaf's side-branded topic kind (`Publish`/`Subscribe`/`AskQuery`/`ServeQuery`),
45/// so taking the wrong side of a topic is a compile error. The role also rides
46/// alongside each generated contract body as a `ROLE` const; that const is not yet
47/// emitted by `emit-apis` (a later increment of plan #00).
48///
49/// [`phoxal_api_tree!`]: https://docs.rs/phoxal
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
51pub enum TopicRole {
52 /// A control input the owning service subscribes (e.g. `drive/target`).
53 Command,
54 /// A telemetry/output the owning service publishes (e.g. `drive/state`).
55 State,
56 /// A request/response topic the owning service answers (e.g. `map/submap`).
57 Query,
58}
59
60impl TopicRole {
61 /// The lowercase grammar keyword for this role (`"command"` / `"state"` /
62 /// `"query"`), matching how it is written in `phoxal_api_tree!`.
63 pub const fn as_str(self) -> &'static str {
64 match self {
65 TopicRole::Command => "command",
66 TopicRole::State => "state",
67 TopicRole::Query => "query",
68 }
69 }
70}
71
72/// A version-local wire body: a plain serde type bound to exactly one
73/// [`ApiVersion`] and one contract topic (D61/D1).
74///
75/// Every body declared inside a `phoxal_api_tree!` node gets a generated impl.
76/// Handles, `SetupContext` builders, and the `Service`/`Driver` derive assertions
77/// all key off [`Api`](ContractBody::Api)/[`TOPIC`](ContractBody::TOPIC), which is
78/// how a body from the wrong API version is rejected at compile time.
79///
80/// The serde encoding of an implementor *is* the wire payload; there is no version
81/// envelope (D62).
82///
83/// **Wire identity is the key, not a hash (D1).** The generation is folded into
84/// [`TOPIC`](ContractBody::TOPIC), so `y2026_1::drive::Target` and a
85/// hypothetically re-minted `y2026_2::drive::Target` publish on different Zenoh
86/// keys and physically cannot collide. There is therefore no `SCHEMA_ID`/`FAMILY`
87/// axis: two participants interoperate on a contract iff they use the exact same
88/// version-qualified name, which is enforced by the type system (`Api` bound) and
89/// realized on the wire by the key. A receiver's per-key Zenoh subscription is the
90/// whole fast-reject; the bus decode path validates only the codec.
91pub trait ContractBody:
92 serde::Serialize + serde::de::DeserializeOwned + Clone + Send + Sync + 'static
93{
94 /// The single API version this body belongs to. Two bodies from different
95 /// versions have different `Api`, so the type system keeps them apart.
96 type Api: ApiVersion;
97 /// The version-qualified type path this body's own generation module
98 /// places it at: the version module name, then the `::`-joined node path
99 /// (dynamic-node vars are topic params, never type-path segments), then
100 /// the PascalCase type leaf, e.g. `"y2026_1::drive::Target"` or
101 /// `"y2026_1::component::motor::Command"`. This is the contract's source
102 /// identity (D1) - two contracts interoperate iff they share this exact
103 /// name - as distinct from [`TOPIC`](ContractBody::TOPIC), the resolved
104 /// wire key derived from it. `NAME` is exactly the `"::"`-join of
105 /// [`GENERATION`](ContractBody::GENERATION) and
106 /// [`CONTRACT`](ContractBody::CONTRACT); it stays available for callers
107 /// that want the whole identity as one string (e.g. display), while
108 /// metadata recording splices the two split consts instead (coherence-gate
109 /// design doc §2 - a joined name is not machine-parseable without
110 /// assuming the generation naming scheme).
111 const NAME: &'static str;
112 /// This body's generation alone, e.g. `"y2026_1"` - equal to
113 /// `<Self::Api as ApiVersion>::ID`, but exposed directly on the body so a
114 /// metadata recorder (`#[derive(phoxal::Api)]`'s linker-section
115 /// splicing) can const-splice it without routing through `Self::Api`.
116 /// Split from [`CONTRACT`](ContractBody::CONTRACT) so consumers (the
117 /// coherence gate, catalog) record generation and contract as two
118 /// separate fields rather than parsing a joined name.
119 const GENERATION: &'static str;
120 /// This body's contract path within its own generation: the `::`-joined
121 /// node path (dynamic-node vars excluded, as with `NAME`) plus the
122 /// PascalCase type leaf, e.g. `"drive::Target"`. The **logical
123 /// contract** - stable across a generation bump - is this value alone;
124 /// pairing it with [`GENERATION`](ContractBody::GENERATION) recovers the
125 /// full version-qualified identity (`NAME`).
126 const CONTRACT: &'static str;
127 /// The generation-qualified wire key: the version module name, then the
128 /// `/`-joined node path plus the topic leaf, with each dynamic node
129 /// contributing a `{var}` placeholder, e.g. `"y2026_1/drive/state"` or
130 /// `"y2026_1/component/{instance}/motor/{capability}/command"`. The concrete
131 /// key is produced by the api-local `topic` builder, which fills the
132 /// placeholders.
133 const TOPIC: &'static str;
134}