1use std::collections::BTreeSet;
18use std::sync::OnceLock;
19use serde::{Deserialize, Serialize};
21use super::{Direction, Entry, OperationDeclaration, OperationDefinition};
23use crate::catalog::{Catalog, Member, Projection, Visitor};
24use crate::config::SchemaSettings;
25use crate::schema::declaration::Definition as SchemaDefinition;
26use crate::schema::graph::{Kind, Schema, View};
27use crate::schema::{self, Described};
28
29static INITIALIZE_CONTRACT: OnceLock<Contract> = OnceLock::new();
32static SHUTDOWN_CONTRACT: OnceLock<Contract> = OnceLock::new();
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(try_from = "DecodedContract")]
42pub struct Contract {
43 operation: OperationDefinition,
44 fingerprint: crate::Fingerprint,
45 codec: Codec,
46 schema: Schema,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 grpc: Option<super::Grpc>,
50}
51
52#[derive(Deserialize)]
53#[serde(deny_unknown_fields)]
54struct DecodedContract {
55 #[serde(with = "super::DecodedOperationDefinition")]
56 operation: OperationDefinition,
57
58 fingerprint: crate::Fingerprint,
59 codec: Codec,
60 schema: Schema,
61
62 #[serde(default)]
63 grpc: Option<super::Grpc>,
64}
65
66#[derive(Clone)]
67pub struct WireDeclaration {
68 pub operation: Entry,
69 pub codec: &'static str,
70 pub response_codec: &'static str,
71 pub input_codec: Option<&'static str>,
72 pub output_codec: Option<&'static str>,
73 pub request: SchemaDefinition,
74 pub response: SchemaDefinition,
75 pub input: Option<SchemaDefinition>,
76 pub output: Option<SchemaDefinition>,
77 pub grpc: Option<super::Grpc>,
78}
79
80pub struct Contracts;
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Codec {
89 Postcard,
90}
91
92#[derive(Debug, thiserror::Error)]
93pub enum ContractError {
94 #[error("invalid or reserved gRPC binding name")]
95 Grpc,
96
97 #[error("invalid wire operation metadata: {0}")]
98 Operation(#[from] crate::Error),
99
100 #[error("wire operation `{id}` has an invalid contract fingerprint")]
101 Fingerprint { id: String },
102
103 #[error("wire operation uses unsupported codec `{codec}`")]
104 Codec { codec: String },
105
106 #[error("invalid {direction} schema for wire operation `{id}`: {source}")]
107 Schema {
108 id: String,
109 direction: &'static str,
110
111 #[source]
112 source: schema::Error,
113 },
114}
115
116impl Codec {
121 pub const fn id(self) -> &'static str {
122 match self {
123 Self::Postcard => "harmos.postcard.v1",
124 }
125 }
126}
127
128impl WireDeclaration {
129 pub const fn copied(&self) -> Self {
130 let grpc = match &self.grpc {
131 None => None,
132 Some(grpc) => {
133 let (std::borrow::Cow::Borrowed(package), std::borrow::Cow::Borrowed(service), std::borrow::Cow::Borrowed(method)) =
134 (&grpc.package, &grpc.service, &grpc.method)
135 else {
136 panic!("static gRPC declarations must borrow names");
137 };
138 Some(super::Grpc::new(package, service, method))
139 }
140 };
141 Self {
142 operation: self.operation,
143 codec: self.codec,
144 response_codec: self.response_codec,
145 input_codec: self.input_codec,
146 output_codec: self.output_codec,
147 request: self.request,
148 response: self.response,
149 input: self.input,
150 output: self.output,
151 grpc,
152 }
153 }
154
155 pub const fn of<O: OperationDeclaration>() -> Self {
156 Self {
157 operation: Entry::of::<O>(),
158 codec: <O::Request as crate::Message>::CODEC,
159 response_codec: <O::Response as crate::Message>::CODEC,
160 input_codec: if O::Input::STREAMED { Some(<<O::Input as super::Direction>::Item as crate::Message>::CODEC) } else { None },
161 output_codec: if O::Output::STREAMED { Some(<<O::Output as super::Direction>::Item as crate::Message>::CODEC) } else { None },
162 request: <O::Request as Described>::DEFINITION,
163 response: <O::Response as Described>::DEFINITION,
164 input: direction::<O::Input>(),
165 output: direction::<O::Output>(),
166 grpc: O::GRPC,
167 }
168 }
169
170 pub fn contract(&self) -> Result<Contract, ContractError> {
171 self.contract_with_settings(&SchemaSettings::default())
172 }
173
174 pub fn contract_with_settings(&self, settings: &SchemaSettings) -> Result<Contract, ContractError> {
175 let operation = self.operation.definition()?;
176 for codec in [Some(self.codec), Some(self.response_codec), self.input_codec, self.output_codec].into_iter().flatten() {
177 if codec != "harmos.postcard.v1" {
178 return Err(ContractError::Codec { codec: codec.to_owned() });
179 }
180 }
181 let mut roots = vec![("request", self.request), ("response", self.response)];
182 if let Some(input) = self.input {
183 roots.push(("input", input));
184 }
185 if let Some(output) = self.output {
186 roots.push(("output", output));
187 }
188 let schema = Schema::from_declarations(&roots, settings).map_err(|source| ContractError::Schema {
189 id: operation.id.to_string(),
190 direction: "contract",
191 source: source.into(),
192 })?;
193 Contract::new_with_settings(operation.clone(), operation.fingerprint, Codec::Postcard, schema, settings)?
194 .with_grpc(self.grpc.clone())
195 }
196}
197
198impl Contract {
199 pub fn new(
200 operation: OperationDefinition, fingerprint: crate::Fingerprint, codec: Codec, schema: Schema,
201 ) -> Result<Self, ContractError> {
202 Self::new_with_settings(operation, fingerprint, codec, schema, &SchemaSettings::default())
203 }
204
205 pub fn new_with_settings(
206 operation: OperationDefinition, fingerprint: crate::Fingerprint, codec: Codec, schema: Schema, settings: &SchemaSettings,
207 ) -> Result<Self, ContractError> {
208 operation.validate()?;
209 Self::canonical(operation, fingerprint, codec, schema, settings)
210 }
211
212 fn canonical(
213 operation: OperationDefinition, fingerprint: crate::Fingerprint, codec: Codec, schema: Schema, settings: &SchemaSettings,
214 ) -> Result<Self, ContractError> {
215 let invalid = |source: schema::graph::Error| ContractError::Schema {
216 id: operation.id.to_string(),
217 direction: "contract",
218 source: source.into(),
219 };
220 schema.prepare(settings).map_err(invalid)?;
221 let directions = schema.root().map_err(invalid)?;
222 let fields = directions.fields();
223 let valid = (2..=4).contains(&fields.len())
224 && fields[0].name == "request"
225 && fields[1].name == "response"
226 && fields.iter().enumerate().all(|(index, field)| {
227 field.tag == (index + 1) as u32
228 && field.cardinality == schema::Cardinality::Required
229 && matches!(field.kind, Kind::Message { .. })
230 })
231 && fields.get(2).is_none_or(|field| field.name == "input" || field.name == "output")
232 && fields.get(3).is_none_or(|field| fields[2].name == "input" && field.name == "output");
233 if !valid {
234 return Err(invalid(schema::graph::Error::Field {
235 field: "contract directions".into(),
236 reason: "expected request, response, and optional input/output roots",
237 }));
238 }
239 let contract = Self {
240 operation,
241 fingerprint,
242 codec,
243 schema,
244 grpc: None,
245 };
246 let computed = contract.computed_fingerprint()?;
247 if fingerprint != computed || contract.operation.fingerprint != computed {
248 return Err(ContractError::Fingerprint { id: contract.operation.id.to_string() });
249 }
250 Ok(contract)
251 }
252
253 pub fn grpc(&self) -> Option<&super::Grpc> {
254 self.grpc.as_ref()
255 }
256
257 pub fn with_grpc(mut self, binding: Option<super::Grpc>) -> Result<Self, ContractError> {
258 if let Some(binding) = &binding {
259 binding.validate()?;
260 }
261 self.grpc = binding;
262 Ok(self)
263 }
264
265 pub(crate) fn lifecycle(
267 action: crate::model::operations::lifecycle::Action, policy: super::OperationPolicy,
268 ) -> Result<Self, ContractError> {
269 use crate::model::operations::lifecycle::{self, Action};
270 policy.validate().map_err(crate::Error::from)?;
271 let cache = match action {
272 Action::Initialize => &INITIALIZE_CONTRACT,
273 Action::Shutdown => &SHUTDOWN_CONTRACT,
274 };
275 let template = if let Some(contract) = cache.get() {
276 contract
277 } else {
278 let request = action.request();
279 let request_schema = Schema::from_declaration(request, &SchemaSettings::default()).map_err(|source| ContractError::Schema {
280 id: action.id().into(),
281 direction: "request",
282 source: source.into(),
283 })?;
284 let request_root = request_schema.root().map_err(|source| ContractError::Schema {
285 id: action.id().into(),
286 direction: "request",
287 source: source.into(),
288 })?;
289 let request_fingerprint =
290 crate::schema::identity::fingerprint(request_root, Codec::Postcard.id()).map_err(|source| ContractError::Schema {
291 id: action.id().into(),
292 direction: "request",
293 source: source.into(),
294 })?;
295 let fingerprint =
296 crate::schema::identity::operation(action.id(), request_fingerprint, <() as crate::Portable>::fingerprint(), None, None);
297 let operation = OperationDefinition {
298 id: crate::Identifier::new(action.id()).map_err(crate::Error::from)?,
299 description: String::new(),
300 policy: lifecycle::POLICY,
301 visibility: super::Visibility::Private,
302 replay: super::ReplaySafety::Unknown,
303 fingerprint,
304 };
305 let schema =
306 Schema::from_declarations(&[("request", request), ("response", <() as Described>::DEFINITION)], &SchemaSettings::default())
307 .map_err(|source| ContractError::Schema {
308 id: action.id().into(),
309 direction: "contract",
310 source: source.into(),
311 })?;
312 let contract = Self::canonical(operation, fingerprint, Codec::Postcard, schema, &SchemaSettings::default())?;
313 cache.get_or_init(|| contract)
314 };
315 let mut contract = template.clone();
316 contract.operation.policy = policy;
317 Ok(contract)
318 }
319
320 pub(crate) fn reserved<O: OperationDeclaration>(policy: super::OperationPolicy) -> Result<Self, ContractError> {
323 policy.validate().map_err(crate::Error::from)?;
324 let operation = OperationDefinition {
325 id: crate::Identifier::new(O::ID).map_err(crate::Error::from)?,
326 description: O::DESCRIPTION.to_owned(),
327 policy,
328 visibility: O::VISIBILITY,
329 replay: O::REPLAY,
330 fingerprint: (Entry::of::<O>().fingerprint)()?,
331 };
332 let schema = Schema::from_declarations(
333 &[("request", <O::Request as Described>::DEFINITION), ("response", <O::Response as Described>::DEFINITION)],
334 &SchemaSettings::default(),
335 )
336 .map_err(|source| ContractError::Schema {
337 id: operation.id.to_string(),
338 direction: "contract",
339 source: source.into(),
340 })?;
341 Self::canonical(operation.clone(), operation.fingerprint, Codec::Postcard, schema, &SchemaSettings::default())
342 }
343
344 pub fn operation(&self) -> &OperationDefinition {
345 &self.operation
346 }
347
348 pub fn fingerprint(&self) -> crate::Fingerprint {
349 self.fingerprint
350 }
351
352 pub fn codec(&self) -> Codec {
353 self.codec
354 }
355
356 pub fn schema(&self) -> &Schema {
357 &self.schema
358 }
359
360 pub fn request(&self) -> View<'_> {
361 self.direction("request").expect("validated request root")
362 }
363
364 pub fn response(&self) -> View<'_> {
365 self.direction("response").expect("validated response root")
366 }
367
368 pub fn input(&self) -> Option<View<'_>> {
369 self.direction("input")
370 }
371
372 pub fn output(&self) -> Option<View<'_>> {
373 self.direction("output")
374 }
375
376 pub fn compatible(&self, requirement: &Self) -> bool {
377 self.compatible_with_settings(requirement, &SchemaSettings::default())
378 }
379
380 pub fn compatible_with_settings(&self, requirement: &Self, settings: &SchemaSettings) -> bool {
381 self.operation.id == requirement.operation.id
382 && self.fingerprint == requirement.fingerprint
383 && self.codec == requirement.codec
384 && ["request", "response", "input", "output"].into_iter().all(|name| {
385 match (self.direction(name), requirement.direction(name)) {
386 (None, None) => true,
387 (Some(left), Some(right)) => {
388 self.schema.equivalent(left.index(), &requirement.schema, right.index(), settings).unwrap_or(false)
389 }
390 _ => false,
391 }
392 })
393 }
394
395 fn computed_fingerprint(&self) -> Result<crate::Fingerprint, ContractError> {
396 let payload = |view| {
397 schema::identity::fingerprint(view, self.codec.id()).map_err(|source| ContractError::Schema {
398 id: self.operation.id.to_string(),
399 direction: "identity",
400 source: source.into(),
401 })
402 };
403 Ok(schema::identity::operation(
404 self.operation.id.as_str(),
405 payload(self.request())?,
406 payload(self.response())?,
407 self.input().map(payload).transpose()?,
408 self.output().map(payload).transpose()?,
409 ))
410 }
411
412 fn direction(&self, name: &str) -> Option<View<'_>> {
413 let field = self.schema.definitions[self.schema.root].iter().find(|field| field.name == name)?;
414 let Kind::Message { definition } = field.kind else {
415 unreachable!("validated contract direction");
416 };
417 Some(self.schema.view(definition).expect("validated direction reference"))
418 }
419}
420
421impl TryFrom<DecodedContract> for Contract {
422 type Error = ContractError;
423
424 fn try_from(decoded: DecodedContract) -> Result<Self, Self::Error> {
425 if !crate::reserved(decoded.operation.id.as_str()) {
426 return Self::new(decoded.operation, decoded.fingerprint, decoded.codec, decoded.schema)?.with_grpc(decoded.grpc);
427 }
428
429 use crate::model::operations::lifecycle::{Action, Descriptor};
431 let refused = || crate::Error::ReservedOperation { id: decoded.operation.id.to_string() }.into();
432 let expected = match decoded.operation.id.as_str() {
433 crate::observers::Control::ID => crate::observers::contract()?,
434 "harmos.initialize" => Descriptor::new(Action::Initialize, decoded.operation.policy).map_err(|_| refused())?.contract().clone(),
435 "harmos.shutdown" => Descriptor::new(Action::Shutdown, decoded.operation.policy).map_err(|_| refused())?.contract().clone(),
436 _ => return Err(refused()),
437 };
438 let identity_matches =
439 decoded.operation == expected.operation && decoded.fingerprint == expected.fingerprint && decoded.codec == expected.codec;
440 let schema_matches = decoded.schema == expected.schema && decoded.grpc == expected.grpc;
441 if !identity_matches || !schema_matches {
442 return Err(refused());
443 }
444 Ok(expected)
445 }
446}
447
448impl Visitor for Contracts {
449 type Entry = WireDeclaration;
450}
451
452impl<M: OperationDeclaration> Member<M> for Contracts {
453 const ENTRY: WireDeclaration = WireDeclaration::of::<M>();
454}
455
456impl Contracts {
457 pub fn of<C: Catalog<Self>>() -> Result<Vec<Contract>, ContractError> {
458 Self::from_projection(&C::ENTRIES)
459 }
460
461 pub fn from_entries(entries: &[WireDeclaration]) -> Result<Vec<Contract>, ContractError> {
462 let mut ids = BTreeSet::new();
463 let mut contracts = Vec::with_capacity(entries.len());
464 for declaration in entries {
465 append(declaration, &mut ids, &mut contracts)?;
466 }
467 Ok(contracts)
468 }
469
470 pub fn from_tree(entries: &[crate::catalog::Node<WireDeclaration>]) -> Result<Vec<Contract>, ContractError> {
471 let mut ids = BTreeSet::new();
472 let mut contracts = Vec::new();
473 crate::catalog::try_for_each(entries, |declaration| append(declaration, &mut ids, &mut contracts))?;
474 Ok(contracts)
475 }
476
477 pub fn from_projection<P: Projection<WireDeclaration>>(entries: &P) -> Result<Vec<Contract>, ContractError> {
478 let mut ids = BTreeSet::new();
479 let mut contracts = Vec::with_capacity(P::LEN);
480 entries.try_for_each(|declaration| append(declaration, &mut ids, &mut contracts))?;
481 Ok(contracts)
482 }
483}
484
485const fn direction<D: super::Direction>() -> Option<SchemaDefinition> {
490 if D::STREAMED { Some(<D::Item as Described>::DEFINITION) } else { None }
491}
492
493fn append(
494 declaration: &WireDeclaration, ids: &mut BTreeSet<crate::Identifier>, contracts: &mut Vec<Contract>,
495) -> Result<(), ContractError> {
496 let contract = declaration.contract()?;
497 if !ids.insert(contract.operation.id.clone()) {
498 return Err(crate::Error::DuplicateOperation { id: contract.operation.id.to_string() }.into());
499 }
500 contracts.push(contract);
501 Ok(())
502}
503
504#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::model::operations::lifecycle::{self, Action, Descriptor};
510 use crate::operations::{Backoff, Jitter, RetryPolicy};
511 use serde_json::json;
512
513 fn sdk_contracts() -> [Contract; 3] {
514 let policy = super::super::OperationPolicy {
515 timeout: std::time::Duration::from_secs(17),
516 attempt_timeout: std::time::Duration::from_secs(11),
517 ..lifecycle::POLICY
518 };
519 [
520 crate::observers::contract().unwrap(),
521 Descriptor::new(Action::Initialize, policy).unwrap().contract().clone(),
522 Descriptor::new(Action::Shutdown, policy).unwrap().contract().clone(),
523 ]
524 }
525
526 #[test]
527 fn sdk_metadata_roundtrip() {
528 for original in sdk_contracts() {
529 let encoded = serde_json::to_vec(&original).unwrap();
530 let decoded: Contract = serde_json::from_slice(&encoded).unwrap();
531 assert_eq!(decoded, original);
532 }
533 }
534
535 #[test]
536 fn sdk_metadata_forgery() {
537 let delay = std::time::Duration::from_secs(1);
538 let backoff = Backoff::Fixed { delay, jitter: Jitter::None };
539 for original in sdk_contracts() {
540 let encoded = serde_json::to_value(&original).unwrap();
541 for (pointer, replacement) in [
542 ("/operation/id", json!("harmos.forged")),
543 ("/operation/description", json!("forged SDK contract")),
544 ("/operation/visibility", json!("shared")),
545 ("/operation/policy/attempt_timeout/secs", json!(0)),
546 ("/operation/policy/retries", json!(RetryPolicy::OnTransient { max_retries: 1, backoff })),
547 ("/fingerprint", json!("00".repeat(32))),
548 ("/schema", json!({"root": 0, "definitions": [[]]})),
549 ] {
550 let mut forged = encoded.clone();
551 *forged.pointer_mut(pointer).unwrap() = replacement;
552 assert!(serde_json::from_value::<Contract>(forged).is_err(), "{pointer}");
553 }
554 let mut exposed = encoded;
555 exposed["grpc"] = json!({"package": "forged.v1", "service": "Exposed", "method": "Invoke"});
556 assert!(serde_json::from_value::<Contract>(exposed).is_err());
557 }
558 }
559
560 #[test]
561 fn sdk_lifecycle_refuses_invalid() {
562 let delay = std::time::Duration::from_secs(1);
563 let backoff = Backoff::Fixed { delay, jitter: Jitter::None };
564 let descriptors = [
565 Descriptor::new(Action::Initialize, lifecycle::POLICY).unwrap(),
566 Descriptor::new(Action::Shutdown, lifecycle::POLICY).unwrap(),
567 ];
568 for descriptor in &descriptors {
569 assert_eq!(Contract::from(descriptor.clone()), *descriptor.contract());
570 let encoded = serde_json::to_value(descriptor).unwrap();
571 assert_eq!(serde_json::from_value::<Descriptor>(encoded.clone()).unwrap(), *descriptor);
572 for (pointer, replacement) in [
573 ("/fingerprint", json!("00".repeat(32))),
574 ("/policy/attempt_timeout/secs", json!(0)),
575 ("/policy/retries", json!(RetryPolicy::OnTransient { max_retries: 1, backoff })),
576 ] {
577 let mut forged = encoded.clone();
578 *forged.pointer_mut(pointer).unwrap() = replacement;
579 assert!(serde_json::from_value::<Descriptor>(forged).is_err(), "{pointer}");
580 }
581 }
582 let mut manifest = crate::inventory::contracts::Manifest {
583 facility: "guest".into(),
584 owner: "fixture".into(),
585 provides: Vec::new(),
586 requires: Vec::new(),
587 lifecycle: descriptors,
588 };
589 crate::inventory::contracts::validate(&manifest).unwrap();
590 manifest.lifecycle.swap(0, 1);
591 assert!(crate::inventory::contracts::validate(&manifest).is_err());
592 }
593
594 #[test]
595 fn reserved_operations_refused() {
596 use crate::inventory::contracts::{Manifest, validate};
597 for contract in sdk_contracts() {
598 assert!(contract.operation().validate().is_err());
599 let definition = serde_json::to_value(contract.operation()).unwrap();
600 assert!(serde_json::from_value::<OperationDefinition>(definition).is_err());
601
602 let mut manifest = Manifest {
603 facility: "sidecar".into(),
604 owner: "fixture".into(),
605 provides: Vec::new(),
606 requires: Vec::new(),
607 lifecycle: [
608 Descriptor::new(Action::Initialize, lifecycle::POLICY).unwrap(),
609 Descriptor::new(Action::Shutdown, lifecycle::POLICY).unwrap(),
610 ],
611 };
612 validate(&manifest).unwrap();
613 manifest.provides.push(contract);
614 assert!(validate(&manifest).is_err());
615 let encoded = serde_json::to_value(&manifest).unwrap();
616 assert!(serde_json::from_value::<Manifest>(encoded).is_err());
617 }
618 }
619}