MEP 66. Mochi and Erlang/OTP package bridge
| Field | Value |
|---|---|
| MEP | 66 |
| Title | Mochi and Erlang/OTP package bridge |
| Author | Mochi core |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-05-29 22:46 (GMT+7) |
| Depends | MEP-1 (Grammar, for the import erlang extension), MEP-2 (AST), MEP-4 (Type System), MEP-13 (ADTs and Match, for Result/optional translation), MEP-57 (Mochi module and package system, for mochi.toml, mochi.lock, the content-addressed object store, the capability declaration model, and OIDC trusted publishing) |
| Research | /docs/research/0066/ |
| Tracking | /docs/implementation/0066/ |
Abstract
Mochi today (May 2026, after MEP-57's source-level package system reached Draft) bridges five foreign ecosystems: Go, Python, TypeScript, Rust, and Ruby. Erlang/OTP is the sixth. The Erlang ecosystem occupies a unique niche: it is the canonical platform for distributed, fault-tolerant, long-running concurrent systems. The BEAM virtual machine, the OTP supervision framework, and the 18,000+ packages on Hex.pm represent decades of battle-tested infrastructure for telephony, messaging, and real-time systems.
MEP-66 specifies the bidirectional Erlang/OTP bridge: Mochi programs can consume any Hex.pm package via import erlang "<package>@<semver>" as <alias> with no user-written boilerplate, and Mochi packages can publish to Hex.pm as Erlang applications via mochi pkg publish --to=hex.pm. The bridge does not require writing a NIF (Native Implemented Function): it uses OTP's Port protocol, the officially sanctioned mechanism for running external programs alongside a BEAM node. The synthesised shim.erl spawns the Mochi binary as an Erlang Port, exchanges messages encoded in Erlang External Term Format (ETF), and wraps the call-response cycle behind a familiar Erlang module API.
The type information for each package comes from the package's compiled .beam files: Erlang stores its abstract syntax tree inside each .beam in the Dbgi chunk (OTP 20+) or the Abst chunk (OTP 17-19), encoded as ETF. The bridge decodes this chunk in Go using an ETF parser, extracts all -spec and -type directives, and translates them through a closed Dialyzer-typespec-to-Mochi table. Packages that ship no typespecs fall back to EDoc XML. Items outside the closed table are skipped with a structured SkipReport.
The proposal builds on MEP-57's manifest / lockfile / capability infrastructure and adds a self-contained package3/erlang/ component. The Mochi grammar gains the single keyword erlang as a valid <lang> token. The lockfile gains one new repeated table ([[erlang-package]]). No existing transpiler MEP needs to change.
Motivation
Mochi today integrates with foreign ecosystems through Go, Python, TypeScript, Rust, and Ruby. Erlang is the sixth. The Erlang gap is distinct in character from every previous gap:
-
The BEAM/OTP ecosystem is the canonical home of distributed, fault-tolerant concurrent systems. Libraries such as
cowboy(HTTP server),ranch(TCP acceptor pool),hackney(HTTP client),gun(HTTP/2 client),jose(JWT/JWE/JWK),poolboy(worker pool),telemetry(metrics), andopentelemetry_apihave no equivalent in Mochi's standard library and no close substitute in the other bridged ecosystems. A Mochi program targeting an embedded or distributed systems context needs access to these libraries without writing Erlang boilerplate. -
Hex.pm is the shared registry for both Erlang and Elixir packages. As of May 2026, Hex.pm hosts 18,000+ packages across both ecosystems. Many Erlang packages are consumed by Elixir projects (and vice versa) because they share the BEAM VM. This means the fixture corpus, the index client, and the publish flow designed for Erlang automatically cover a large slice of the Elixir library surface as well, without requiring a separate MEP for Elixir.
-
BEAM abstract code is a machine-readable, in-artifact type source that does not require running the Erlang compiler. Erlang
.beamfiles carry their entire abstract syntax tree inside a compressed ETF chunk. This chunk contains every-spec,-type, and-opaquedirective the module author wrote, without any transformation or summary loss. Decoding this chunk from Go gives the bridge access to the module's full type information, analogous to rustdoc-JSON for Rust (MEP-73) and RBS for Ruby (MEP-76). No Erlang runtime is required at ingest time: the bridge reads the.beamfile as bytes and decodes ETF natively. -
OTP Port protocol is the blessed way to integrate non-BEAM programs, and it requires zero kernel changes. Erlang's Port mechanism (spawning an external OS program and communicating via stdin/stdout + ETF packet framing) is OTP's officially sanctioned integration path for non-BEAM code. Ports are supervised by the same OTP supervision trees that supervise Erlang processes. A crashing Port is restarted by its supervisor; it does not take down the BEAM node. The alternative, a NIF (Native Implemented Function), loads a shared library directly into the BEAM VM and runs in the VM's scheduler threads: a crash is fatal. MEP-66 uses Ports by default. A future sub-phase (MEP-66 N.1) may offer an opt-in NIF path for latency-sensitive applications, but Port is the default and the safe choice.
-
Dialyzer's typespec system is a mature, widely-used annotation language for the Erlang standard library and most popular packages. The Erlang standard library (OTP) ships
-specannotations for every exported function. Projects following OTP conventions annotate their public APIs with-spec. Dialyzer (the type analysis tool) has been part of OTP since OTP-9 (2008). The closed typespec-to-Mochi table can cover the most common Erlang idioms without requiring community-maintained annotation databases likegem_rbs_collection. -
Hex.pm trusted publishing (2024) is the current supply-chain best practice for the Erlang/Elixir ecosystem. Hex.pm launched Hex Trusted Publishing in 2024, using GitHub Actions
id-token: writeto obtain a short-lived OIDC token. This is the same pattern MEP-57 mandates for all publish flows. Long-livedHEX_API_KEYtokens are not accepted by MEP-66 for the same reasons MEP-73 rejectsCARGO_REGISTRY_TOKENand MEP-76 rejectsGEM_HOST_API_KEY.
Specification
This section is normative. Sub-notes under /docs/research/0066/ are informative.
1. Pipeline overview
MEP-66 introduces a per-import Erlang dependency resolution layer that sits between the Mochi parser (after MEP-57 has resolved mochi.toml) and the Mochi build driver:
mochi.toml [erlang-dependencies]
| pkgmanifest.Parse + pkgsolver.Solve (MEP-57)
v
resolved Erlang dep tree (package name + version + source URL)
| package3/erlang/hexindex.Fetch (Hex.pm HTTP API v2)
v
.tar.gz archives in ~/.cache/mochi/erlang-deps/<sha512-hex>/
| package3/erlang/beamingest.Ingest (decode Dbgi/Abst chunk from each .beam)
| package3/erlang/edocingest.Ingest (EDoc XML fallback when no -spec)
v
TypespecSet per module (or EDocModule pseudo-specs)
| package3/erlang/typemap.Translate (closed typespec-to-Mochi table)
v
TranslatedSurface + SkipReport per application
| package3/erlang/portemit.Emit (shim.erl: gen_server + Port process)
v
erlang_shims/<app>/shim.erl (emitted into build output directory)
| package3/erlang/externemit.Emit (Mochi extern fn / extern type corpus)
v
synthesised erlang_shims/<app>/shim.mochi, imported by user source
| Mochi Driver.Build (native binary + rebar3 compile of shim)
v
native binary + compiled BEAM shim bytecode, launched as Port pair
The bridge does not run the Erlang runtime at ingest time. BEAM file parsing and ETF decoding are done entirely in Go. The .erl shim is emitted as source by the Go side; a rebar3 compile step (run by the build orchestration in phase 7) compiles the shim to .beam bytecode, which is then deployed alongside the compiled Mochi native binary.
2. Manifest extension: [erlang-dependencies], [erlang], and [erlang.publish]
The MEP-57 mochi.toml gains three new optional top-level tables:
[erlang-dependencies]
cowboy = "~> 2.12"
hackney = "~> 1.20"
jose = "~> 1.11"
telemetry = ">= 1.2.0 and < 2.0.0"
poolboy = { version = "~> 1.5", override = true }
my-local-app = { path = "../my_erlang_app" }
my-git-app = { git = "https://github.com/example/my_app", ref = "main" }
[erlang]
otp-version = "27"
rebar3-version = "~> 3.23"
elixir-compat = false
[erlang.publish]
app-name = "my_mochi_app"
description = "A Mochi package published as an Erlang application."
version = "1.0.0"
licenses = ["Apache-2.0"]
links = { "GitHub" = "https://github.com/example/my_mochi_app" }
maintainers = ["tamnd"]
files = ["ebin/**/*.beam", "src/**/*.erl", "include/**/*.hrl", "priv/**"]
build-tools = ["rebar3"]
[erlang-dependencies] follows Hex.pm's constraint grammar: a string is shorthand for a version constraint using Hex.pm's version range syntax (~> for pessimistic, >=/</and for compound ranges). This is intentional: Mochi users with Erlang/Elixir background can copy entries from an existing rebar.config or mix.exs deps list without translation. The bridge passes each entry to the synthesised rebar.config via a {<name>, "<constraint>"} deps tuple.
The [erlang] table holds Mochi-specific knobs:
otp-version: the minimum OTP version required. Written torebar.configas{minimum_otp_vsn, "<version>"}. Default"25"(the OTP 25 baseline, which ships theDbgichunk format).rebar3-version: the rebar3 version constraint. Default"~> 3.20".elixir-compat: whentrue, the bridge also resolves Elixir library packages from Hex.pm (packages that ship precompiled BEAM bytecode with no Elixir runtime dependency). Defaultfalse.
The [erlang.publish] table mirrors the hex package metadata fields. When present, mochi pkg publish --to=hex.pm reads these fields to populate the mix.exs or rebar3.config hex metadata. All fields map one-to-one to Hex.pm's package metadata API. If the package already ships a hand-written rebar.config with hex metadata, the generated one is skipped.
3. Lockfile extension: [[erlang-package]]
The MEP-57 mochi.lock gains one new repeated table:
[[erlang-package]]
name = "cowboy"
version = "2.12.0"
source = { kind = "hex", registry = "https://hex.pm" }
outer-sha256 = "abc123..."
inner-sha256 = "def456..."
inner-sha512 = "9f8e7d..."
beam-ingest-sha256 = "111aaa..."
shim-sha256 = "222bbb..."
capabilities-declared = ["net"]
dependencies = ["cowlib@~> 2.13", "ranch@~> 2.1"]
otp-app = "cowboy"
modules = ["cowboy", "cowboy_req", "cowboy_router", "cowboy_handler"]
outer-sha256 is the SHA-256 of the outer .tar.gz archive as downloaded from Hex.pm. inner-sha256 and inner-sha512 are the SHA-256 and SHA-512 hashes of the inner contents.tar.gz embedded inside the outer archive. Hex.pm's package registry publishes both hashes in its API response; the bridge cross-verifies all three at download time.
beam-ingest-sha256 is the SHA-256 of the concatenated ETF bytes the bridge read from the Dbgi (or Abst) chunks of every .beam file in the package, sorted by module name. A drift here at mochi pkg lock --check time is a hard error: the typespec extraction has changed and the shim must be regenerated.
shim-sha256 is the SHA-256 of the synthesised shim.erl file. A drift here at --check time is a hard error.
capabilities-declared mirrors MEP-57's capability model. The Erlang bridge recognises net (application opens network sockets), fs (application reads/writes files), proc (application spawns OS processes via os:cmd/1 or open_port/2), and dist (application participates in Erlang distributed node protocol via net_kernel). The dist capability gates phase 13 behaviour.
otp-app is the OTP application name (the atom declared in the .app file), which may differ from the Hex.pm package name. modules lists the translated module names whose surface the bridge exposes through the shim.
4. Dialyzer typespec-to-Mochi type mapping
The bridge uses a closed translation table. Items not in the table are skipped with a SkipReport entry naming the item and the reason.
| Erlang typespec | Mochi type | Notes |
|---|---|---|
integer() | int | Arbitrary-precision in Erlang; bridge clips to 64-bit signed |
pos_integer() | int | Range constraint lost; SkipNote |
non_neg_integer() | int | Range constraint lost; SkipNote |
neg_integer() | int | Range constraint lost; SkipNote |
float() | float | IEEE 754 double |
boolean() | bool | |
atom() | string | Atoms become strings; bridge marshals via ETF ATOM_UTF8_EXT |
binary() | bytes | Raw byte binary (most common string rep in modern Erlang) |
list(T) where T in table | list<T> | |
[T] shorthand | list<T> | |
nonempty_list(T) | list<T> | Non-empty constraint lost; SkipNote |
{ok, T} where T in table | T? (success branch) | Used in ok/error idiom; see §4.1 |
ok (bare atom, return pos.) | nil | Unit return |
{ok, T} | {error, atom()} | result<T, string> | Idiomatic Erlang result; see §4.1 |
{ok, T} | {error, binary()} | result<T, string> | |
{ok, T} | {error, Reason} where Reason is atom/binary | result<T, string> | |
{A, B} (2-tuple, both in table) | [A, B] as Mochi pair | |
{A, B, C} (3-tuple, all in table) | [A, B, C] as Mochi triple | |
pid() | opaque Pid | Handle to an Erlang process |
reference() | opaque Reference | Erlang reference term |
port() | opaque ErlPort | Erlang port (distinct from Mochi Port) |
node() | string | Node name as string |
T | undefined | T? | undefined atom as nil |
undefined | nil | |
fun((A) -> B) where A, B in table | fun(A): B | Fixed-arity, in-table only |
number() | refused | Union of integer and float; SkipReport: ambiguous number |
string() | refused | Erlang charlist (list of integers); SkipReport: use binary() |
iodata() | refused | Union: binary | iolist; SkipReport: iodata union |
iolist() | refused | Recursive list type; SkipReport: iolist |
bitstring() | refused | Non-byte-aligned; SkipReport: bitstring |
tuple() | refused | Untyped tuple; SkipReport: untyped tuple |
map() | refused | Untyped map; SkipReport: untyped map |
#{K := V} | refused | Map with typed keys requires schema; SkipReport: typed map |
any() / term() | refused | Top type; SkipReport: any/term |
none() / no_return() | void in return pos. | Only in return position |
complex union A | B | C | refused | SkipReport: non-ok/error union |
fun() (untyped fun) | refused | SkipReport: untyped fun |
§4.1 Ok/error idiom
The {ok, T} | {error, Reason} 2-tuple return pattern is the most common Erlang function signature shape. The bridge recognises this pattern as a whole and maps it to Mochi's result<T, string>, translating the ok branch to the success value and the error tuple's second element (atom or binary) to a string error message. This pattern recognition is structural: the bridge looks for a 2-element union where one element is {ok, T} (or bare ok) and the other is {error, _}.
The {ok, T} pattern alone (without a corresponding {error, _} branch) is mapped to T?, where the absence of an explicit error branch means the function either always succeeds or returns undefined on failure.
5. Surface syntax: import erlang "..."
The Mochi grammar's existing FFI-import production:
ImportStmt := "import" Lang? StringLit "as" Ident ("auto")?
Lang := "go" | "python" | "typescript" | "rust" | "ruby" | "erlang"
gains erlang as a Lang alternative. The string literal is one of:
<package>: bare name, resolves through[erlang-dependencies]constraint plusmochi.lock.<package>@<semver-constraint>: explicit version constraint.<package>@git+<url>#<ref>: git source.<package>@path+<relative>: path source.<app>.<module>@<package>@<constraint>: import a specific module from a multi-module application.
Example surface programs:
import erlang "cowboy@~> 2.12" as cowboy
import erlang "hackney@~> 1.20" as hackney
import erlang "jose@~> 1.11" as jose
fn start_http_server(port: int): result<Pid, string> {
let routes = cowboy_router.compile([
{"/api/v1/[...]", my_handler, []}
])
let dispatch = cowboy_router.dispatch(routes)
return cowboy.start_clear("my_http_listener", [{port, port}], #{
env => #{dispatch => dispatch}
})
}
fn sign_jwt(claims: map<string, string>, secret: bytes): result<string, string> {
let jwk = jose_jwk.from_oct(secret)
let jws = jose_jws.from_map(#{alg => "HS256"})
let {_,token} = jose_jwt.sign(jwk, jws, claims)
return jose_jws.compact(token)
}
The <alias> introduces a Mochi namespace. Symbol resolution looks up <alias>.<item> and binds against the synthesised extern fn declaration the bridge generated for <module>#<function>. The auto keyword is admitted for import erlang ... auto, providing flat top-level binding.
For multi-module OTP applications (e.g., cowboy ships modules cowboy, cowboy_req, cowboy_router, cowboy_handler), each module becomes a sub-namespace under the alias: cowboy.cowboy_req, cowboy.cowboy_router, etc. The user can also import a specific module directly using <app>.<module>@<package> syntax.
6. Erlang Port bridge shim emit
For each imported OTP application, the bridge emits two files:
erlang_shims/<app>/shim.erl (an Erlang gen_server process):
%% Auto-generated by mochi pkg lock. DO NOT EDIT.
%% app: cowboy 2.12.0 (beam-ingest: bundled)
-module(mochi_shim_cowboy).
-behaviour(gen_server).
-export([start_link/0, stop/0, call/2]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-define(PORT_BIN, <<"path/to/mochi_binary">>).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
stop() ->
gen_server:stop(?MODULE).
%% Translated function: cowboy:start_clear/3
call_start_clear(Name, TransportOpts, ProtocolOpts) ->
gen_server:call(?MODULE, {call, cowboy, start_clear,
[Name, TransportOpts, ProtocolOpts]}).
%% gen_server callbacks
init([]) ->
Port = open_port({spawn_executable, binary_to_list(?PORT_BIN)},
[{packet, 4}, binary, exit_status]),
{ok, #{port => Port}}.
handle_call({call, _Mod, Fun, Args}, _From, #{port := Port} = State) ->
Msg = term_to_binary({call, Fun, Args}),
Port ! {self(), {command, Msg}},
receive
{Port, {data, Reply}} ->
{reply, binary_to_term(Reply, [safe]), State}
after 30000 ->
{reply, {error, timeout}, State}
end.
handle_info({Port, {exit_status, Code}}, #{port := Port} = State) ->
{stop, {port_exited, Code}, State}.
terminate(_Reason, #{port := Port}) ->
catch port_close(Port),
ok.
The shim is a gen_server process that wraps the Mochi native binary as an OTP Port. It serialises call arguments to ETF via term_to_binary/1, sends them over the Port's stdin (with a 4-byte length prefix, the {packet, 4} option), and reads the response from the Port's stdout. The Mochi binary reads the ETF-encoded request, dispatches to the appropriate Erlang library function (loaded via erl_interface/NIFbridge or via a compiled BEAM node co-process), encodes the result as ETF, and writes it back.
erlang_shims/<app>/shim.mochi (the extern fn corpus):
extern type Pid
extern type Reference
extern type ErlPort
extern fn cowboy__start_clear(
name: string,
transport_opts: list<[string, int]>,
protocol_opts: bytes
): result<Pid, string> from erlang "cowboy:start_clear/3"
extern fn cowboy_req__reply(
status: int,
headers: map<string, string>,
body: bytes,
req: bytes
): bytes from erlang "cowboy_req:reply/4"
extern fn cowboy_router__compile(routes: bytes): bytes from erlang "cowboy_router:compile/1"
extern fn cowboy_router__dispatch(dispatch_table: bytes): bytes from erlang "cowboy_router:dispatch/1"
The external name mangling is <app>__<function> for module-level functions (with arity resolved to the function name), and <module>__<function> for functions in submodules of an OTP application.
7. CLI surface
The Mochi CLI gains the following additions under the existing mochi pkg subcommand:
mochi pkg add erlang <package>[@<constraint>]: adds an entry to[erlang-dependencies]and runsmochi pkg lock.mochi pkg lock: extended to walk[erlang-dependencies], query the Hex.pm HTTP API for resolution, download each.tar.gzinto the content-addressed cache, run BEAM ingest + EDoc fallback, synthesise the shims, and write[[erlang-package]]entries tomochi.lock.mochi pkg lock --check: extended to verifyouter-sha256,inner-sha256,inner-sha512,beam-ingest-sha256, andshim-sha256for every[[erlang-package]]entry.mochi pkg publish --to=hex.pm [--dry-run]: builds the package as a rebar3 application viaTargetErlangPort, invokesrebar3 hex publish, obtains an OIDC token from the CI environment, presents it to Hex.pm's trusted-publishing endpoint, uploads the signed package.--dry-runskips upload but exercises the signing flow against a mock-hex harness.mochi pkg sync erlang: regenerates the shim files from scratch without re-resolving versions.
8. Build orchestration
When a Mochi program contains one or more import erlang "..." declarations, the build driver gains the following extensions:
-
Before invoking the native binary builder, the driver invokes
package3/erlang/Bridge.PrepareWorkspace(workdir, mochiLock)which:- For each
[[erlang-package]]inmochi.lock, materialises the package from the content-addressed cache. - Materialises the synthesised shim files into
<workdir>/erlang_shims/<app>/. - Writes a
<workdir>/rebar.configthat includes a{deps, [...]}section listing each[[erlang-package]]entry at its exact pinned version. - Runs
rebar3 compileto compile the shim.erlfiles and their dependencies to.beambytecode.
- For each
-
The
extern fncorpus (theshim.mochifiles) is imported by the Mochi compiler as localextern fndeclarations. -
The emitted Mochi binary includes a built-in ETF encoder/decoder (pure Go,
package3/erlang/etf/) and a Port process manager (package3/erlang/port/) that handles the bidirectional message loop. -
The driver invalidates the shim cache when any
[[erlang-package]]shim-sha256changes.
9. Publish flow (Direction 2)
mochi pkg publish --to=hex.pm runs the following steps:
- Invoke
TargetErlangPortto emit the rebar3 application skeleton: asrc/tree containing the Erlang Port driver shim, an.app.srcdescriptor, and arebar.configwith the[erlang.publish]hex metadata. - Compile the emitted application with
rebar3 compile. - Invoke
rebar3 hex buildto produce the Hex.pm package tarball. - Obtain an OIDC token from the CI environment. On GitHub Actions this requires
id-token: writein the workflowpermissionsblock. The bridge checks forACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENenvironment variables and uses the GitHub Actions OIDC provider. - Present the OIDC token to Hex.pm's trusted-publishing endpoint (
https://hex.pm/api/auth). - Hex.pm exchanges the token for a short-lived signing certificate.
- Upload the package tarball via
rebar3 hex publish(or directly via the Hex.pm HTTP API with the signing token). - Record the package checksum in
mochi.lockunder a[publish]sub-table.
--dry-run executes steps 1-3, skips steps 4-8, and prints the token and payload that would be sent.
Rationale
1. BEAM abstract code over Dialyzer PLT or EDoc XML
The Erlang ecosystem has three machine-readable sources of type information: BEAM abstract code (embedded in .beam files), Dialyzer PLT (persistent lookup table, produced by running dialyzer --build_plt), and EDoc XML (generated by running rebar3 edoc). The bridge uses BEAM abstract code as the primary source for four reasons. First, it is in-artifact: every compiled .beam file carries its abstract code without any separate tool invocation. Second, it contains the original -spec and -type source directly, not a Dialyzer-inferred approximation. Third, it does not require running the Erlang VM at ingest time. Fourth, the Dbgi chunk format has been stable since OTP 20 (2017); the older Abst chunk format was stable since OTP 17. Both are ETF-encoded, meaning the bridge needs only a Go-side ETF decoder (no Erlang VM process). The Dialyzer PLT alternative requires running dialyzer (which requires running OTP), produces inferred types that may differ from the author's declared specs, and changes on every OTP version bump. The EDoc XML alternative requires running rebar3 edoc, parses free-text @spec tags from comment blocks, and is the weakest of the three. EDoc XML is used as a fallback (phase 3) only for packages that ship no -spec directives in their source.
2. OTP Port over NIF or C-node
The Erlang ecosystem offers three integration mechanisms for non-BEAM code: Ports (stdin/stdout + ETF, supervised by OTP), NIFs (Native Implemented Functions, shared library loaded into the BEAM scheduler), and C-nodes (a full Erlang distribution node implemented in C via the erl_interface library). MEP-66 uses OTP Ports for three reasons. First, safety: a crashing Port process terminates cleanly; the OTP supervisor restarts it. A crashing NIF kills the entire BEAM VM. A crashing C-node disconnects from the cluster but does not crash the local node. Second, deployment simplicity: a Port is just a binary launched with open_port/2; no shared-library loading, no EPMD registration, no Erlang cookie authentication. Third, latency adequacy: the per-call overhead of a Port round-trip (ETF serialisation + pipe IPC) is approximately 50-200 microseconds, which is acceptable for all but the most latency-sensitive hot-path operations. The NIF path is reserved for a future sub-phase (MEP-66 N.1, "NIFex opt-in") where the user explicitly annotates a function as [erlang.nif = true] in mochi.toml, accepting the stability trade-off. The C-node path is reserved for phase 13 (distributed Erlang bridge), where the Mochi binary must participate as a named node in an Erlang cluster.
3. Hex.pm trusted publishing over HEX_API_KEY
Hex.pm launched trusted publishing in 2024 using GitHub Actions OIDC, the same pattern now adopted by npm (2024), Maven Central (Sigstore 2024), PyPI (PEP 740, 2025), crates.io (RFC #3724, 2025), and RubyGems.org (2023). MEP-66 ships exclusively on the OIDC path. The HEX_API_KEY token path is not supported. Long-lived API tokens are the leading cause of ecosystem account compromise and supply-chain injection; the bridge was designed after those lessons and does not repeat them.
4. Closed typespec-to-Mochi table
The closed-table philosophy is identical to MEP-73's Rust bridge and MEP-76's Ruby bridge. The bridge promises sound Mochi bindings only for the subset of Erlang typespecs it can translate without information loss or ambiguity. Items outside the table (untyped maps, iodata(), bitstring(), complex unions, untyped funs) are skipped with a structured SkipReport. This gives the user a clear, auditable list of what the bridge translated, without making unsound promises. A user who needs an out-of-table item can write a hand-authored extern fn declaration in Mochi that takes responsibility for the type.
5. rebar3 as build orchestrator
rebar3 has been the canonical Erlang build tool since 2015 and is the recommended tool by the Erlang/OTP team. It handles dependency fetching from Hex.pm, version resolution (via its own SAT solver), compilation, release building, and Hex.pm publishing. Writing a custom Erlang resolver in Go would require re-implementing rebar3's SAT resolver, understanding every edge case in the .app.src → .app expansion, and tracking the Hex.pm HTTP API format (which rebar3 already handles correctly for all current and historical Hex.pm API versions). The bridge generates a rebar.config from [erlang-dependencies] and delegates resolution and compilation to rebar3.
6. Ok/error idiom pattern matching
The {ok, T} | {error, Reason} 2-tuple pattern is not just common in Erlang; it is universal. Every OTP API, every gen_server:call/2 response, every file I/O function, and every socket operation returns this pattern. Mapping it to Mochi's result<T, string> is not a simplification but a correct semantic translation: the Erlang community has used this idiom as an untyped equivalent of a Result type for 30 years. Refusing to translate it (and requiring users to write hand-authored extern fn declarations) would make the bridge nearly useless for real Erlang libraries. The bridge recognises this pattern structurally: a union of {ok, T} and {error, atom() | binary() | string()} is mapped to result<T, string>, with the error reason marshalled as a UTF-8 string.
Phases
See /docs/implementation/0066/ for the per-phase tracking matrix. Fourteen phases cover skeleton (0), Hex.pm index client (1), BEAM abstract-code ingest (2), EDoc XML fallback ingest (3), typespec-to-Mochi type mapping (4), Port bridge shim emitter (5), import erlang grammar extension (6), rebar3 build orchestration (7), mochi.lock integration (8), TargetErlangPort emit (9), Hex.pm trusted publishing (10), OTP behavior bindings (11), async process bridge (12), and distributed Erlang node bridge (13).
A phase is LANDED only when its gate is green against the curated 20-package fixture corpus.
Target matrix
| Phase | OTP 25 (linux-x64) | OTP 27 (linux-x64) | OTP 27 (darwin-arm64) |
|---|---|---|---|
| 0. skeleton | NOT STARTED | NOT STARTED | n/a |
| 1. Hex.pm index client | NOT STARTED | NOT STARTED | n/a |
| 2. BEAM abstract code ingest | NOT STARTED | NOT STARTED | n/a |
| 3. EDoc XML fallback | NOT STARTED | NOT STARTED | n/a |
| 4. type-mapping table | NOT STARTED | NOT STARTED | n/a |
| 5. Port bridge shim emit | NOT STARTED | NOT STARTED | n/a |
| 6. import erlang grammar | NOT STARTED | NOT STARTED | required |
| 7. rebar3 build orchestration | NOT STARTED | NOT STARTED | required |
| 8. mochi.lock integration | NOT STARTED | NOT STARTED | required |
| 9. TargetErlangPort emit | NOT STARTED | NOT STARTED | required |
| 10. Hex.pm trusted publishing | NOT STARTED | NOT STARTED | n/a (publish is linux-x64 only) |
| 11. OTP behavior bindings | NOT STARTED | NOT STARTED | required |
| 12. async process bridge | NOT STARTED | NOT STARTED | required |
| 13. distributed Erlang bridge | NOT STARTED | NOT STARTED | required |
A phase marked n/a for a target is intentional. OTP 25 is the minimum supported version (it ships the Dbgi chunk and a modern erl_interface). OTP 27 is the primary development target. darwin-arm64 is added from phase 6 onward when the runtime bridge becomes testable.
Non-goals
-
Full Elixir syntax support. The bridge consumes packages from Hex.pm, which hosts both Erlang and Elixir packages. However, Elixir packages that expose only an Elixir-flavoured API (using macros, protocols, and structs from the Elixir standard library) are out of scope. The bridge targets Erlang-callable modules only. Pure Erlang packages and Erlang-compatible Elixir packages (those that expose a plain Erlang module API) are in scope.
-
Full Dialyzer inference. The bridge reads author-declared
-specdirectives. It does not run Dialyzer to infer types for functions without specs. Functions without specs produce aSkipReportentry of kindno_spec. -
OTP hot code reloading. The bridge does not support
code:load_file/1or thecode_change/3supervisor callback. Hot reloading would require the Mochi binary to reload the shim.beamat runtime, which conflicts with the content-addressable shim cache model. -
Bidirectional callbacks. The bridge generates read-direction (Mochi calls Erlang) bindings. A pattern where an Erlang process calls back into a Mochi function via a callback module is out of scope for the core phases (it would require the Mochi binary to register as a gen_server and accept
handle_callmessages, which is phase-13 territory and may be a future sub-phase). -
WASM targets. Erlang's
open_port/2spawns an OS process; WASM runtimes do not support OS process spawning. Mochi programs targeting WASM (MEP-52) cannot use Erlang bridge imports. The bridge driver asserts this constraint at compile time. -
Erlang distribution without explicit
distcapability. Phase 13 (distributed Erlang) requires thedistcapability to be declared in[erlang.capabilities]. Without it, the bridge never opens EPMD connections or sets up the OTP distribution protocol.
Appendix: Fixture corpus
The 20-package fixture corpus covers the top-downloaded Erlang packages on Hex.pm plus representative OTP behavior, distributed systems, and data-format use cases.
| Package | Version (May 2026) | Spec source | Phase gate | Notes |
|---|---|---|---|---|
| cowboy | 2.12.x | beam/bundled | 2 | HTTP 1.1/2 server; OTP supervisors |
| ranch | 2.1.x | beam/bundled | 2 | TCP acceptor pool; core of cowboy |
| hackney | 1.20.x | beam/bundled | 2 | HTTP 1.1 client |
| gun | 2.1.x | beam/bundled | 2 | HTTP/2 + WebSocket client |
| jsx | 3.1.x | beam/bundled | 2 | JSON encode/decode |
| jose | 1.11.x | beam/bundled | 2 | JWT/JWE/JWK/JWS |
| poolboy | 1.5.x | beam/bundled | 2 | Worker pool (gen_server) |
| lager | 3.9.x | edoc | 3 | Legacy logging; no -spec on all exports |
| erlware_commons | 1.7.x | beam/bundled | 2 | Utility library |
| parse_trans | 3.4.x | beam/bundled | 2 | AST transformations |
| meck | 0.9.x | beam/bundled | 2 | Mocking library |
| proper | 1.4.x | beam/bundled | 2 | Property-based testing |
| recon | 2.5.x | beam/bundled | 2 | Runtime diagnostics; process inspection |
| observer_cli | 1.7.x | edoc | 3 | LiveDashboard-style terminal UI |
| telemetry | 1.3.x | beam/bundled | 2 | Metrics/events (shared Erlang/Elixir) |
| opentelemetry_api | 1.4.x | beam/bundled | 2 | OpenTelemetry SDK |
| prometheus.erl | 4.11.x | beam/bundled | 2 | Prometheus metrics exporter |
| gproc | 0.9.x | beam/bundled | 2 | Global process registry |
| cuttlefish | 3.1.x | edoc | 3 | Config file management |
| uuid | 2.0.x | beam/bundled | 2 | UUID v1-v5 generation |
Packages marked edoc (lager, observer_cli, cuttlefish) exercise phase 3 (EDoc XML fallback ingest). All other packages exercise phase 2 (BEAM abstract code ingest). Every package in the corpus must produce a clean SkipReport and a compilable extern fn corpus before its phase gate is marked LANDED.
Acknowledgements
This MEP builds on MEP-57 (Mochi module and package system) for the mochi.toml manifest, the mochi.lock lockfile, the SHA-256 content-addressed object store, the OIDC trusted-publishing infrastructure, and the capability declaration scheme; on the Erlang/OTP team for the BEAM abstract code format (ETF, Dbgi chunk, OTP 20+), the -spec and -type typespec language, the Port protocol, the erl_interface library, and the gen_server / supervisor OTP behaviours; on the Hex.pm team for the Hex.pm HTTP API v2, the compact index, and Hex Trusted Publishing (2024); on the rebar3 team for the build tool, dependency resolution, and rebar3 hex publish integration; on Dialyzer for the typespec analysis model and the PLT format; on the Erlang community for the widely-followed {ok, T} | {error, Reason} idiom that makes closed-table translation viable; on MEP-73 (Rust bridge) and MEP-76 (Ruby bridge) for the closed-table philosophy, the content-addressed cache model, the capability declaration scheme, the fixture-corpus gate methodology, and the dual-direction (consume + publish) bridge architecture that MEP-66 adapts to the Erlang/OTP context.