Skip to main content

phoxal_runtime_contract/
metadata.rs

1//! The read side of the embedded participant-metadata document.
2//!
3//! Every participant binary carries one [`ParticipantContract`] in a linker
4//! section. The same contract is persisted with the reusable artifact in a
5//! runtime bundle; keeping one type for both boundaries prevents a binary's
6//! identity and compatibility claims from being copied into a second DTO.
7
8use serde::{Deserialize, Serialize};
9
10use crate::identity::ParticipantArtifactId;
11use crate::version::{BusAbi, LaunchAbi, RobotApiVersion, RuntimeSchema};
12
13/// Every process-boundary version identity one participant binary speaks.
14/// Authored source grammars are intentionally absent: a runtime process
15/// consumes the compiled runtime document, not `robot.yaml`, component files,
16/// or simulation source.
17#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct ParticipantSchemas {
20    /// The bus wire ABI.
21    pub bus: BusAbi,
22    /// The process launch compatibility identity.
23    pub launch: LaunchAbi,
24    /// The compiled runtime document grammar.
25    pub runtime: RuntimeSchema,
26}
27
28/// The complete compatibility contract embedded in one reusable participant
29/// artifact.
30///
31/// This is the single contract value shared by binary metadata and the
32/// persisted runtime bundle. In particular, it does not contain a launched
33/// instance id: one artifact may serve many runtime participant instances.
34#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct ParticipantContract {
37    /// The compile-time identity of the reusable artifact.
38    pub id: ParticipantArtifactId,
39    /// The role kind declared by the artifact's role macro.
40    pub kind: ParticipantKind,
41    /// The robot API revision used by the artifact.
42    pub api: RobotApiVersion,
43    /// The process-boundary schemas used by the artifact.
44    pub schemas: ParticipantSchemas,
45    /// The optional static topology requirement.
46    pub requirement: Option<ParticipantRequirement>,
47    /// The exact JSON Schema emitted for the artifact's config type.
48    pub config_schema: serde_json::Value,
49}
50
51/// What a participant binary is, as declared by the role macro it was built
52/// with. A supervisor schedules and supervises a process by this alone; there
53/// is no second, finer classification anywhere in the process contract.
54#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ParticipantKind {
57    Service,
58    Driver,
59    Simulator,
60    /// The one mandatory root brain: the robot project's composition root,
61    /// built from the root Cargo package and staged as `bin/brain`.
62    Brain,
63}
64
65impl ParticipantKind {
66    /// The wire token for this kind, identical to the `snake_case` rename
67    /// serde derives. Const so the role macro can splice it into the embedded
68    /// document during const-eval.
69    #[must_use]
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            ParticipantKind::Service => "service",
73            ParticipantKind::Driver => "driver",
74            ParticipantKind::Simulator => "simulator",
75            ParticipantKind::Brain => "brain",
76        }
77    }
78}
79
80/// The one topology requirement a participant binary may currently declare.
81#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ParticipantRequirement {
84    /// The stock `drive` service's topology and motor-command contract.
85    DifferentialDriveVelocity,
86}
87
88impl ParticipantRequirement {
89    /// The canonical wire token, identical to the serde rename.
90    #[must_use]
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            Self::DifferentialDriveVelocity => "differential_drive_velocity",
94        }
95    }
96}
97
98/// The record every participant binary embeds in its `.phoxal_meta` /
99/// `__DATA,__phoxal_meta` section at compile time.
100///
101/// Deserialize-only on purpose: the sole writer is
102/// [`crate::emit::ParticipantMetadataRecord`], so a reader can never
103/// accidentally re-persist a document it merely parsed.
104#[derive(Clone, Debug, Deserialize, PartialEq)]
105#[serde(tag = "schema", deny_unknown_fields)]
106pub enum ParticipantMetadata {
107    #[serde(rename = "phoxal/participant-metadata/v0")]
108    V0 {
109        #[serde(flatten)]
110        contract: ParticipantContract,
111    },
112}
113
114impl ParticipantMetadata {
115    /// Strictly parse the bytes of an embedded metadata section.
116    pub fn from_bytes(bytes: &[u8]) -> Result<Self, MetadataError> {
117        serde_json::from_slice(bytes).map_err(MetadataError)
118    }
119
120    /// Borrow the canonical artifact contract carried by this record.
121    #[must_use]
122    pub const fn contract(&self) -> &ParticipantContract {
123        match self {
124            Self::V0 { contract } => contract,
125        }
126    }
127}
128
129/// An embedded metadata section that is not a document this framework train
130/// understands: malformed JSON, an unknown schema tag, a malformed version
131/// identity, or an unknown field.
132#[derive(Debug, thiserror::Error)]
133#[error("participant metadata is not a readable phoxal document: {0}")]
134pub struct MetadataError(#[from] serde_json::Error);
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    const SCHEMAS: &str = r#"{"bus":"phoxal/bus-abi/v0","launch":"phoxal/participant-launch/v0","runtime":"phoxal/runtime-bundle/v0"}"#;
141
142    fn record(fields: &str) -> Vec<u8> {
143        format!(
144            r#"{{"schema":"phoxal/participant-metadata/v0","api":"phoxal/robot-api/v0.1","schemas":{SCHEMAS},"requirement":null,{fields}}}"#
145        )
146        .into_bytes()
147    }
148
149    #[test]
150    fn a_v0_record_parses_into_the_canonical_artifact_contract() {
151        let ParticipantMetadata::V0 { contract } = ParticipantMetadata::from_bytes(&record(
152            r#""id":"drive","kind":"service","config_schema":{"type":"null"}"#,
153        ))
154        .expect("the exact document a role macro embeds must parse");
155
156        assert_eq!(contract.api, RobotApiVersion::new(0, 1));
157        assert_eq!(contract.schemas.bus, BusAbi::V0);
158        assert_eq!(contract.schemas.launch, LaunchAbi::V0);
159        assert_eq!(contract.schemas.runtime, RuntimeSchema::V0);
160        assert_eq!(contract.id.as_str(), "drive");
161        assert_eq!(contract.kind, ParticipantKind::Service);
162        assert_eq!(contract.requirement, None);
163        assert_eq!(contract.config_schema, serde_json::json!({"type": "null"}));
164    }
165
166    #[test]
167    fn the_root_brain_kind_is_distinct_from_a_service() {
168        let metadata = ParticipantMetadata::from_bytes(&record(
169            r#""id":"brain","kind":"brain","config_schema":{"type":"null"}"#,
170        ))
171        .expect("the exact document `#[phoxal::brain]` embeds must parse");
172        let contract = metadata.contract();
173        assert_eq!(contract.id.as_str(), "brain");
174        assert_eq!(contract.kind, ParticipantKind::Brain);
175        assert_ne!(contract.kind, ParticipantKind::Service);
176    }
177
178    #[test]
179    fn the_kind_wire_token_is_the_serde_rename() {
180        for kind in [
181            ParticipantKind::Service,
182            ParticipantKind::Driver,
183            ParticipantKind::Simulator,
184            ParticipantKind::Brain,
185        ] {
186            let json = serde_json::to_string(&kind).expect("a unit variant serializes");
187            assert_eq!(json, format!("\"{}\"", kind.as_str()));
188        }
189    }
190
191    #[test]
192    fn an_unknown_schema_tag_is_rejected() {
193        let bytes = br#"{"schema":"phoxal/participant-metadata/v1","api":"phoxal/robot-api/v0.1","schemas":{"bus":"phoxal/bus-abi/v0","launch":"phoxal/participant-launch/v0","runtime":"phoxal/runtime-bundle/v0"},"id":"drive","kind":"service","config_schema":null}"#;
194        assert!(ParticipantMetadata::from_bytes(bytes).is_err());
195    }
196
197    #[test]
198    fn a_future_robot_api_identity_is_preserved() {
199        let bytes = format!(
200            r#"{{"schema":"phoxal/participant-metadata/v0","api":"phoxal/robot-api/v0.3","schemas":{SCHEMAS},"id":"drive","kind":"service","config_schema":null}}"#
201        )
202        .into_bytes();
203        let metadata = ParticipantMetadata::from_bytes(&bytes)
204            .expect("the process boundary keeps a validated API identity open");
205        assert_eq!(metadata.contract().api, RobotApiVersion::new(0, 3));
206    }
207
208    #[test]
209    fn an_unknown_field_is_rejected() {
210        assert!(
211            ParticipantMetadata::from_bytes(&record(
212                r#""id":"drive","kind":"service","config_schema":null,"extra":true"#,
213            ))
214            .is_err()
215        );
216    }
217
218    #[test]
219    fn a_record_missing_a_runtime_schema_is_rejected() {
220        let bytes = br#"{"schema":"phoxal/participant-metadata/v0","api":"phoxal/robot-api/v0.1","schemas":{"bus":"phoxal/bus-abi/v0","launch":"phoxal/participant-launch/v0"},"id":"drive","kind":"service","config_schema":null}"#;
221        assert!(ParticipantMetadata::from_bytes(bytes).is_err());
222    }
223
224    #[test]
225    fn requirement_tokens_round_trip() {
226        let requirement = ParticipantRequirement::DifferentialDriveVelocity;
227        let json = serde_json::to_string(&requirement).expect("requirement serializes");
228        assert_eq!(json, format!("\"{}\"", requirement.as_str()));
229        assert_eq!(
230            serde_json::from_str::<ParticipantRequirement>(&json).expect("requirement parses"),
231            requirement
232        );
233    }
234}