The module-ABI question is per-tier, not global: shrink the panic-containment blast radius to one optional tier
Источник:
dev/fera-research/docs/oc4/idea-09-per-tier-module-abi.md(read-only; сгенерировано scripts/sync-docs.mjs)
The module-ABI question is per-tier, not global: shrink the panic-containment blast radius to one optional tier
Section titled “The module-ABI question is per-tier, not global: shrink the panic-containment blast radius to one optional tier”- kind: idea
- area: modules / trust / architecture
- evidence:
- Builds on
~/dev/fera-research/docs/oc5/no-panic-containment-at-dylib-boundary.md(kind: risk, severity: high) — its closing ask: “Ask before merging: is the module ABIextern "C"or a Rust-ABI trait object? The answer decides between option 1 and option 3, and no document in the corpus states it.” — this file is an answer to that ask, and a proposal for narrowing the problem. ~/dev/fera/project-design/context/ADR.md:46-56(ADR-002) — the tier split is already decided: first-party/performance-critical modules are native dylibs, “No sandbox — these are trusted-by-design”; third-party community plugins are WASM, “sandboxed by construction”~/dev/fera/project-design/context/ADR.md:442-446(ADR-024) — third-party native modules exist only behind an explicit opt-in developer mode~/dev/fera/project-design/context/OPTIM-IDEAS-3-research.md:101— recommendspanic=abortfor binary size, which oc5 shows is mutually exclusive with boundarycatch_unwind~/dev/fera/project-design/context/OQ.md:874-883(OQ-024) — “pin the Rust compiler version so our own modules compile without#[repr(C)]— a fixed rustc version makes the Rust ABI itself stable enough between our modules”~/dev/fera/project-design/context/OPTIM-IDEAS-4-oq24.md§7 — the anti-list forbids the Rust ABI across independently built plugins~/dev/fera/project-design/context/ADR.md:28-30(ADR-001) — “Every module boundary must be designed as a trait contract (interface crate) shared between host and module”~/dev/fera/chroix-core/src/lib.rs:192-195,283-286,317-321,470-473—CELL_BUDGET = 128guards areassert!, notResult~/dev/fera/fera/core/crates/domain/src/chat.rs:264—unreachable!()on the message-send path~/dev/fera/fera/core/crates/storage/src/engine.rs:14—type Error;— the storage contract already has a per-impl error type, i.e. the “no panic, return an error” discipline has a home it isn’t using~/dev/fera/fera/Cargo.toml:28-36—unsafe_codeis denied workspace-wide;chat.rs:272usespanic!in aconstinitializer, which escapesclippy::panic = "deny"because clippy skips const items
- Builds on
Суть / Thesis
Section titled “Суть / Thesis”oc5’s finding is right and its closing question is the right one to ask. My contribution is that the question as posed has one answer too many.
“Is the module ABI extern "C" or a Rust-ABI trait object?” is asked as if
there is one module tier. ADR-002 already drew the tier line, and the two
tiers have opposite answers for reasons that are already written down:
| Tier | ADR-002’s words | Tools | Trust | Correct ABI |
|---|---|---|---|---|
| First-party (storage, search, protocol, media, Engine/Style/Feature/Native axes, predictor) | “trusted-by-design”, “No sandbox” | the project’s own pinned rustc | self | Rust ABI trait object — and better, no boundary at all |
| Third-party plugins | “sandboxed by construction” | wasmtime / Component Model | untrusted | neither — it is WASM, there is no native ABI |
| Third-party native modules | ADR-024: behind an explicit opt-in developer mode | unknown, possibly a different rustc | user-installed | extern "C", with a checked panic contract |
The middle row is why this looks global: third-party plugins are WASM, so they never have a panic-strategy problem, and they therefore never get counted when people list “the tiers”. But every module the project actually ships as a first-party dylib is in row 1, and OQ-024(d) has already decided that row compiles with the Rust ABI.
So the panic-ABI question only ever has a real answer for row 3 — the one tier that is optional, user-consented, off by default, and explicitly “developer mode”.
And row 1 can be taken further than “Rust ABI”: per
idea-08-static-module-loader.md, first-party modules can be statically
linked, which removes the boundary rather than hardening it. That is not a
mitigation — it is elimination of the UB class, by construction:
- A statically-linked module is in the same crate graph as the host, so rustc enforces a single panic strategy. There is no possibility of disagreement, so there is nothing to check and nothing that can be misconfigured.
- There is no
extern "C"shim, so “unwinding across an ABI” never arises. - The trait is a plain Rust trait, so
catch_unwindat its edge is unconditionally sound — no UB precondition to violate. StorageEngine::Error(the per-impl associated type that already exists atengine.rs:14) is the natural place for a module failure to land, so containment does not need a parallel error channel invented for it.
This turns oc5’s “option 4 — make the panic strategy a checked build contract” from a convention that CI must police into a property the compiler guarantees for the tiers that matter.
Варианты / Options (with tradeoffs)
Section titled “Варианты / Options (with tradeoffs)”A. One global ABI decision, applied to all native modules.
- Pros: simple story.
- Cons: it is either
extern "C"(giving up OQ-024(d)’s whole point and paying marshaling on every call, contradicting ADR-005’s batch-first design) or Rust-ABI (unsafe for genuinely third-party native code compiled by an unknown toolchain — which OPTIM-IDEAS-4 §7 already forbids). One answer is wrong for at least one tier, so the real cost is that someone has to re-litigate this per tier anyway, without a framework.
B. extern "C" everywhere + checked panic contract (oc5’s option 4).
- Pros: sound, uniform, works for third-party native modules.
- Cons: pays the C-ABI tax (marshaling, no inlining,
repr(C)discipline — ADR-020’s whole design) on the first-party tier that OQ-024(d) was explicitly created to avoid. Fixes a soundness problem by accepting a performance cost the project has already decided not to pay.
C. Per-tier, chosen by existing decisions (proposed).
- Row 1 statically linked → no ABI boundary, compiler-enforced panic
strategy. Fallback for anything that genuinely must be swappable at runtime:
Rust-ABI trait object across a dylib, which is exactly what OQ-024(d)
licenses, with a CI check that module artifacts record their
rustc_version(OQ-024’s own manifest requirement) and the host refuses a mismatch. - Row 2 WASM → not applicable, sandboxed, fuel-limited (OQ-022).
- Row 3 third-party native →
extern "C", and the only place oc5’s option-4 contract and its ABI check must live. - The manifest carries the tier per module entry, so the host knows which contract to apply and cannot apply the wrong one.
Proposed solution
Section titled “Proposed solution”1. Record it as the answer to oc5’s question — one paragraph, in ADR-002’s consequences
Section titled “1. Record it as the answer to oc5’s question — one paragraph, in ADR-002’s consequences”ADR-002 states the two-tier trust model and its consequences are, verbatim, “Two separate loading/validation pipelines exist in the codebase; they must not be conflated.” The module-ABI choice is a third such pipeline decision and it is missing. This is a short addendum, not a new debate.
2. Let the consequence drive the sequencing
Section titled “2. Let the consequence drive the sequencing”oc5 lists four containment options and correctly notes that option 3 (a process per axis) is the only one containing arbitrary native failure and costs IPC that contradicts ADR-005. With per-tier answers, option 3 is only needed for row 3, and row 3 is:
- off by default,
- opt-in per module (ADR-024),
- already outside the “trusted-by-design” guarantee (ADR-002 explicitly).
So “run it in its own process” is a proportionate answer to “a user installed an unsigned native module from an unknown toolchain”, and it does not tax the three first-party axes at all. That is a much smaller and much more defensible decision than “process-isolate the renderer”.
3. The CELL_BUDGET finding gets a cheaper fix under this framing
Section titled “3. The CELL_BUDGET finding gets a cheaper fix under this framing”oc5 shows that the frozen fera-scene-v0 (500 chats, OQ.md:146) needs 500
live cells against CELL_BUDGET = 128 — 3.9× over — and the guard is
assert!. Under a same-crate-graph static link, that panic is a normal
in-process panic catchable at the Elm loop’s boundary, not an application-wide
abort. That does not make it acceptable — oc1’s point that a frozen sanctioned
scene panics the render loop stands, and the fix is still to make it a
degradation path — but it does change the severity of the fix’s absence
from “host dies” to “one frame’s work is lost”, which makes it a normal bug
rather than an architecture-shaped one.
4. Make Error the answer everywhere it already exists
Section titled “4. Make Error the answer everywhere it already exists”StorageEngine::Error (engine.rs:14) is a per-implementation associated type
precisely so implementations report failure instead of panicking — and the
contracts that need it most (storage, search, engine paint) are exactly the
first-party ones that become statically linked. The discipline oc5 is asking
for already has a home in the trait design; it is not adopted yet, and under
this proposal adopting it is cheap rather than urgent.
5. Tradeoffs, honestly
Section titled “5. Tradeoffs, honestly”- Static linking is not free — it gives up runtime hot-swap for row 1, which is ADR-001’s stated purpose. That is a real cost and it is why the proposal keeps a Rust-ABI-dylib fallback rather than mandating static links everywhere. The static mode is opt-in per build, and per idea-08 it is already the developer mode and the hermetic build.
- This does not solve row 3. A user-installed native module compiled by an unknown rustc still needs the C ABI, the checked panic contract, and probably a process. Contained to the tier that deserves it.
- It requires someone to actually measure whether losing runtime hot-swap for first-party modules is acceptable to the author. That is a product question, not a technical one, and the proposal’s honest framing is: the panic-strategy problem is the thing forcing the question, so the question has to be answered anyway.
Why this is the useful contribution
Section titled “Why this is the useful contribution”oc5’s finding is correct and its option analysis is sound. What it lacks is a reason the question has more than one answer — and having worked out that the tier line was drawn by ADR-002 before oc5’s question was asked, the per-tier split is what makes its containment problem tractable at all. The “checked build contract” becomes a compiler property; the “process per axis” becomes proportionate; and the two options that are expensive-and-unnecessary stop being on the table for the tiers where nothing is at stake.