use franken_kernel::SchemaVersion;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fmt;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RoleName(String);
impl RoleName {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn is_valid(&self) -> bool {
is_symbolic_identifier(self.as_str())
}
}
impl fmt::Display for RoleName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for RoleName {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for RoleName {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Label(String);
impl Label {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self(label.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn is_valid(&self) -> bool {
is_symbolic_identifier(self.as_str())
}
}
impl fmt::Display for Label {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for Label {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for Label {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionPath(Vec<String>);
impl SessionPath {
#[must_use]
pub fn root() -> Self {
Self(vec!["root".to_owned()])
}
#[must_use]
pub fn child(&self, segment: impl Into<String>) -> Self {
let mut segments = self.0.clone();
segments.push(segment.into());
Self(segments)
}
#[must_use]
pub fn segments(&self) -> &[String] {
&self.0
}
}
impl fmt::Display for SessionPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0.join("/"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct MessageType {
pub name: String,
pub sender: RoleName,
pub receiver: RoleName,
pub payload_schema: String,
}
impl MessageType {
#[must_use]
pub fn new(
name: impl Into<String>,
sender: impl Into<RoleName>,
receiver: impl Into<RoleName>,
payload_schema: impl Into<String>,
) -> Self {
Self {
name: name.into(),
sender: sender.into(),
receiver: receiver.into(),
payload_schema: payload_schema.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionBranch {
pub label: Label,
pub continuation: SessionType,
}
impl SessionBranch {
#[must_use]
pub fn new(label: impl Into<Label>, continuation: SessionType) -> Self {
Self {
label: label.into(),
continuation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionType {
Send {
message: MessageType,
next: Box<Self>,
},
Receive {
message: MessageType,
next: Box<Self>,
},
Choice {
decider: RoleName,
branches: Vec<SessionBranch>,
},
Branch {
offerer: RoleName,
branches: Vec<SessionBranch>,
},
Recurse {
label: Label,
},
RecursePoint {
label: Label,
body: Box<Self>,
},
#[default]
End,
}
impl SessionType {
#[must_use]
pub fn send(message: MessageType, next: Self) -> Self {
Self::Send {
message,
next: Box::new(next),
}
}
#[must_use]
pub fn receive(message: MessageType, next: Self) -> Self {
Self::Receive {
message,
next: Box::new(next),
}
}
#[must_use]
pub fn choice(decider: impl Into<RoleName>, branches: Vec<SessionBranch>) -> Self {
Self::Choice {
decider: decider.into(),
branches,
}
}
#[must_use]
pub fn branch(offerer: impl Into<RoleName>, branches: Vec<SessionBranch>) -> Self {
Self::Branch {
offerer: offerer.into(),
branches,
}
}
#[must_use]
pub fn recurse(label: impl Into<Label>) -> Self {
Self::Recurse {
label: label.into(),
}
}
#[must_use]
pub fn recurse_point(label: impl Into<Label>, body: Self) -> Self {
Self::RecursePoint {
label: label.into(),
body: Box::new(body),
}
}
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::End)
}
fn collect_paths(&self, base: &SessionPath, paths: &mut BTreeSet<SessionPath>) {
paths.insert(base.clone());
match self {
Self::Send { message, next } => {
let here = base.child(format!("send:{}", message.name));
paths.insert(here.clone());
next.collect_paths(&here, paths);
}
Self::Receive { message, next } => {
let here = base.child(format!("receive:{}", message.name));
paths.insert(here.clone());
next.collect_paths(&here, paths);
}
Self::Choice { decider, branches } => {
for branch in branches {
let here = base.child(format!("choice:{decider}:{}", branch.label));
paths.insert(here.clone());
branch.continuation.collect_paths(&here, paths);
}
}
Self::Branch { offerer, branches } => {
for branch in branches {
let here = base.child(format!("branch:{offerer}:{}", branch.label));
paths.insert(here.clone());
branch.continuation.collect_paths(&here, paths);
}
}
Self::Recurse { label } => {
paths.insert(base.child(format!("recurse:{label}")));
}
Self::RecursePoint { label, body } => {
let here = base.child(format!("recurse-point:{label}"));
paths.insert(here.clone());
body.collect_paths(&here, paths);
}
Self::End => {
paths.insert(base.child("end"));
}
}
}
#[allow(clippy::too_many_lines)]
fn validate(
&self,
roles: &[RoleName],
base: &SessionPath,
active_labels: &mut Vec<Label>,
) -> Result<(), ProtocolContractValidationError> {
match self {
Self::Send { message, next } => {
validate_message(message, roles)?;
next.validate(
roles,
&base.child(format!("send:{}", message.name)),
active_labels,
)
}
Self::Receive { message, next } => {
validate_message(message, roles)?;
next.validate(
roles,
&base.child(format!("receive:{}", message.name)),
active_labels,
)
}
Self::Choice { decider, branches } => {
if !decider.is_valid() {
return Err(ProtocolContractValidationError::InvalidRoleName(
decider.clone(),
));
}
if !roles.contains(decider) {
return Err(ProtocolContractValidationError::UnknownRole {
context: format!("choice at `{base}`"),
role: decider.clone(),
});
}
if branches.is_empty() {
return Err(ProtocolContractValidationError::ChoiceWithoutBranches {
path: base.clone(),
});
}
let mut labels = BTreeSet::new();
for branch in branches {
if !branch.label.is_valid() {
return Err(ProtocolContractValidationError::InvalidLabel(
branch.label.clone(),
));
}
if !labels.insert(branch.label.clone()) {
return Err(ProtocolContractValidationError::DuplicateBranchLabel {
path: base.clone(),
label: branch.label.clone(),
});
}
branch.continuation.validate(
roles,
&base.child(format!("choice:{decider}:{}", branch.label)),
active_labels,
)?;
}
Ok(())
}
Self::Branch { offerer, branches } => {
if !offerer.is_valid() {
return Err(ProtocolContractValidationError::InvalidRoleName(
offerer.clone(),
));
}
if !roles.contains(offerer) {
return Err(ProtocolContractValidationError::UnknownRole {
context: format!("branch at `{base}`"),
role: offerer.clone(),
});
}
if branches.is_empty() {
return Err(ProtocolContractValidationError::BranchWithoutBranches {
path: base.clone(),
});
}
let mut labels = BTreeSet::new();
for branch in branches {
if !branch.label.is_valid() {
return Err(ProtocolContractValidationError::InvalidLabel(
branch.label.clone(),
));
}
if !labels.insert(branch.label.clone()) {
return Err(ProtocolContractValidationError::DuplicateBranchLabel {
path: base.clone(),
label: branch.label.clone(),
});
}
branch.continuation.validate(
roles,
&base.child(format!("branch:{offerer}:{}", branch.label)),
active_labels,
)?;
}
Ok(())
}
Self::Recurse { label } => {
if !label.is_valid() {
return Err(ProtocolContractValidationError::InvalidLabel(label.clone()));
}
if !active_labels.contains(label) {
return Err(ProtocolContractValidationError::UndefinedRecursionLabel {
path: base.clone(),
label: label.clone(),
});
}
Ok(())
}
Self::RecursePoint { label, body } => {
if !label.is_valid() {
return Err(ProtocolContractValidationError::InvalidLabel(label.clone()));
}
if active_labels.contains(label) {
return Err(ProtocolContractValidationError::DuplicateRecursionLabel {
path: base.clone(),
label: label.clone(),
});
}
active_labels.push(label.clone());
let result = body.validate(
roles,
&base.child(format!("recurse-point:{label}")),
active_labels,
);
active_labels.pop();
result
}
Self::End => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct GlobalSessionType {
pub root: SessionType,
}
impl GlobalSessionType {
#[must_use]
pub fn new(root: SessionType) -> Self {
Self { root }
}
#[must_use]
pub fn paths(&self) -> BTreeSet<SessionPath> {
let mut paths = BTreeSet::new();
self.root.collect_paths(&SessionPath::root(), &mut paths);
paths
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceCheckpoint {
pub name: String,
pub path: SessionPath,
}
impl EvidenceCheckpoint {
#[must_use]
pub fn new(name: impl Into<String>, path: SessionPath) -> Self {
Self {
name: name.into(),
path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TimeoutLaw {
pub default_timeout: Option<Duration>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub per_step: Vec<TimeoutOverride>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeoutOverride {
pub path: SessionPath,
pub timeout: Duration,
}
impl TimeoutOverride {
#[must_use]
pub fn new(path: SessionPath, timeout: Duration) -> Self {
Self { path, timeout }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompensationPath {
pub name: String,
pub trigger: SessionPath,
pub path: Vec<SessionPath>,
}
impl CompensationPath {
#[must_use]
pub fn new(name: impl Into<String>, trigger: SessionPath, path: Vec<SessionPath>) -> Self {
Self {
name: name.into(),
trigger,
path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CutoffPath {
pub name: String,
pub trigger: SessionPath,
pub path: Vec<SessionPath>,
}
impl CutoffPath {
#[must_use]
pub fn new(name: impl Into<String>, trigger: SessionPath, path: Vec<SessionPath>) -> Self {
Self {
name: name.into(),
trigger,
path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolContract {
pub name: String,
pub version: SchemaVersion,
pub roles: Vec<RoleName>,
pub global_type: GlobalSessionType,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence_checkpoints: Vec<EvidenceCheckpoint>,
#[serde(default)]
pub timeout_law: TimeoutLaw,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compensation_paths: Vec<CompensationPath>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cutoff_paths: Vec<CutoffPath>,
}
impl ProtocolContract {
#[must_use]
pub fn new(
name: impl Into<String>,
version: SchemaVersion,
roles: Vec<RoleName>,
global_type: GlobalSessionType,
) -> Self {
Self {
name: name.into(),
version,
roles,
global_type,
evidence_checkpoints: Vec::new(),
timeout_law: TimeoutLaw::default(),
compensation_paths: Vec::new(),
cutoff_paths: Vec::new(),
}
}
pub fn validate(&self) -> Result<(), ProtocolContractValidationError> {
if self.name.trim().is_empty() {
return Err(ProtocolContractValidationError::EmptyContractName);
}
if self.roles.len() != 2 {
return Err(ProtocolContractValidationError::UnsupportedRoleCount {
actual: self.roles.len(),
});
}
let mut roles = BTreeSet::new();
for role in &self.roles {
if !role.is_valid() {
return Err(ProtocolContractValidationError::InvalidRoleName(
role.clone(),
));
}
if !roles.insert(role.clone()) {
return Err(ProtocolContractValidationError::DuplicateRole(role.clone()));
}
}
if self.global_type.root.is_terminal() {
return Err(ProtocolContractValidationError::EmptyProtocol);
}
let mut active_labels = Vec::new();
self.global_type
.root
.validate(&self.roles, &SessionPath::root(), &mut active_labels)?;
let valid_paths = self.global_type.paths();
let mut checkpoint_names = BTreeSet::new();
for checkpoint in &self.evidence_checkpoints {
if checkpoint.name.trim().is_empty() {
return Err(ProtocolContractValidationError::EmptyEvidenceCheckpointName);
}
if !checkpoint_names.insert(checkpoint.name.clone()) {
return Err(
ProtocolContractValidationError::DuplicateEvidenceCheckpointName(
checkpoint.name.clone(),
),
);
}
if !is_addressable_session_path(&checkpoint.path, &valid_paths) {
return Err(ProtocolContractValidationError::UnknownEvidencePath {
name: checkpoint.name.clone(),
path: checkpoint.path.clone(),
});
}
}
if let Some(default_timeout) = self.timeout_law.default_timeout {
if default_timeout.is_zero() {
return Err(ProtocolContractValidationError::ZeroDefaultTimeout);
}
}
let mut timeout_paths = BTreeSet::new();
for override_rule in &self.timeout_law.per_step {
if override_rule.timeout.is_zero() {
return Err(ProtocolContractValidationError::ZeroTimeoutOverride {
path: override_rule.path.clone(),
});
}
if !is_addressable_session_path(&override_rule.path, &valid_paths) {
return Err(ProtocolContractValidationError::UnknownTimeoutPath {
path: override_rule.path.clone(),
});
}
if !timeout_paths.insert(override_rule.path.clone()) {
return Err(ProtocolContractValidationError::DuplicateTimeoutOverride {
path: override_rule.path.clone(),
});
}
}
validate_recovery_paths(&self.compensation_paths, &valid_paths)?;
validate_cutoff_paths(&self.cutoff_paths, &valid_paths)?;
let (left_local, right_local) = super::projection::project_pair(self)
.map_err(|err| ProtocolContractValidationError::ProjectionInvariant(err.to_string()))?;
if !super::projection::is_dual(&left_local, &right_local) {
return Err(ProtocolContractValidationError::ProjectedRolesNotDual {
left: self.roles[0].clone(),
right: self.roles[1].clone(),
});
}
Ok(())
}
pub fn validated(self) -> Result<Self, ProtocolContractValidationError> {
self.validate()?;
Ok(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ProtocolContractValidationError {
#[error("protocol contract name must not be empty")]
EmptyContractName,
#[error("protocol contract must declare exactly two roles for now, got {actual}")]
UnsupportedRoleCount {
actual: usize,
},
#[error("invalid role name `{0}`")]
InvalidRoleName(RoleName),
#[error("duplicate role `{0}`")]
DuplicateRole(RoleName),
#[error("{context} references undeclared role `{role}`")]
UnknownRole {
context: String,
role: RoleName,
},
#[error("message name must not be empty")]
EmptyMessageName,
#[error("message `{0}` must declare a payload schema")]
EmptyPayloadSchema(String),
#[error("message `{message}` cannot send from and to the same role `{role}`")]
SelfDirectedMessage {
message: String,
role: RoleName,
},
#[error("protocol contract must contain at least one non-terminal step")]
EmptyProtocol,
#[error("choice at `{path}` must contain at least one branch")]
ChoiceWithoutBranches {
path: SessionPath,
},
#[error("branch at `{path}` must contain at least one branch")]
BranchWithoutBranches {
path: SessionPath,
},
#[error("invalid label `{0}`")]
InvalidLabel(Label),
#[error("duplicate branch label `{label}` at `{path}`")]
DuplicateBranchLabel {
path: SessionPath,
label: Label,
},
#[error("duplicate recursion label `{label}` at `{path}`")]
DuplicateRecursionLabel {
path: SessionPath,
label: Label,
},
#[error("undefined recursion label `{label}` at `{path}`")]
UndefinedRecursionLabel {
path: SessionPath,
label: Label,
},
#[error("evidence checkpoint name must not be empty")]
EmptyEvidenceCheckpointName,
#[error("duplicate evidence checkpoint `{0}`")]
DuplicateEvidenceCheckpointName(String),
#[error("evidence checkpoint `{name}` references unknown path `{path}`")]
UnknownEvidencePath {
name: String,
path: SessionPath,
},
#[error("default timeout must be greater than zero")]
ZeroDefaultTimeout,
#[error("timeout override at `{path}` must be greater than zero")]
ZeroTimeoutOverride {
path: SessionPath,
},
#[error("timeout override references unknown path `{path}`")]
UnknownTimeoutPath {
path: SessionPath,
},
#[error("duplicate timeout override for `{path}`")]
DuplicateTimeoutOverride {
path: SessionPath,
},
#[error("compensation path name must not be empty")]
EmptyCompensationPathName,
#[error("compensation path `{name}` must contain at least one step")]
EmptyCompensationSequence {
name: String,
},
#[error("compensation path `{name}` references unknown trigger `{path}`")]
UnknownCompensationTrigger {
name: String,
path: SessionPath,
},
#[error("compensation path `{name}` references unknown step `{path}`")]
UnknownCompensationStep {
name: String,
path: SessionPath,
},
#[error("duplicate compensation path `{0}`")]
DuplicateCompensationPath(String),
#[error("compensation path `{name}` must stay ordered; `{path}` does not extend `{previous}`")]
InvalidCompensationSequenceOrder {
name: String,
previous: SessionPath,
path: SessionPath,
},
#[error("cutoff path name must not be empty")]
EmptyCutoffPathName,
#[error("cutoff path `{name}` must contain at least one step")]
EmptyCutoffSequence {
name: String,
},
#[error("cutoff path `{name}` references unknown trigger `{path}`")]
UnknownCutoffTrigger {
name: String,
path: SessionPath,
},
#[error("cutoff path `{name}` references unknown step `{path}`")]
UnknownCutoffStep {
name: String,
path: SessionPath,
},
#[error("duplicate cutoff path `{0}`")]
DuplicateCutoffPath(String),
#[error("cutoff path `{name}` must stay ordered; `{path}` does not extend `{previous}`")]
InvalidCutoffSequenceOrder {
name: String,
previous: SessionPath,
path: SessionPath,
},
#[error("projection invariant failed: {0}")]
ProjectionInvariant(String),
#[error("projected local protocols for `{left}` and `{right}` are not dual")]
ProjectedRolesNotDual {
left: RoleName,
right: RoleName,
},
}
fn is_symbolic_identifier(value: &str) -> bool {
!value.trim().is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
fn validate_message(
message: &MessageType,
roles: &[RoleName],
) -> Result<(), ProtocolContractValidationError> {
if message.name.trim().is_empty() {
return Err(ProtocolContractValidationError::EmptyMessageName);
}
if !message.sender.is_valid() {
return Err(ProtocolContractValidationError::InvalidRoleName(
message.sender.clone(),
));
}
if !message.receiver.is_valid() {
return Err(ProtocolContractValidationError::InvalidRoleName(
message.receiver.clone(),
));
}
if !roles.contains(&message.sender) {
return Err(ProtocolContractValidationError::UnknownRole {
context: format!("message `{}` sender", message.name),
role: message.sender.clone(),
});
}
if !roles.contains(&message.receiver) {
return Err(ProtocolContractValidationError::UnknownRole {
context: format!("message `{}` receiver", message.name),
role: message.receiver.clone(),
});
}
if message.sender == message.receiver {
return Err(ProtocolContractValidationError::SelfDirectedMessage {
message: message.name.clone(),
role: message.sender.clone(),
});
}
if message.payload_schema.trim().is_empty() {
return Err(ProtocolContractValidationError::EmptyPayloadSchema(
message.name.clone(),
));
}
Ok(())
}
fn validate_recovery_paths(
paths: &[CompensationPath],
valid_paths: &BTreeSet<SessionPath>,
) -> Result<(), ProtocolContractValidationError> {
let mut names = BTreeSet::new();
for compensation in paths {
if compensation.name.trim().is_empty() {
return Err(ProtocolContractValidationError::EmptyCompensationPathName);
}
if !names.insert(compensation.name.clone()) {
return Err(ProtocolContractValidationError::DuplicateCompensationPath(
compensation.name.clone(),
));
}
if !is_addressable_session_path(&compensation.trigger, valid_paths) {
return Err(
ProtocolContractValidationError::UnknownCompensationTrigger {
name: compensation.name.clone(),
path: compensation.trigger.clone(),
},
);
}
if compensation.path.is_empty() {
return Err(ProtocolContractValidationError::EmptyCompensationSequence {
name: compensation.name.clone(),
});
}
for step in &compensation.path {
if !is_addressable_session_path(step, valid_paths) {
return Err(ProtocolContractValidationError::UnknownCompensationStep {
name: compensation.name.clone(),
path: step.clone(),
});
}
}
if let Some((previous, path)) = first_unordered_recovery_step(&compensation.path) {
return Err(
ProtocolContractValidationError::InvalidCompensationSequenceOrder {
name: compensation.name.clone(),
previous,
path,
},
);
}
}
Ok(())
}
fn validate_cutoff_paths(
paths: &[CutoffPath],
valid_paths: &BTreeSet<SessionPath>,
) -> Result<(), ProtocolContractValidationError> {
let mut names = BTreeSet::new();
for cutoff in paths {
if cutoff.name.trim().is_empty() {
return Err(ProtocolContractValidationError::EmptyCutoffPathName);
}
if !names.insert(cutoff.name.clone()) {
return Err(ProtocolContractValidationError::DuplicateCutoffPath(
cutoff.name.clone(),
));
}
if !is_addressable_session_path(&cutoff.trigger, valid_paths) {
return Err(ProtocolContractValidationError::UnknownCutoffTrigger {
name: cutoff.name.clone(),
path: cutoff.trigger.clone(),
});
}
if cutoff.path.is_empty() {
return Err(ProtocolContractValidationError::EmptyCutoffSequence {
name: cutoff.name.clone(),
});
}
for step in &cutoff.path {
if !is_addressable_session_path(step, valid_paths) {
return Err(ProtocolContractValidationError::UnknownCutoffStep {
name: cutoff.name.clone(),
path: step.clone(),
});
}
}
if let Some((previous, path)) = first_unordered_recovery_step(&cutoff.path) {
return Err(
ProtocolContractValidationError::InvalidCutoffSequenceOrder {
name: cutoff.name.clone(),
previous,
path,
},
);
}
}
Ok(())
}
fn is_addressable_session_path(path: &SessionPath, valid_paths: &BTreeSet<SessionPath>) -> bool {
path != &SessionPath::root() && valid_paths.contains(path)
}
fn first_unordered_recovery_step(path: &[SessionPath]) -> Option<(SessionPath, SessionPath)> {
path.windows(2).find_map(|window| {
let [previous, next] = window else {
return None;
};
(!is_strict_session_path_extension(previous, next))
.then(|| (previous.clone(), next.clone()))
})
}
fn is_strict_session_path_extension(previous: &SessionPath, next: &SessionPath) -> bool {
next.segments().len() > previous.segments().len()
&& next.segments().starts_with(previous.segments())
}
#[cfg(test)]
mod tests {
use super::*;
fn path(parts: &[&str]) -> SessionPath {
let mut current = SessionPath::root();
for part in parts {
current = current.child(*part);
}
current
}
#[test]
fn request_reply_contract_validates_and_roundtrips() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("get_user", client.clone(), server.clone(), "GetUser");
let response = MessageType::new("user", server.clone(), client.clone(), "UserRecord");
let mut contract = ProtocolContract::new(
"user_lookup",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract.evidence_checkpoints.push(EvidenceCheckpoint::new(
"request-enqueued",
path(&["send:get_user"]),
));
contract.timeout_law.default_timeout = Some(Duration::from_secs(5));
contract.timeout_law.per_step.push(TimeoutOverride::new(
path(&["send:get_user", "receive:user"]),
Duration::from_secs(2),
));
contract.compensation_paths.push(CompensationPath::new(
"cancel-request",
path(&["send:get_user"]),
vec![path(&["send:get_user", "receive:user", "end"])],
));
contract.cutoff_paths.push(CutoffPath::new(
"reply-cutoff",
path(&["send:get_user", "receive:user"]),
vec![path(&["send:get_user", "receive:user", "end"])],
));
contract.validate().unwrap();
let json = serde_json::to_string(&contract).unwrap();
let decoded: ProtocolContract = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, contract);
}
#[test]
fn streaming_protocol_with_choice_and_recursion_validates() {
let producer = RoleName::from("producer");
let consumer = RoleName::from("consumer");
let open = MessageType::new("open_stream", producer.clone(), consumer.clone(), "Open");
let chunk = MessageType::new("chunk", producer.clone(), consumer.clone(), "Chunk");
let close = MessageType::new("close", producer.clone(), consumer.clone(), "Close");
let contract = ProtocolContract {
name: "streaming".to_owned(),
version: SchemaVersion::new(1, 1, 0),
roles: vec![producer, consumer.clone()],
global_type: GlobalSessionType::new(SessionType::send(
open,
SessionType::recurse_point(
"stream_loop",
SessionType::choice(
consumer,
vec![
SessionBranch::new(
"chunk",
SessionType::receive(chunk, SessionType::recurse("stream_loop")),
),
SessionBranch::new(
"done",
SessionType::receive(close, SessionType::End),
),
],
),
),
)),
evidence_checkpoints: vec![EvidenceCheckpoint::new(
"chunk-ack",
path(&[
"send:open_stream",
"recurse-point:stream_loop",
"choice:consumer:chunk",
"receive:chunk",
]),
)],
timeout_law: TimeoutLaw {
default_timeout: Some(Duration::from_secs(10)),
per_step: vec![TimeoutOverride::new(
path(&[
"send:open_stream",
"recurse-point:stream_loop",
"choice:consumer:done",
"receive:close",
]),
Duration::from_secs(1),
)],
},
compensation_paths: vec![CompensationPath::new(
"rollback-stream",
path(&[
"send:open_stream",
"recurse-point:stream_loop",
"choice:consumer:chunk",
"receive:chunk",
]),
vec![path(&[
"send:open_stream",
"recurse-point:stream_loop",
"choice:consumer:done",
"receive:close",
"end",
])],
)],
cutoff_paths: vec![CutoffPath::new(
"graceful-stop",
path(&[
"send:open_stream",
"recurse-point:stream_loop",
"choice:consumer:done",
]),
vec![path(&[
"send:open_stream",
"recurse-point:stream_loop",
"choice:consumer:done",
"receive:close",
"end",
])],
)],
};
contract.validate().unwrap();
}
#[test]
fn reservation_handoff_protocol_with_branch_validates() {
let caller = RoleName::from("caller");
let steward = RoleName::from("steward");
let reserve = MessageType::new("reserve", caller.clone(), steward.clone(), "Reserve");
let granted = MessageType::new("granted", steward.clone(), caller.clone(), "Lease");
let denied = MessageType::new("denied", steward.clone(), caller.clone(), "Denied");
let handoff = MessageType::new("handoff", caller.clone(), steward.clone(), "Delegate");
let contract = ProtocolContract::new(
"reservation_handoff",
SchemaVersion::new(1, 0, 1),
vec![caller, steward.clone()],
GlobalSessionType::new(SessionType::send(
reserve,
SessionType::branch(
steward,
vec![
SessionBranch::new(
"granted",
SessionType::receive(
granted,
SessionType::send(handoff, SessionType::End),
),
),
SessionBranch::new(
"denied",
SessionType::receive(denied, SessionType::End),
),
],
),
)),
);
contract.validate().unwrap();
}
#[test]
fn undefined_recursion_label_is_rejected() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let contract = ProtocolContract::new(
"bad_loop",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::recurse("missing_loop"),
)),
);
assert_eq!(
contract.validate(),
Err(ProtocolContractValidationError::UndefinedRecursionLabel {
path: path(&["send:request"]),
label: Label::from("missing_loop"),
})
);
}
#[test]
fn unknown_evidence_path_is_rejected() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"bad_evidence",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract
.evidence_checkpoints
.push(EvidenceCheckpoint::new("missing", path(&["send:nope"])));
assert_eq!(
contract.validate(),
Err(ProtocolContractValidationError::UnknownEvidencePath {
name: "missing".to_owned(),
path: path(&["send:nope"]),
})
);
}
#[test]
fn root_evidence_path_is_rejected() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"root_evidence",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract
.evidence_checkpoints
.push(EvidenceCheckpoint::new("root", SessionPath::root()));
assert_eq!(
contract.validate(),
Err(ProtocolContractValidationError::UnknownEvidencePath {
name: "root".to_owned(),
path: SessionPath::root(),
})
);
}
#[test]
fn root_timeout_override_is_rejected() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"root_timeout",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract.timeout_law.per_step.push(TimeoutOverride::new(
SessionPath::root(),
Duration::from_secs(1),
));
assert_eq!(
contract.validate(),
Err(ProtocolContractValidationError::UnknownTimeoutPath {
path: SessionPath::root(),
})
);
}
#[test]
fn root_compensation_trigger_is_rejected() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"root_compensation",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract.compensation_paths.push(CompensationPath::new(
"rollback",
SessionPath::root(),
vec![path(&["send:request", "receive:response", "end"])],
));
assert_eq!(
contract.validate(),
Err(
ProtocolContractValidationError::UnknownCompensationTrigger {
name: "rollback".to_owned(),
path: SessionPath::root(),
}
)
);
}
#[test]
fn root_cutoff_step_is_rejected() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"root_cutoff",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract.cutoff_paths.push(CutoffPath::new(
"graceful",
path(&["send:request"]),
vec![SessionPath::root()],
));
assert_eq!(
contract.validate(),
Err(ProtocolContractValidationError::UnknownCutoffStep {
name: "graceful".to_owned(),
path: SessionPath::root(),
})
);
}
#[test]
fn compensation_path_must_progress_forward() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"unordered_compensation",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract.compensation_paths.push(CompensationPath::new(
"rollback",
path(&["send:request"]),
vec![
path(&["send:request", "receive:response", "end"]),
path(&["send:request", "receive:response"]),
],
));
assert_eq!(
contract.validate(),
Err(
ProtocolContractValidationError::InvalidCompensationSequenceOrder {
name: "rollback".to_owned(),
previous: path(&["send:request", "receive:response", "end"]),
path: path(&["send:request", "receive:response"]),
}
)
);
}
#[test]
fn cutoff_path_must_progress_forward() {
let client = RoleName::from("client");
let server = RoleName::from("server");
let request = MessageType::new("request", client.clone(), server.clone(), "Req");
let response = MessageType::new("response", server.clone(), client.clone(), "Resp");
let mut contract = ProtocolContract::new(
"unordered_cutoff",
SchemaVersion::new(1, 0, 0),
vec![client, server],
GlobalSessionType::new(SessionType::send(
request,
SessionType::receive(response, SessionType::End),
)),
);
contract.cutoff_paths.push(CutoffPath::new(
"graceful",
path(&["send:request"]),
vec![
path(&["send:request", "receive:response", "end"]),
path(&["send:request", "receive:response"]),
],
));
assert_eq!(
contract.validate(),
Err(
ProtocolContractValidationError::InvalidCutoffSequenceOrder {
name: "graceful".to_owned(),
previous: path(&["send:request", "receive:response", "end"]),
path: path(&["send:request", "receive:response"]),
}
)
);
}
}