Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

DSL Extensions

This document explains how to extend the choreography DSL with runtime effects and syntax extensions.

Overview

The extension system has two parts. Runtime extensions add type-safe effects that can be inserted into Program sequences. Syntax extensions add new grammar rules and statement parsers to the DSL.

Runtime effect extensions are projected to roles during compilation and dispatched by interpret_extensible at runtime. Syntax extensions are registered through the parser and grammar composition APIs.

The repository contains two extension registries with the same name:

  • effects::registry::ExtensionRegistry<E, R> for runtime extension effect handlers.
  • extensions::ExtensionRegistry for DSL grammar and parser extensions.

Simulator Integration for Extensions

Extension projects often need protocol-machine-level regression tests in addition to parser tests. Use telltale-simulator harness APIs to run projected local types under scenario middleware. This keeps extension validation aligned with protocol-machine effect contracts.

#![allow(unused)]
fn main() {
let harness = SimulationHarness::new(&DirectAdapter::new(&handler));
let result = harness.run(&spec)?;
assert_contracts(&result, &ContractCheckConfig::default())?;
}

This pattern makes extension runtime checks reusable across projects. See Simulation Overview for harness config files and preset constructors.

Runtime Effect Extensions

ExtensionEffect Trait

Extensions implement ExtensionEffect and specify which roles participate.

#![allow(unused)]
fn main() {
pub trait ExtensionEffect<R: RoleId>: Send + Sync + Debug {
    fn type_id(&self) -> TypeId;
    fn type_name(&self) -> &'static str;
    fn participating_roles(&self) -> Vec<R> { vec![] }
    fn as_any(&self) -> &dyn Any;
    fn as_any_mut(&mut self) -> &mut dyn Any;
    fn clone_box(&self) -> Box<dyn ExtensionEffect<R>>;
}
}

The default participating_roles implementation returns an empty vector, which makes the extension global. A non-empty vector limits the extension to specific roles.

Defining an Extension

#![allow(unused)]
fn main() {
#[derive(Clone, Debug)]
pub struct ValidateCapability<R> {
    pub role: R,
    pub capability: String,
}

impl<R: RoleId> ExtensionEffect<R> for ValidateCapability<R> {
    fn type_id(&self) -> TypeId {
        TypeId::of::<Self>()
    }

    fn type_name(&self) -> &'static str {
        "ValidateCapability"
    }

    fn participating_roles(&self) -> Vec<R> {
        vec![self.role]
    }

    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }

    fn clone_box(&self) -> Box<dyn ExtensionEffect<R>> {
        Box::new(self.clone())
    }
}
}

This extension only appears in the projection for role. Use an empty vector to make it global.

Extension Registry

Handlers register extension logic in an ExtensionRegistry.

#![allow(unused)]
fn main() {
let mut registry = ExtensionRegistry::new();
registry.register::<ValidateCapability<Role>, _>(|_ep, ext| {
    Box::pin(async move {
        let validate = ext
            .as_any()
            .downcast_ref::<ValidateCapability<Role>>()
            .ok_or_else(|| ExtensionError::TypeMismatch {
                expected: "ValidateCapability",
                actual: ext.type_name(),
            })?;
        tracing::info!(cap = %validate.capability, "checked capability");
        Ok(())
    })
})?;
}

The register method returns an error on duplicate handlers. Use ExtensionRegistry::merge to compose registries across modules.

Error Handling

Extension errors surface as ExtensionError.

#![allow(unused)]
fn main() {
pub enum ExtensionError {
    UnknownExtension { type_name: &'static str, type_id: TypeId },
    HandlerNotRegistered { type_name: &'static str },
    ExecutionFailed { type_name: &'static str, error: String },
    TypeMismatch { expected: &'static str, actual: &'static str },
    DuplicateHandler { type_name: &'static str },
    MergeConflict { type_name: &'static str },
}
}

Handlers should register all required extensions before interpretation. This keeps failures at startup instead of in the middle of protocol execution.

Interpreter Integration

Use interpret_extensible with handlers that implement ExtensibleHandler.

#![allow(unused)]
fn main() {
let mut handler = MyHandler::new();
let mut endpoint = ();
let result = interpret_extensible(&mut handler, &mut endpoint, program).await?;
}

The non-extensible interpret function does not dispatch extensions. That runtime is now an internal choreography-layer surface, not the primary public entrypoint for application code.

Using Extensions in Programs

Extensions should be introduced from the DSL/parser side so they remain part of the validated semantic surface. Do not teach or depend on manual Program::ext construction as the public modeling path.

Syntax Extensions

GrammarExtension Trait

Grammar extensions provide Pest rules and a list of statement rules they handle.

#![allow(unused)]
fn main() {
pub trait GrammarExtension: Send + Sync + Debug {
    fn grammar_rules(&self) -> &'static str;
    fn statement_rules(&self) -> Vec<&'static str>;
    fn priority(&self) -> u32 { 100 }
    fn extension_id(&self) -> &'static str;
}
}

The priority value resolves conflicts when multiple extensions define the same rule. Higher priority wins and conflicts are recorded by the registry.

StatementParser and ProtocolExtension

Statement parsers translate matched rules into protocol extensions.

#![allow(unused)]
fn main() {
pub trait StatementParser: Send + Sync + Debug {
    fn can_parse(&self, rule_name: &str) -> bool;
    fn supported_rules(&self) -> Vec<String>;
    fn parse_statement(
        &self,
        rule_name: &str,
        content: &str,
        context: &ParseContext,
    ) -> Result<Box<dyn ProtocolExtension>, ParseError>;
}
}

ProtocolExtension instances participate in validation, projection, and code generation.

#![allow(unused)]
fn main() {
pub trait ProtocolExtension: Send + Sync + Debug {
    fn type_name(&self) -> &'static str;
    fn mentions_role(&self, role: &Role) -> bool;
    fn validate(&self, roles: &[Role]) -> Result<(), ExtensionValidationError>;
    fn project(&self, role: &Role, context: &ProjectionContext) -> Result<LocalType, ProjectionError>;
    fn generate_code(&self, context: &CodegenContext) -> proc_macro2::TokenStream;
    fn as_any(&self) -> &dyn Any;
    fn as_any_mut(&mut self) -> &mut dyn Any;
    fn type_id(&self) -> TypeId;
}
}

Use these traits to attach new DSL constructs to projection and codegen.

ExtensionRegistry for Syntax

The ExtensionRegistry stores grammar extensions and statement parsers.

#![allow(unused)]
fn main() {
let mut registry = ExtensionRegistry::new();
registry.register_grammar(MyGrammarExtension)?;
registry.register_parser(MyStatementParser, "my_parser".to_string());
}

The registry tracks rule conflicts and supports dependency checks. Use get_detailed_conflicts for human-readable conflict reports.

GrammarComposer

GrammarComposer combines the base grammar with registered extension rules and caches the result.

#![allow(unused)]
fn main() {
let mut composer = GrammarComposer::new();
composer.register_extension(MyGrammarExtension)?;
let grammar = composer.compose()?;
}

The cache avoids recomposing the grammar when the extension set has not changed.

ExtensionParser

ExtensionParser wires the grammar composer into the parsing pipeline.

#![allow(unused)]
fn main() {
let mut parser = ExtensionParser::new();
parser.register_extension(MyGrammarExtension, MyStatementParser)?;
let choreography = parser.parse_with_extensions(source)?;
}

The parse_with_extensions method now composes the grammar metadata and routes registered statement rules through the shared extension registry. Supported extension statements are parsed deterministically into Protocol::Extension nodes instead of falling back to the standard parser unchanged.

Extension Discovery

The extensions::discovery module provides metadata and dependency management.

#![allow(unused)]
fn main() {
let mut discovery = ExtensionDiscovery::new();
discovery.add_search_path("./extensions");
let registry = discovery.create_registry(&["timeout".to_string()])?;
}

Discovery assembles an ExtensionRegistry in dependency order and performs basic validation.

Complete Workflow Example

This section shows an end-to-end workflow for building and running a runtime extension.

Step 1: Add Dependencies

Add the choreography crate and an async runtime.

[dependencies]
telltale-runtime = "11.3.0"
tokio = { version = "1", features = ["full"] }

Use a path dependency only for local workspace development. External projects should pin to a release version.

Step 2: Define Roles and Messages

Define roles, labels, and messages for your protocol.

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use telltale_runtime::{LabelId, RoleId, RoleName};

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum Role { Client, Server }

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum Label { Ok }

impl LabelId for Label {
    fn as_str(&self) -> &'static str {
        match self { Label::Ok => "ok" }
    }
    fn from_str(label: &str) -> Option<Self> {
        match label { "ok" => Some(Label::Ok), _ => None }
    }
}

impl RoleId for Role {
    type Label = Label;
    fn role_name(&self) -> RoleName {
        match self {
            Role::Client => RoleName::from_static("Client"),
            Role::Server => RoleName::from_static("Server"),
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
enum Message { Ping, Pong }
}

Roles implement RoleId. Labels implement LabelId. Messages are serializable.

Step 3: Build an Extensible Handler

Register extension handlers and implement ExtensibleHandler.

#![allow(unused)]
fn main() {
struct DomainHandler {
    registry: ExtensionRegistry<(), Role>,
}

impl DomainHandler {
    fn new() -> Self {
        let mut registry = ExtensionRegistry::new();
        registry
            .register::<ValidateCapability<Role>, _>(|_ep, ext| {
                Box::pin(async move {
                    let validate = ext
                        .as_any()
                        .downcast_ref::<ValidateCapability<Role>>()
                        .ok_or(ExtensionError::TypeMismatch {
                            expected: "ValidateCapability",
                            actual: ext.type_name(),
                        })?;
                    tracing::info!(cap = %validate.capability, "validated");
                    Ok(())
                })
            })
            .expect("register extension");
        Self { registry }
    }
}
}

ExtensionRegistry stores handlers for each extension type. The interpret_extensible entry point uses this registry during execution.

Step 4: Build and Run a Program

Extensions remain available in the low-level choreography runtime via interpret_extensible, but that path is internal. The public modeling path is to parse/validate extension-bearing DSL and lower it through the canonical semantic surface rather than hand-assembling extension programs.

Testing

Unit tests can validate registry setup and projection behavior.

#![allow(unused)]
fn main() {
#[test]
fn test_registry() {
    let handler = DomainHandler::new();
    assert!(handler
        .extension_registry()
        .is_registered::<ValidateCapability<Role>>());
}
}

Use integration tests to check end-to-end protocol behavior with interpret_extensible.

Built-In Extensions

The extensions::timeout module contains a sample grammar extension, parser, and protocol extension. It is intended as a reference implementation and currently uses simplified parsing logic.