Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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 ABI extern "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 — recommends panic=abort for binary size, which oc5 shows is mutually exclusive with boundary catch_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 = 128 guards are assert!, not Result
    • ~/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_code is denied workspace-wide; chat.rs:272 uses panic! in a const initializer, which escapes clippy::panic = "deny" because clippy skips const items

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_unwind at its edge is unconditionally sound — no UB precondition to violate.
  • StorageEngine::Error (the per-impl associated type that already exists at engine.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.

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.

  • 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.

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.