phoxal_bus/contract.rs
1//! The contract primitive traits: the API-version marker and endpoint
2//! descriptors.
3//!
4//! These are the two traits the bus client is generic over - the ABI floor every
5//! API revisions and process protocols use ordinary Rust payloads plus one
6//! generated descriptor per endpoint. This crate owns the shared ABI traits;
7//! `phoxal-api` and `phoxal-supervisor-api` own their concrete declarations.
8
9/// Marker trait identifying one generated contract tree.
10///
11/// Implemented only by the zero-variant `enum Api {}` that
12/// `phoxal_api_tree!` generates inside each tree module. The [`ID`] is the
13/// dotted wire revision (`"v0.1"`) for a robot API revision, and the protocol
14/// name (`"supervisor"`) for a `protocol` tree - in both cases the tree's
15/// identity and the leading segment of every key it declares. It is carried in
16/// bus metadata as informational provenance, never in the wire body.
17///
18/// The marker's job is the same in both modes: it keeps one tree's bodies from
19/// standing in for another's at compile time. `ParticipantSpec::ContractApi`
20/// pins a participant to exactly one of them.
21///
22/// [`ID`]: ApiVersion::ID
23pub trait ApiVersion: 'static {
24 /// The tree's wire identifier: a dotted revision such as `"v0.1"` (Rust
25 /// module `v0_1`), or a protocol name such as `"supervisor"`.
26 const ID: &'static str;
27}
28
29/// A plain serde payload carried by one bus endpoint.
30///
31/// Payloads deliberately contain no transport identity or delivery policy.
32/// Those facts belong to an [`EndpointDescriptor`], which is the type used by
33/// typed topics and handles. The blanket implementation keeps ordinary
34/// structs and enums frictionless: an author only derives serde for a payload
35/// and never has to repeat a topic, role, or queue policy on the payload type.
36pub trait Payload: serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static {}
37
38impl<T> Payload for T where T: serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static
39{}
40
41/// The minimal transport semantic family a contract requires.
42///
43/// Temporal stamping is intentionally separate: a `sample` may carry a device
44/// capture window while a `state` is stamped by the runner's logical step.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum DeliveryFamily {
47 /// Retain the newest observable snapshot.
48 State,
49 /// Preserve bounded ordered observations with explicit loss evidence.
50 Sample,
51 /// Retain only the newest actionable intent.
52 Setpoint,
53 /// Preserve ordered chunks and surface saturation/gaps.
54 Stream,
55 /// Bounded immediate lookup/admission.
56 Query,
57}
58
59/// The fixed semantic kind of an endpoint.
60///
61/// This is endpoint metadata, not payload metadata. The five pub/sub kinds
62/// intentionally have no user-selectable queue policy: their bus behavior is
63/// fixed by the kind. `Query` remains the bounded request/reply path rather
64/// than an outbound scheduler lane.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum EndpointKind {
67 /// A current state snapshot, stamped at a logical step.
68 State,
69 /// A captured observation, ordered with explicit bounded loss evidence.
70 Sample,
71 /// A state-temporal event, ordered and gap-observable.
72 Event,
73 /// An ordered stream chunk with refusal-preserving admission.
74 Stream,
75 /// A newest-actionable intent, coalesced before transport.
76 Setpoint,
77 /// A bounded request/reply endpoint.
78 Query,
79}
80
81impl EndpointKind {
82 /// The transport family fixed by this endpoint kind.
83 pub const fn delivery_family(self) -> DeliveryFamily {
84 match self {
85 Self::State => DeliveryFamily::State,
86 Self::Sample => DeliveryFamily::Sample,
87 Self::Event | Self::Stream => DeliveryFamily::Stream,
88 Self::Setpoint => DeliveryFamily::Setpoint,
89 Self::Query => DeliveryFamily::Query,
90 }
91 }
92}
93
94/// Endpoint-owned identity and semantic descriptor.
95///
96/// `Payload` is the only wire body. `TOPIC`, `KIND`, and the contract identity
97/// are owned by this separate descriptor type, so reusing one payload in two
98/// endpoints cannot silently reuse the first endpoint's transport behavior.
99/// The API tree generator emits one descriptor per endpoint and implements the
100/// semantic marker appropriate to [`EndpointDescriptor::KIND`].
101pub trait EndpointDescriptor: 'static {
102 /// The API tree or protocol this endpoint belongs to.
103 type Api: ApiVersion;
104 /// The plain serde payload carried by this endpoint.
105 type Payload: Payload;
106 /// Version-qualified endpoint identity.
107 const NAME: &'static str;
108 /// Endpoint tree identity, such as `"v0.1"` or `"supervisor"`.
109 const VERSION: &'static str;
110 /// Stable endpoint path within its tree.
111 const CONTRACT: &'static str;
112 /// Version-qualified concrete wire key template.
113 const TOPIC: &'static str;
114 /// Fixed semantic endpoint kind.
115 const KIND: EndpointKind;
116}
117
118/// Descriptor for a typed request/reply endpoint.
119///
120/// A semantic API endpoint owns both payload paths while remaining one
121/// zero-sized type in the generated API. Compatibility trees also generate
122/// this descriptor for query topics so typed request/reply handles have one
123/// stable shape during migration.
124pub trait QueryEndpointDescriptor: EndpointDescriptor {
125 /// Request payload decoded from the query.
126 type Request: Payload;
127 /// Response payload encoded in the reply.
128 type Response: Payload;
129}
130
131/// Short name for an endpoint descriptor used by typed bus APIs.
132pub trait Endpoint: EndpointDescriptor {}
133
134impl<T: EndpointDescriptor> Endpoint for T {}
135
136/// Marker for an endpoint whose temporal meaning is current state.
137///
138/// Generated by `phoxal_api_tree!` for every ordinary `State<T>` endpoint. Deliberately
139/// NOT implemented for the runtime-owned world-clock hand - see
140/// [`WorldClockContract`] for why that exclusion is the enforcement mechanism,
141/// not an oversight.
142pub trait StateContract: EndpointDescriptor {}
143
144/// Marker for the framework's single runtime-owned world-clock hand.
145///
146/// Deliberately a SIBLING of [`StateContract`], not a subtrait of it: if the
147/// world clock also implemented `StateContract`, it would still satisfy the
148/// ordinary, unrestricted `state_publisher` builder every participant has,
149/// which would make "only a simulator can mint world steps" an unenforced
150/// convention rather than a compiler rule.
151/// Excluding it from `StateContract` is what makes that builder reject it at
152/// compile time, forcing every caller through the world-authority-gated
153/// `SetupContext::world_clock_publisher` in the `phoxal` crate instead
154/// (`Self: world-authority surface`).
155///
156/// Bounds [`WorldClockPublisher`](crate::handle::publisher::WorldClockPublisher), a
157/// dedicated handle type separate from
158/// [`StatePublisher`](crate::handle::publisher::StatePublisher) even though both publish
159/// at a logical step with the same [`StepStamp`](crate::handle::stamp::StepStamp)
160/// path: sharing one generic handle type across both traits would force
161/// `StatePublisher`'s bound onto a common supertrait, which would blur an
162/// ordinary participant's "wrong contract for `StatePublisher`" compile error
163/// (today a precise `B: StateContract` message with the real `state` topics
164/// listed as candidates) into a less legible one naming an internal plumbing
165/// trait instead. Two small handle types keep that diagnostic exact.
166pub trait WorldClockContract: EndpointDescriptor {}
167
168/// Marker for an endpoint whose temporal meaning is an ordered stream chunk.
169pub trait StreamContract: EndpointDescriptor {}
170
171/// Marker for a state-temporal, ordered event endpoint.
172///
173/// Events use the stream transport's ordered/gap-visible behavior but are
174/// stamped like state at a logical step. Generated endpoint descriptors should
175/// implement this marker and `StreamDeliveryContract`; the payload remains a
176/// plain serde type.
177pub trait EventContract: EndpointDescriptor + StreamDeliveryContract {}
178
179/// Marker for the endpoint kind `Sample`.
180pub trait SampleContract: EndpointDescriptor {}
181
182/// Marker for the endpoint kind `Setpoint`.
183pub trait SetpointContract: EndpointDescriptor {}
184
185/// Marker for a contract whose transport retains the newest state snapshot.
186///
187/// This is deliberately independent from the temporal publisher marker above:
188/// a diagnostic or event can use state-temporal stamping while requiring an
189/// ordered transport family.
190pub trait StateDeliveryContract: EndpointDescriptor {}
191
192/// Marker for a contract whose transport preserves bounded ordered samples
193/// with explicit loss evidence.
194pub trait SampleDeliveryContract: EndpointDescriptor {}
195
196/// Marker for a contract whose transport retains only the newest actionable
197/// intent.
198pub trait SetpointDeliveryContract: EndpointDescriptor {}
199
200/// Marker for a contract whose transport preserves ordered chunks and surfaces
201/// saturation rather than evicting an older chunk.
202pub trait StreamDeliveryContract: EndpointDescriptor {}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
209 struct SharedPayload {
210 value: u8,
211 }
212
213 enum TestApi {}
214
215 impl ApiVersion for TestApi {
216 const ID: &'static str = "endpoint-test";
217 }
218
219 struct StateEndpoint;
220 struct EventEndpoint;
221
222 impl EndpointDescriptor for StateEndpoint {
223 type Api = TestApi;
224 type Payload = SharedPayload;
225
226 const NAME: &'static str = "endpoint-test::state";
227 const VERSION: &'static str = "endpoint-test";
228 const CONTRACT: &'static str = "state";
229 const TOPIC: &'static str = "endpoint-test/state";
230 const KIND: EndpointKind = EndpointKind::State;
231 }
232
233 impl StateContract for StateEndpoint {}
234 impl StateDeliveryContract for StateEndpoint {}
235
236 impl EndpointDescriptor for EventEndpoint {
237 type Api = TestApi;
238 type Payload = SharedPayload;
239
240 const NAME: &'static str = "endpoint-test::event";
241 const VERSION: &'static str = "endpoint-test";
242 const CONTRACT: &'static str = "event";
243 const TOPIC: &'static str = "endpoint-test/event";
244 const KIND: EndpointKind = EndpointKind::Event;
245 }
246
247 impl EventContract for EventEndpoint {}
248 impl StreamDeliveryContract for EventEndpoint {}
249
250 fn accepts_state_endpoint<E: EndpointDescriptor<Payload = SharedPayload>>() {}
251
252 fn accepts_event_handles(
253 _: Option<crate::handle::publisher::EventPublisher<EventEndpoint>>,
254 _: Option<crate::handle::subscriber::EventReceiver<EventEndpoint>>,
255 ) {
256 }
257
258 #[test]
259 fn one_plain_payload_can_be_reused_by_distinct_endpoint_descriptors() {
260 accepts_state_endpoint::<StateEndpoint>();
261 accepts_state_endpoint::<EventEndpoint>();
262 accepts_event_handles(None, None);
263
264 assert_eq!(StateEndpoint::KIND, EndpointKind::State);
265 assert_eq!(EventEndpoint::KIND, EndpointKind::Event);
266 assert_ne!(StateEndpoint::TOPIC, EventEndpoint::TOPIC);
267 assert_eq!(
268 EndpointKind::Event.delivery_family(),
269 DeliveryFamily::Stream
270 );
271 }
272}