Skip to main content

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}