diff --git a/eeps/eep-0080.md b/eeps/eep-0080.md new file mode 100644 index 0000000..ece3f35 --- /dev/null +++ b/eeps/eep-0080.md @@ -0,0 +1,359 @@ + Author: Erik Stenman + 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: