Conversation: For Tech & Product
Every protocol you've shipped assumes the spec doesn't change during the connection. This one doesn't. Code is typed, composable, content-addressed, runtime-loadable, and cryptographically verified.
If you're coming from the main piece: this is the implementation.
The Architecture
Session types fix the types before the session begins. The actor model doesn't constrain message structure. BEAM hot code loading evolves the system but not its specification. Current tooling treats versioning, code, and execution as separate problems.
Conversation does not. Code is typed, composable, content-addressed, runtime-loadable, and cryptographically verified. The spec changes mid-flight. That's a feature not a bug.
The Language
.conv files declare domain grammars. A grammar defines a domain's type surface and action interface:
in @actor
grammar @compiler {
type = target | artifact | request | result
type target = gleam | elixir | rust | fortran | eaf
type artifact = source | bytecode | oid | blob
action compile {
source: artifact
target: target
}
}
Types are sum types: tagged variants with optional parameterization. Actions are named operations with typed fields. The in @actor declaration is a lens. It declares a dependency on @actor, making its types available for reference. The boot sequence ensures @actor is compiled first.
The root definition is two primitives: grammar | type. Everything else is composition.
The Pipeline
Source becomes running actor through four content-addressed phases. The Rust NIF crosses the boundary through Rustler --- two functions (parse_conv and compile_grammar), both running on dirty CPU schedulers to avoid blocking the BEAM's normal scheduling.
Parse. Source text becomes a content-addressed tree:
Decl:grammar:@compiler
Decl:type:target
Atom:variant:gleam
Atom:variant:elixir
Decl:action:compile
Atom:field:source
Atom:field:target
Each node's content address is kind:name:value. Compatible with Git's object model --- nodes map to blobs, subtrees to trees.
Resolve. The AST is validated against a type registry. Type references, cross-domain action calls, and in dependencies are checked. The extending grammar's types must be compatible with its parent's type surface.
Compile. The resolved tree emits Erlang Abstract Format (EAF). Each grammar becomes a BEAM module:
-module(conv_compiler).
-export([compile/1, lenses/0, extends/0]).
compile(Args) -> gen_server:call('compiler', {compile, Args}).
lenses() -> [<<"actor">>].
extends() -> [].
Modules are prefixed conv_ to avoid namespace collisions --- @erlang compiles to conv_erlang.
Load and Supervise. EAF is loaded via compile:forms/1. Each domain starts as a supervised server registered under its atom. Crashed domains restart. Shut-down domains stay down.
Each phase is a typed, traced transformation --- each trace carries a content address and a reference to its parent trace. Identical inputs produce cached results without recomputation.
The Cairn Pattern
Actor identity is deterministic. A root keypair derives each actor's key:
reed@systemic.engineering (root keypair)
→ sha512(root_pub || "compiler") → @compiler keypair
→ sha512(root_pub || "filesystem") → @filesystem keypair
Anyone who knows the root public key can derive any actor's public key and verify its traces. The root key is the single thing you distribute. No key exchange. No certificate authority. No registry.
This applies the same intuition as content-addressed storage (Git, IPFS) to actor identity rather than data identity. Where Git asks "what is the content?" and derives an address, conversation asks "what is the domain?" and derives a keypair. The hierarchy expresses trust topology without sacrificing determinism.
Key rotation is a traced event*, not a derivation change:
Trace(KeyRotation,
domain: "compiler",
old_pub: <old>,
new_pub: <new>,
signed_by: <old>)
The old key signs the rotation. The new key signs subsequent traces. Verification follows the rotation chain.
Witnessed Compilation*
The compiler doesn't merely produce output. It witnesses the transformation.
When @compiler compiles a grammar, each phase produces a traced output:
source: "in @actor\ngrammar @compiler { ... }"
→ parse → Trace(AST, oid: 7a3f...)
→ resolve → Trace(Resolve, oid: b2c1..., parent: 7a3f...)
→ compile → Trace(Compile, oid: e5d9..., parent: b2c1...)
→ swap → Trace(Swap, oid: 1f8a..., parent: e5d9...)
signed by: Ed25519(sha512("compiler"))
Each trace binds source to artifact under a specific actor identity. The chain is the compilation receipt: an auditable, cryptographic proof that this source became this module, witnessed by this compiler.
Without this, the BEAM's hot code loading is a trust hole. Any process can load arbitrary code via compile:forms/1. With witnessed compilation, every running module has a verifiable chain back to its source. When you hot-swap a module at runtime, the swap itself is traced. Supply chain integrity for a runtime that was designed before supply chain attacks existed.
Grammar Composition as Architecture
Two composition primitives. in declares a lens: an import dependency. The in @actor in the compiler grammar above means @compiler depends on @actor. Its lenses/0 returns [<<"actor">>]. The boot sequence validates that @actor is compiled before @compiler is considered resolved.
extends declares type surface inheritance:
grammar @fox extends @smash {
type = dodge | counter
}
@fox inherits @smash's type surface. Its extends/0 returns [<<"smash">>]. Composition through extends is transitive.
Actions compose across domains. Visibility* controls how:
grammar @filesystem {
type = file | path
public action read {
path: path
}
protected action write {
path: path
content: file
}
}
Public actions inline at the call site. The compiled module exports the function directly. For stateless operations.
Protected actions dispatch through gen_server:call. Serialized. Coordinated. For actions that need state or ordering guarantees.
Private actions are domain-internal. Helper functions not exported from the compiled module.
%% public — caller executes directly
read(Args) -> file:read_file(maps:get(path, Args)).
%% protected — coordinated through domain server
write(Args) -> gen_server:call('filesystem', {write, Args}).
Every domain server has one native primitive --- exec:
handle_call({exec, {M, F, A}}, _From, State) ->
Result = apply(M, F, A),
{reply, {ok, Result}, State};
exec is apply/3 on the BEAM. All other actions are coordination points that compose down to it.
New grammars compile and load at runtime. Each one extends the protocol surface available to every other grammar. The specification grows through examples. The growth is cryptographically witnessed.
What's Next
The formal paper follows. With formalization, benchmarks, and distribution.
Read the full thesis: Conversation: The Self-Learning Protocol
* Active work in progress.