Two systems that talk to each other must agree on their types. Canopy IDL is where those
types are defined — once — and from that single definition they flow outward: across the
wire, into the code that never crosses a boundary, and down into storage.
The types don't stop at the boundary
The moment two systems share an interface, they share its types. But the generated types are
ordinary types as in C++ or Rust — so they don't stay penned at the edge of the program. They are
used
directly by the code that never marshals anything, which means there is no second set of
"domain" types to translate to and from. And because the same machinery that marshals a call
can serialise a value to bytes, the very same definitions can describe how data is stored.
One schema ends up governing how a system talks, computes, and persists.
This is the long game. The further the same definitions reach — past the
wire, into the logic, down into the database — the more the IDL stops being "the marshalling
format" and becomes the canonical type system the whole system is built on.
Coming soon
A language-independent surface for the same wire type system.
Canopy's current IDL deliberately resembles C++. The next IDL surface will use
language-independent, Rust- and Protocol Buffers-like type spelling while preserving Canopy's
canonical wire type system, contract identities, and compatibility rules. It is a clearer way to
express the same boundary contract, not a second RPC protocol.
Neutral type vocabulary
Scalar, collection, optional, variant, interface, and result types will be expressed without
making C++ syntax the default language of the contract.
Compatible native-type overrides
Where an implementation needs an alternative compatible native representation for a
serialised type, the original C++-flavoured spelling can be specified explicitly as an
override without changing the canonical wire type. For example, one dictionary type may
be represented in C++ as either
std::map<K, V> or std::unordered_map<K, V>. The
override
selects the native container; the dictionary's declared key type, value type, and wire
meaning remain the same.
Const and move by default
Functions will be const by default, and values will use move semantics by default. The
common efficient behaviour becomes the concise form; exceptions remain explicit.
Trailing return types
The new grammar will support trailing return types, keeping longer function declarations
readable and making the callable shape natural across generated languages.
Informed by proven interface models
The WebAssembly Component Model, Protocol Buffers, Apache Thrift, and JSON will all inform
the design. Their approaches to portable types, schema evolution, tooling, and
human-readable
interchange will be evaluated without giving up Canopy's wire identity or capability model.
Interfaces, not services
A gRPC-style service declaration introduces a runtime concept into the schema
and may deliberately have no direct mapping. Canopy's IDL defines wire types and
exchangeable
interfaces instead. Interfaces are exchanged over a specified transport and addressing
scheme chosen at runtime or through configuration, keeping the contract independent of its
deployment.
Structured errors and results
Error codes and return types will be refined around explicit success-or-error results. A
typed result can map naturally to std::expected<T, E> in modern C++,
Result<T, E> in Rust, or the equivalent construct in another language,
while retaining one language-independent contract.
Explicit numbered namespaces such as
v1 remain strongly recommended. The surface syntax may evolve, but wire-version and
contract identity should always remain visible.
Imperative code, functional-grade plumbing
C++ is an imperative language, and hand-written boundary glue is imperative at its most
error-prone: mutable buffers, manual ordering, lifetimes juggled by hand — and subtly
different on every boundary. It is exactly the code most likely to hide a bug that surfaces on
one transport and not another.
The IDL is declarative. You state the contract — the methods, the types, the
direction of each parameter — and nothing about how it is carried. Declarative definitions
remove boilerplate by construction, and generating the boundary from one means the whole path
from proxy call to stub dispatch is produced for you: serialise the arguments, move the bytes,
deserialise them, invoke your implementation. Because that path is generated rather than typed
out by hand, it carries guarantees that functional programming usually needs a dedicated
language to enforce — while your runtime stays plain imperative C++:
Deterministic. The same contract generates the same path every time — no
drift between boundaries, no marshalling bug on one transport but not another.
Referentially transparent. Given the same inputs the boundary behaves the
same way, whichever transport or serialiser sits underneath it.
Free of incidental state. The generated layer does one thing — call your
implementation correctly — and keeps no hidden state of its own.
Total. Every declared method and type is handled exhaustively; there is no
half-written case left to be forgotten.
It borrows the principle behind functional core, imperative shell — separate the
deterministic part from the effectful part — and bakes it into the boundary itself: a
generated, deterministic contract layer kept cleanly apart from the imperative implementation it
wraps. You write your business logic the way you always have; the plumbing earns the
discipline.
Only two things are allowed to break that determinism, and both are
deliberate: logging and telemetry layered into the generated path for
observability, and the expected failures of an unreliable wire — a dropped
connection, a timeout — which the contract surfaces as errors rather than hiding. Everything
else is the same code, doing the same thing, on every boundary.
The anatomy of an interface definition
The current Canopy IDL reads like trimmed-down C++ with a few RPC-specific attributes in square
brackets. Here is each construct used by the current format, building from data types up through
interfaces and remote references.
These examples use C++-flavoured spellings such as
std::string and std::vector. The
language-independent surface described above will retain
the same wire type system while making language-specific native types explicit overrides.
Namespaces and versioning
Namespaces group interfaces and structs, and can nest. We strongly recommend putting every durable
contract inside an explicit numbered namespace such as v1, and using that version in
the fully qualified type name. Callers then state exactly which contract they were built against,
while v1 and v2 can coexist during migration.
Explicit version names make compatibility boundaries visible in code, generated
bindings, fingerprints, diagnostics, and policy. They also prevent a new default version from
silently changing the meaning of an existing unqualified interface name.
namespace calculator
{
namespace v1
{
interface i_calculator
{
error_code add(int a, int b, [out]int& result);
};
}
}
Enums
Enums carry an explicit underlying type, keeping their numeric domain consistent across generated
bindings and serialisers. Enum values are also how [tag=…] method routing is
declared.
Structs are plain data: members, defaults, vectors and maps, and nesting. They are ordinary
generated C++ types, so the same definition is used directly in your in-process code — there is
no separate DTO layer to translate to and from.
struct config
{
std::string name = "default";
int timeout = 30;
std::vector<std::string> tags;
std::map<std::string, int> name_to_id;
};
Optional and variant
Some types use Canopy's own wrappers, rpc::optional<T> and
rpc::variant<Ts…>. The generator deliberately rejects
std::optional and std::variant so that YAS binary, Protobuf, Nanopb,
and JSON all agree on one wire shape. In JSON an absent optional is simply omitted, and an
explicit null loads back as empty.
Interfaces are the RPC contracts: methods returning an error_code (a typedef you
define, conventionally 0 = OK), with parameter direction in brackets. They can
inherit — including multiple inheritance — carry [description] and
[status] documentation, and mark one-way methods with [post]. The full
attribute set is listed under the annotation vocabulary below.
[status=production, description="Calculator interface"]interface i_calculator
{
error_code add(int a, int b, [out]int& result);
// fire-and-forget: returns immediately, ordered, no [out] params[post] error_code log_event(conststd::string& message);
};
Remote references: shared_ptr vs optimistic_ptr
An interface can be passed as a parameter or returned — that is how one runtime component hands
another a callable object across a boundary. Two pointer types express the ownership:
rpc::shared_ptr<i_foo>
An owning handle. It extends C++ RAII across the network: while you hold it the remote
object is kept alive, and releasing it releases the remote reference. Use it for objects
whose lifetime you own.
rpc::optimistic_ptr<i_foo>
A non-owning, callable reference — a weak pointer that keeps a channel open without
pinning the remote object's lifetime. It breaks the ownership cycles that two-way
callbacks and long-lived objects (a database, an LLM) would otherwise create.
interface i_host
{
// hand back an owning reference to a created object
error_code create_example([out]rpc::shared_ptr<i_example>& target);
// accept an object owned by the caller
error_code set_app(conststd::string& name,
[in]constrpc::shared_ptr<i_example> app);
};
Smart-pointer parameters are [in]or[out],
never [in, out]; and [post] methods cannot pass interface pointers at
all. Raw C++ pointers are rejected too — an address has no meaning in another address space.
Composing across files — #import vs #include
Almost always reach for #import: it makes the generator aware of types defined
elsewhere and reuses their marshalling, without regenerating them. #include
copy-pastes the file like the C preprocessor — handy for shared #defines, but a
source of duplicate-symbol errors if the same types land twice.
#import"shared/types.idl"// preferred: reference, don't regenerate#include"macros.idl"// copy-paste; use sparingly (#defines etc.)
What gets generated
Proxy
Caller-side typed C++ methods. Holds an rpc::shared_ptr<i_foo> or
rpc::optimistic_ptr<i_foo> — callers
invoke it exactly like a local object.
Stub
Callee-side dispatch layer. Deserialises parameters and calls the real implementation;
serialises the return values back.
Long-lived & callback interfaces
When a server and client both need to call each other, or an interface implementation
outlives its callers — a database, an LLM — a non-owning reference is needed.
rpc::optimistic_ptr<i_foo> is that: a callable weak pointer that
keeps the channel open without locking the remote object's lifetime. It breaks the
circular dependencies that two-way interfaces would otherwise create.
[post] one-way calls
Fire-and-forget methods. The caller sends and continues immediately — no reply is
awaited beyond local errors. The basis for high-rate feeds: price data, telemetry,
media frames, and streamed LLM tokens.
Use explicit versioned namespaces such as
v1 from the first published contract. Add v2 beside it when a breaking
change is needed, keep both callable during migration, and retire the old version deliberately.
The annotation vocabulary
Attributes in square brackets add RPC-specific meaning to a declaration without changing the
types themselves. They are how an interface carries documentation, lifecycle, dispatch
behaviour, and parameter direction — all of which the generator and the reflection metadata
pick up.
[description="…"]Human-readable documentation on a namespace, interface, method, struct, or member.
It travels into the reflection metadata that powers MCP and IDE tooling — the docs
you write stay attached to the type.
[status=…]Declares maturity: development, demo,
production, or deprecated. A production
interface is fingerprint-checked so its wire contract cannot silently drift;
deprecated marks it for removal.
[deprecated]On a method: emits C++ [[deprecated]] so every caller gets a
compile-time warning, without removing the method.
[post]A fire-and-forget, one-way method — returns immediately, expects no reply,
preserves send order. The basis for high-rate feeds.
[tag=…]Attaches a value carried through to i_marshaller::send() /
post(). The streaming transport uses tags for one-way traffic handling, and
custom components can interpret them for application-specific dispatch or telemetry.
[const]Marks a method const. It is an attribute because the parser does not accept a
trailing const after the parameter list.
[in] / [out]Parameter direction. [in] is the default; [out] returns
a value to the caller through a reference parameter.
member = valueDefault values for struct members and rpc::optional fields — an
omitted optional is filled with its default.
One definition also powers your tools
Beyond proxy and stub code, the same IDL emits machine-readable metadata:
reflection schemas that let a running component describe its interfaces, and configuration
schemas that bind exchanged interfaces to transports and addresses. Both are derived from the one
source of truth, so the tools that consume them are never out of step with the running system.
MCP → LLMs
AI agents read the reflection metadata over MCP to discover which interfaces a component
exposes and how to call them correctly. The IDL becomes a contract an LLM can act on
directly — no bespoke, hand-written integration per interface.
Language services → IDEs
The same schemas drive editor language services: autocomplete, type checking, and
inline validation while authoring against an interface or its configuration — the
IntelliSense experience comes for free from the definition.
CMake integration
The CanopyGenerate CMake command takes an IDL file and produces
C++ proxy, stub, and serialisation glue for each requested format. Multiple
formats from the same IDL are supported.