Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 359 additions & 0 deletions eeps/eep-0080.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
Author: Erik Stenman <happi(at)happihacking(dot)se>
Status: Draft
Type: Standards Track
Created: 19-May-2026
Erlang-Version: OTP-30.0
Post-History:

****
EEP XXX: BEAM-Level Scoped Export Visibility
----

Abstract
========

This EEP proposes scoped function visibility for Erlang by introducing two
module attributes, `-scope(Scope).` and
`-scope_export([Name/Arity, ...]).`, with enforcement in the BEAM runtime.
Functions listed in `-scope_export/1` remain ordinary exported functions, but
remote calls to them are accepted only from modules declaring the same
`-scope/1` value.

The feature adds package-private-style visibility without adding new Erlang
syntax and without changing the behavior of existing modules. Code that does
not use the new attributes behaves as it does today. The proposal is intended
to make internal APIs explicit, prevent accidental coupling between libraries
or subsystems, and give tools a shared source of visibility metadata.

Motivation
==========

Erlang's module boundary is binary: a function is either exported or private
to its defining module. Libraries often need a middle ground where functions
are callable by sibling modules inside the same visibility scope but are not
part of the external API. Today that distinction is represented with naming
conventions, documentation, wrapper modules, or static checks. Those
approaches document intent, but they do not provide a uniform runtime boundary.

A VM-level mechanism gives Erlang code a precise way to say that an exported
function is internal to a declared visibility scope. It prevents accidental
dependencies, improves refactoring safety, exposes intent in BEAM metadata, and
gives tools such as xref, Dialyzer, documentation generators, and language
servers a common representation to inspect.

Specification
=============

New Module Attributes
---------------------

Two module attributes are introduced:

-scope(Scope).

where `Scope` is an atom naming the visibility scope of the module, and:

-scope_export(Functions).

where `Functions` is a list of function names and arities using the existing
`Name/Arity` attribute syntax, for example:

-scope_export([internal_helper/1, shared_util/2]).

A module without `-scope/1` behaves exactly as it does today. An exported
function not listed in `-scope_export/1` behaves exactly as it does today.

Runtime Enforcement
-------------------

When an external call targets a function marked by the callee module as
scope-restricted, the BEAM runtime checks the caller module's visibility scope.

The call succeeds if the caller module declares the same `-scope(Scope)` value
as the callee module. Otherwise the VM raises an exception of the form:

error:{badscopecall, #{
caller_mfa => {CallerModule, CallerFunction, CallerArity},
caller_scope => CallerScope,
target_mfa => {TargetModule, TargetFunction, TargetArity},
target_scope => TargetScope
}}

where `CallerScope` is either an atom or `undefined`, and `TargetScope` is the
atom from the callee module's `-scope/1` attribute.

The check applies to remote calls, `apply/3`, and remote fun invocation. Local
calls inside the defining module are unaffected. BIFs and NIFs are unaffected
unless explicitly represented as scope-restricted exports by an implementation.
That use is not recommended.

Distributed calls are enforced on the callee node using the callee node's code
and metadata. This proposal does not change Erlang distribution trust
semantics.

BEAM File and Loader Behavior
-----------------------------

No new BEAM chunk is required. The attributes are stored in existing BEAM
attribute metadata. The loader reads `scope` and `scope_export` attributes
during module loading.

For each loaded module, the runtime stores the module's visibility scope. For
each exported function whose `{Name, Arity}` pair is listed in
`-scope_export/1`, the runtime marks the export entry as scope-restricted and
associates it with the module's visibility scope.

If `-scope_export/1` is present without `-scope/1`, the functions are treated
as unrestricted at runtime. The compiler should warn for this case.

Hot code loading naturally follows the active code index. When new code is
loaded, its module metadata determines the visibility behavior for calls into
that code version.

Compiler and Tooling Behavior
-----------------------------

The compiler should accept the new attributes and include them in BEAM
attribute metadata. It should warn when `-scope_export/1` is present without
`-scope/1`, or when `-scope_export/1` lists a function that is not exported by
the module.

`code:module_info(attributes)` should include `scope` and `scope_export` in the
same way it reports other retained module attributes.

Static analysis tools may use the attributes to report cross-scope calls to
scope-restricted functions before runtime. Such checks are advisory; runtime
enforcement is defined by the BEAM.

Examples
========

A module can expose a public function while restricting another exported
function to callers in the same visibility scope:

%% foo_a.erl
-module(foo_a).
-scope(foo).
-export([open/0, secret/0]).
-scope_export([secret/0]).

open() -> open.
secret() -> top_secret.

A module with the same visibility scope can call the restricted function:

%% foo_b.erl
-module(foo_b).
-scope(foo).
-export([try/0]).

try() -> {ok, foo_a:open(), foo_a:secret()}.

A module with a different visibility scope cannot call it:

%% bar_c.erl
-module(bar_c).
-scope(bar).
-export([try/0]).

try() -> foo_a:secret().

The call from `bar_c:try/0` raises `badscopecall`. A caller without
`-scope/1` would also be rejected when calling `foo_a:secret/0`.

Rationale
=========

Attributes are used instead of new syntax because the feature describes module
metadata and function visibility, not a new expression form. This keeps the
language surface stable and reuses BEAM metadata that existing tools already
understand.

The restriction is enforced by the VM because compile-time checks alone cannot
cover all call paths. Calls through `apply/3`, remote funs, dynamically loaded
modules, and mixed-version systems all need a single runtime rule.

The rule is scope-based rather than module-list-scoped. A module list can
express very fine-grained visibility, but it also creates maintenance cost when
internal modules are split, renamed, or reorganized. A named visibility scope
is coarser, but it maps to how many Erlang libraries already distinguish public
API from internal modules.

The default is unrestricted. Existing code does not opt in accidentally, and
adding only `-scope/1` has no behavioral effect. A module author must list a
function in `-scope_export/1` to restrict it.

The proposal deliberately does not derive visibility from OTP applications,
`.app` files, source tree layout, or code paths. The attribute defines an
explicit visibility scope. This lets the mechanism remain useful for code that
is not packaged as an OTP application, for generated code, and for other BEAM
languages that may have different packaging conventions.

Backwards Compatibility
=======================

Existing code without `-scope/1` and `-scope_export/1` is unchanged. Adding
`-scope/1` alone is also unchanged.

Adding `-scope_export/1` to an existing exported function tightens visibility
and can break callers outside the declared visibility scope. This is
intentional and should be treated as a public API compatibility change by
libraries that use it.

Older VMs that do not implement this EEP will ignore the attributes and will
not enforce scope-restricted visibility. Code that depends on enforcement must
therefore require a VM version that implements this EEP.

Security Considerations
=======================

This feature is an encapsulation mechanism, not a sandbox. A node operator
with shell access, the ability to load arbitrary code, or a modified VM can
bypass it. Erlang distribution trust boundaries are unchanged.

The error value includes caller and target metadata to make failures
debuggable. Implementations should avoid including more process or code server
state than is needed to explain the rejected call.

Performance Impact
==================

Unrestricted exports should pay at most one predictable flag check in external
call paths. Restricted exports require an additional comparison of the caller
and callee scope atoms. The allocation of the detailed `badscopecall` term
occurs only on the error path.

Implementations should keep the hot path branch predictable and avoid heap
allocation unless enforcement fails.

Indicative prototype measurements on x86 JIT, comparing a patched VM against
vanilla upstream OTP at commit `311fecb87f` with `+S 1:1`, showed public calls
within measurement noise and allowed restricted calls adding a few nanoseconds
per operation:

direct public, same scope: 6.89 ns/op -> 7.25 ns/op
direct restricted, same scope: 7.02 ns/op -> 12.01 ns/op
apply public, same scope: 6.89 ns/op -> 7.21 ns/op
apply restricted, same scope: 7.07 ns/op -> 13.35 ns/op
external fun restricted, same scope: 6.82 ns/op -> 13.34 ns/op
direct public, cross scope: 7.17 ns/op -> 6.81 ns/op

These figures are non-normative and come from a microbenchmark. The blocked
cross-scope path is not comparable to a normal call because it constructs and
catches an exception.

Implementation Notes
====================

A prototype implementation is available in the `eep80-poc` branch of
[otp-app-export][]. It is intended to help review the design and VM
implications. It is not yet proposed as a merge-ready Erlang/OTP pull request.
The prototype still uses the earlier attribute names `-app/1` and
`-app_export/1`, and the earlier exception name `badappcall`. Those names will
be updated after the proposal terminology settles.

The implementation touches these areas:

* the export table, to record scope-restricted exports;
* the module data structure, to record the module visibility scope;
* the BEAM loader, to read `scope` and `scope_export` attributes;
* external call dispatch, `apply/3`, and remote fun handling, to enforce the
restriction;
* compiler linting, to warn about malformed or ineffective declarations; and
* common test coverage for same-scope success, cross-scope failure,
unrestricted exports, dynamic calls, and tool-facing metadata.

A final implementation must include JIT support, reference manual updates, and
complete OTP test coverage before the EEP can become Final.

Alternatives Considered
=======================

Parse transforms can reject some invalid calls, but they are opt-in per module
and do not reliably cover `apply/3`, remote funs, generated code, or modules
compiled without the transform.

Documentation-only mechanisms, including hidden documentation, help consumers
avoid internal functions but do not prevent accidental runtime use.

Static analysis only, as explored by [EEP 67][], can catch many direct calls,
but it cannot define the behavior of dynamic calls or mixed-code systems by
itself.

Explicit module allow lists, as explored by [EEP 5][], provide finer control
but require every internal caller to be named. This makes routine
reorganization expensive and can turn visibility metadata into a second module
dependency graph.

A separate "use scope" attribute could distinguish the scope a module belongs
to from additional scopes whose restricted exports it is allowed to call. For
example:

-scope(cowboy).
-use_scope(cowlib).

This is more expressive than the single-scope model, but it changes the feature
from membership in one visibility scope to a capability-like system. Such a
system would need to define whether access is granted by the caller, the
callee, or both. This EEP keeps the core rule to one declared scope per module
and leaves friend or use-scope access as a possible extension.

Related Work
============

[EEP 5][] proposed `-export_to` for finer-grained visibility. It allowed a
module to specify which other modules could call selected functions. This EEP
uses a named visibility scope instead of explicit module lists.

[EEP 67][] proposed `-internal_export` for marking functions internal to an OTP
application. It focused on static analysis through tools such as xref and
Dialyzer. That EEP was rejected after the OTP team concluded that
documentation attributes and static tooling were sufficient for identifying
internal APIs. This EEP revisits the same broad problem, but proposes runtime
enforcement as the distinguishing semantic change.

[OTP PR 7407][] implemented EEP 67 and was closed after EEP 67 was rejected.
The discussion is useful prior art for the distinction between metadata-only
visibility and enforced visibility.

Open Questions
==============

The proposal needs community feedback on whether the attribute should be named
`-scope/1`, or whether a more explicit name such as `-visibility_scope/1` would
be preferable.

The exact exception shape should also be reviewed. The current proposal uses
`badscopecall` with a map containing caller and target metadata. An
alternative is to reuse `undef` or `badarg`, but those errors lose the reason
why the call was rejected.

A final design must specify the JIT implementation strategy in enough detail to
show that the ordinary external call path remains fast.

References
==========

[EEP 5]: eep-0005.md
"EEP 5, More Versatile Encapsulation with export_to, O'Keefe"

[EEP 67]: eep-0067.md
"EEP 67, Internal exports, Mindek"

[OTP PR 7407]: https://github.com/erlang/otp/pull/7407
"Implement EEP 67: Internal exports"

[otp-app-export]: https://github.com/happi/otp-app-export/tree/eep80-poc
"Prototype implementation"

Copyright
=========

This document is placed in the public domain or under the CC0-1.0-Universal
license, whichever is more permissive.

Local Variables:
mode: indented-text
coding: utf-8
fill-column: 70
End: