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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
scope and scope_export attributes;apply/3, and remote fun handling, to enforce the
restriction;A final implementation must include JIT support, reference manual updates, and complete OTP test coverage before the EEP can become Final.
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.
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.
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.
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: