The Lemonade Backend Gateway
A First-Principles Design for Secure, Pluggable Inference Backends
Status: R&D design proposal — not yet an RFC, not an implementation.
The design laid out here is grounded in how Lemonade's backends actually behave, in the capabilities already present in the codebase, and in the positions of the maintainers who reviewed the Working Group charter. It is intended to be read as one coherent, self-contained proposal.
1. Why this exists
Lemonade runs inference by spawning backends as subprocesses (llama-server,
whisper-server, sd-server, flm, vllm, and others). Today, adding a
backend means writing a new C++ WrappedServer class and recompiling lemond.
That limits Lemonade to in-tree backends, slows adoption of new inference
engines, and forces maintainers to build, pin, and ship every engine for every
platform and GPU vendor.
At the same time, letting users run third-party or community binaries raises a
real security problem: an untrusted binary that runs as a child of lemond with
the same user, full network access, and the server's environment can read model
files, reach the network, and exfiltrate API keys that happen to be in the
environment.
The Backend Plugins & Sandboxing Working Group exists to resolve exactly this tension: make Lemonade open enough to run any engine, and safe enough that doing so does not compromise the machine. This document is a first-principles proposal for how to meet both goals.
2. The options considered
There are three natural ways to make a backend pluggable. Each takes a different position on where procedural adaptation logic lives, and each has hard limits. They are evaluated here on their own terms.
Option 1 — Declarative descriptors (backend as data). Express a backend as a JSON manifest: identity, capabilities, an argument template, a health probe. Simple to write and submit. The limit is fundamental: the interesting part of a backend is procedural. Hardware detection, model-metadata introspection, conditional argument construction, and payload rewriting cannot be expressed as data without the data becoming a scripting language. Whatever cannot be declared leaks back into lemond's C++, so the "escape hatch" reintroduces exactly the code the format was meant to avoid.
Option 2 — In-process binary plugins (dlopen). Load the backend's
integration as a shared library inside lemond. Fully expressive as real code.
The limit is security: it runs inside lemond's memory space with lemond's
full privileges, which defeats the sandboxing that running third-party
binaries demands, and a fault in the plugin takes the server down with it.
Option 3 — Out-of-process adapter program (this proposal). Keep the adaptation as real code, but run it as a separate, sandboxed peer process behind a narrow contract. It is expressive (code, like Option 2) and isolated (sandboxable and crash-proof, like Option 1 was intended to be), without either option's limitation.
Options 1 and 2 both decide the question by where the code goes — data in lemond's interpreter, or code in lemond's process. Both, for opposite reasons, leave untrusted behavior inside lemond's trust domain. The fix is to change what they are optimizing:
2.1 The trust boundary is the process boundary
Draw the trust boundary at the process boundary.
A backend is really three concerns glued together in one C++ class today:
(1) a declarative identity (BackendDescriptor), (2) arbitrary procedural
adaptation (argv construction, hardware detection, payload rewriting,
protocol translation), and (3) shared subprocess + HTTP orchestration
(spawn, port lease, health, watchdog, eviction) that WrappedServer already
provides.
Option 1 pushes concern (2) into data and fails; Option 2 pushes (2) into lemond's process and is unsafe. This proposal keeps (2) as real code, but runs it in its own sandboxed process, drawing the line at that process boundary:
- lemond stays a trusted C++ orchestrator with a closed, typed set of capability contracts. It is a fact authority, a resource broker, and the owner of the process/API edge.
- The plugin (an "adapter program") is a self-contained, sandboxed,
out-of-process peer process. It owns its own engine argv and spawn, performs
the adaptation, and talks to
lemondover a narrow, versioned contract.
A fault in an adapter cannot take lemond down. An adapter cannot reach beyond
what its sandbox grant allows or read secrets lemond chooses not to hand it.
And because the adapter runs behind a wire contract, the language it is
written in is irrelevant to lemond — a static binary (C/Rust/Go) or a
container, whichever fits the engine.
The result satisfies expressiveness and isolation at once: the sandbox becomes the security boundary, and the adapter becomes whatever code an engine really needs.
3. Requirements, derived from first principles
3.1 Functional needs
- N1 — Arbitrary procedural adaptation. Hardware detection, model introspection, conditional flags, payload rewriting, new multimedia modalities. These are real, present in every non-trivial backend, and inherently code. A system that cannot express them only supports "spawn + HTTP passthrough" engines. This rules out pure JSON.
- N2 — No mandatory runtime on the host. An adapter must not assume Python (or any interpreter) is installed; it is self-contained or explicitly brings its own (container).
- N3 — Distribution with low friction. A recipe author publishes without
rebuilding/relinking
lemondor maintaining in-tree C++ — but without forcing a complex plugin-authoring build system. - N4 — Declarative identity.
lemondneeds searchable, presentable metadata (name, capabilities, devices, slot policy, grants) before it runs anything, for the CLI/GUI,/system-info, and consent flows. - N5 — Closed, bounded public surface. Plugins must not open arbitrary new public endpoints. New capability kinds are additive, reviewed contract changes.
3.2 Security needs (from the actual threat surface)
Auditing today's code shows there is currently no OS sandboxing for backend
processes and they inherit lemond's full environment. The real leaks to
close:
- S1 — Least privilege at the adapter boundary. Untrusted code runs with only the filesystem/device/network it declares, enforced by the OS, with default-deny network egress.
- S2 — Secret isolation. Backend processes must not inherit the ambient
environment (
LEMONADE_API_KEY,LEMONADE_ADMIN_API_KEY,LEMONADE_<PROVIDER>_API_KEY). Today they leak by inheritance. - S3 — Crash / stability isolation. A plugin crash must not take
lemonddown or disturb other slots. - S4 — Consent before capability grant. The user reviews and approves declared grants before enabling a third-party plugin.
- S5 — Supply-chain integrity. Installed plugins are pinned and content- hashed; the sandbox limits blast radius regardless of binary honesty.
3.3 The synthesis
Needs N1+N2+N3 resolve to "the plugin carries its own procedural logic as a self-contained, sandbox-capable subprocess." N4+N5 resolve to "lemond describes that program declaratively and exposes only a closed, typed contract to it." S1–S5 resolve to "the sandbox wraps the outermost untrusted process — the engine for built-in/passthrough, the adapter (which contains the engine) for external-adapter plugins — with env scrubbing and default-deny grants."
4. The shape: the "Capability Gateway"
4.0 Sandboxing vs. the adapter tier (two independent decisions)
Two things are easy to conflate and must be kept separate:
- No engine binary is trusted, in-tree or not. Being built-in grants no
trust: we do not re-audit
kokoroon every release any more than we audit a third-party plugin. Zero-trust is the aim, and the sandbox is the baseline for every engine process — built-in and plugin alike. Today'sllama.cppsubprocess runs unsandboxed; under this design its engine process itself is wrapped by the kernel sandbox, exactly like any plugin's. "Built-in" buys packaging and support, never a trust exemption. What is trusted is lemond's own coordinator code (secret custody, arbitration, the process/API edge), and only because it is lemond's own code — the smallest trusted core that cannot be avoided — not because the backends it manages are trusted. - The adapter process (the middle tier) is conditional. It exists only when lemond is not the one doing the adaptation — i.e. only for external-adapter plugins. Built-in backends keep lemond's adaptation in-process (it is the coordinator's own code, not a trust claim about the engine) and their engine is still sandboxed; passthrough plugins need no adapter either.
So the process tree is:
| Kind | Process tree | Sandboxed? |
|---|---|---|
| Built-in backend | lemond → engine | engine sandboxed |
| Passthrough plugin | lemond → engine | engine sandboxed |
| External-adapter plugin | lemond → adapter → engine | adapter and engine sandboxed |
The sandbox boundary sits at the engine (the untrusted thing) in every case; the adapter tier is added only when an untrusted adapter owns the adaptation. This keeps "even built-in engines are sandboxed" as a stated guarantee while avoiding an unconditional 2→3 tier expansion.
4.1 The manifest
Declarative and small — it describes where the adapter is and what it may do, never how. Procedural intelligence is not here.
{
"api_contract_version": "1",
"recipe": "my-engine-custom",
"display_name": "My Engine (Custom)",
"extends": "llamacpp", // optional: inherit base recipe's options/contracts
"adapter": {
"kind": "binary", // "binary" | "container" | "passthrough"
"source": "https://cdn.example/adapter/linux-x64/my-engine-adapter",
"sha256": "9f3a…c2",
"version": "1.2.3"
},
"engine": {
"reserved_args": ["--port", "--host", "-m"] // reject user args colliding with these at merge
},
"capabilities": ["chat_completion", "completion", "embeddings"],
"capability_enable_args": { // "I support X; enable it with arg A"
"embeddings": ["--embeddings"], // appended when model_info.type == embedding
"completion": ["--jinja", "--metrics"] // unconditional runtime args (always)
},
"capability_contracts": {
"chat_completion": { "response_fields": { "model": {"mode":"echo_request_model","sensitive":false} } }
},
"slot_policy": "standard", // standard | exclusive_npu | coexist_by_type | unmetered
"model_management": "lemond_managed", // or "self_managed"
"model_reads": { "main": { "form": "gguf" } },
"lifecycle": { "spawn_shape": "single", "requested_ports": 1,
"readiness": {"type":"http","endpoint":"/health"},
"self_manages_downloads": false },
"sandbox": {
"read_paths": ["{hf_cache_models}", "{binary_dir}"], // scoped to models subtree, NOT cache root (D1)
"write_paths": ["{scratch_dir}"],
"devices": ["/dev/dri"],
"network": { "allow_egress": false, "allow_loopback": true },
"env_allowlist": ["GGML_VK_VISIBLE_DEVICES"]
},
"facts_scope": ["devices","igpu","rocm_arch","resolve_checkpoint","model_info","vram_pressure"]
}
The schema has two adapter flavors. passthrough (§4.2) reuses an
existing binary/container with a manifest only; binary / container ships
an adapter program for engines that exceed the passthrough envelope.
The manifest is "data, not code," but with one deliberate, bounded exception:
launch-time token substitution. The argv / command arrays may contain
tokens ({checkpoint:main}, {port}, {ctx_size}, {custom_args}) that lemond
substitutes once, when constructing the spawn — this is how lemond hands the
engine its model path, port, and user-option values. That is the sanctioned,
declarative subset: tokens exist to launch the engine, and they appear only in
the manifest's argv/engine blocks, never in request-time logic.
What is barred is request-time adaptation in data: no request_transform
field maps, no per-request payload rewriting expressed in JSON, no scripting.
Those are what the earlier proposals tried to force into data and what
degenerated into a Turing-complete language. Request-time adaptation lives in
the named-transform registry (§6, bounded, vetted) or — when genuinely open-ended
— in an adapter program (§4.2, out-of-process sandboxed). So the statement "no
command-template scripting" is accurate; the launch-time {token} substitution
is the one declarative exception, and it is only ever about how the engine is
started.
Token vocabulary (launch-time only, resolved by lemond):
| Token | Meaning |
|---|---|
{checkpoint:role} / {checkpoint_relative:role} |
resolved checkpoint path (absolute / lexical-relative) for a recipe role (main, draft, mmproj, ...) |
{hf_cache_models} |
the cache models subtree (hub/models--…), the scope a model read grant should use — never the cache root, which also holds credential files (D1) |
{port} / {host} |
leased port / host for the engine (FD/socket custody or fallback, §5.6) |
{ctx_size}, {threads}, {gpu_layers}, ... |
declared option values (defaults live in options) |
{socket_fd} |
the leased socket descriptor, when the engine accepts an inherited FD |
{custom_args} |
validated user-supplied arguments, appended to the argv array |
{env:VAR} |
forbidden — leaks lemond's environment into child argv and bypasses the scrubber |
Tokens are substituted only in the argv/engine blocks at spawn; the env
block values may also reference token values but never read lemond's ambient
environment. See §6 for the separate request/response surface.
The manifest only declares identity,
capability claims, lifecycle/ownership (driven by the recipe junctions of §9.1),
and the sandbox grants plus fact scope — precisely what a consent dialog, a
kernel sandbox, and the FactService need. The schema above shows both adapter
flavors; the capability_contracts, model_management, model_reads,
lifecycle, and facts_scope fields are covered in §4.3, §5, and §9.
The optional platforms block is the OS × accelerator matrix. It declares
which host/device combinations the recipe runs on and what differs per
combination — GPU device nodes, accelerator env vars, arch gates, and any
security-posture override (e.g. a wsl2 vs windows_native distinction).
Platform gating reuses the same machinery lemond applies to built-ins
(BackendSupport, SystemInfo::check_recipe_supported,
ModelManager::filter_models_by_backend), so lemond enables/disables recipes
against the detected OS and GPU at model-cache build time, exactly as it does
today. The matrix is DRY by construction: the base recipe (options,
lifecycle, capability contracts, core grants) is defined once, and each platform
block supplies only its deltas — argv_extra, env, devices, support, and
an extends pointer that inherits another platform's block and overrides a few
fields. lemond flattens this to an explicit per-OS × accelerator view before
validation; the terseness is author ergonomics, not a hiding of what the resolver
sees.
To make the matrix concrete, here is the ds4 recipe (DwarfStar 4, serving
DeepSeek V4 Flash via antirez/ds4 — a real backend, see
PR #3047), condensed to
the platform-matrix essentials. It is a passthrough native binary:
"platforms": {
"base": {
"argv": ["--model","{checkpoint:main}","--host","{host}","--port","{port}",
"--ctx","{ctx_size}","{custom_args}"]
},
"linux": {
"rocm": { "extends":"base",
"argv_extra":["--gpu-layers","{gpu_layers}"],
"env": { "HIP_VISIBLE_DEVICES": "{hip_visible_devices}" },
"devices": ["/dev/kfd","/dev/dri"],
"support": { "archs": ["gfx1151"] } },
"cuda": { "extends":"base",
"argv_extra":["--gpu-layers","{gpu_layers}"],
"env": { "CUDA_VISIBLE_DEVICES": "{cuda_visible_devices}" },
"devices": ["/dev/dri"] }
},
"wsl2": { "extends":"linux" },
"windows_native": { "extends":"base", "sandbox": { "mode": "degraded" } }
}
The base argv is written once; each platform supplies only its deltas
(argv_extra, env, devices, support), and extends inherits another
block. In practice the real ds4 recipe publishes only the linux.rocm row
(and, by extends, wsl2) and gates on gfx1151; the cuda / windows_native
rows here are illustrative of how the matrix generalizes, and would be absent
(or marked unsupported) for a backend that does not ship those targets. lemond
flattens the matrix and reuses check_recipe_supported /
filter_models_by_backend to enable or disable the recipe against the detected
OS and GPU, exactly as it does for built-ins.
4.2 The zero-code fast path
Many engines already speak a standard OpenAI-compatible HTTP surface and need
no adapter at all. For those, adapter.kind: "passthrough" makes
lemond's own generic adapter do the work: launch the engine with a bounded
argument template, FD-socket-custody + health-wait, then HTTP-passthrough standard
endpoints. This was already demonstrated by the ExternalBackendServer prototype
(PR #2880) running
dflash-rocm (podman) and custom llamacpp recipes with no adapter program —
just a manifest.
Zero-code works when the only adaptation needed is a bounded, named set of known transforms (see §6). Engines that exceed that envelope — protocol translation (whisper), binary sniffing + multipart (trellis), out-of-band streaming (moonshine) — graduate to the external-adapter mode. Both modes are first-class and equally secure.
Containerized engines are the clearest zero-code case. A container is the
engine, so the manifest needs only to declare how to launch it, which capability
endpoints to proxy, and where the model files are. The DFlash speculative-
decoding server is a working example (containerized in the ExternalBackendServer
POC); zml/llmd is analogous. A condensed example follows; the pattern is the
same for any containerized OpenAI-compatible engine. Commands are typed argv
arrays, never shell strings:
"adapter": { "kind": "passthrough" },
"engine": {
"kind": "container",
"runtime": "podman",
"image": "ghcr.io/zml/llmd:latest",
"devices": ["/dev/kfd", "/dev/dri"],
"groups": ["render"],
"mounts": [ { "host": "{hf_cache_models}", "container": "/models", "mode": "ro" } ],
"network": "host",
"argv": ["llmd", "--model", "/models/{checkpoint_relative:main}",
"--fd", "{socket_fd}", "--ctx", "{ctx_size}", "{custom_args}"],
"stop": { "command": "podman", "args": ["rm","-f","-t","0","lemonade-{recipe}-{port}"] }
},
"capability_contracts": { "chat_completion": { "endpoint": "/v1/chat/completions" } },
"sandbox": { "note": "exempt_container_backend" }
image, devices, groups, mounts, network, and argv are structured
fields lemond validates before invoking the runtime; {custom_args} are
range-checked args appended to the array, not interpolated into a shell string.
This is what prevents a recipe from escalating to --privileged or mounting the
host root.
Note that the example's argv uses --fd {socket_fd} — the ideal path
where the engine can accept the leased socket descriptor passed by lemond (§5.6).
Engines that cannot take an inherited FD instead receive --port {port} and
use loopback-port-with-peer-verification (§5.6), where lemond verifies the
connected peer is the spawned engine rather than trusting the numeric port.
Because the container runtime provides the isolation boundary, the sandbox
block is informational for containerized engines — lemond marks the recipe
exempt_container_backend rather than double-wrapping podman in the OS
sandbox. That means container backends are consent-gated, not
sandbox-contained: the OS sandbox cannot confine a container (nono exempts
container runtimes), so the security value sits entirely in the admin-consent
gate. Each imported container recipe declares exactly what it wants —
network:host, device nodes (/dev/kfd, /dev/dri), mounts, and any
widening --security-opt (e.g. seccomp=unconfined) — and the admin approves
that exact set before it can load. Consent must be authenticated
(LEMONADE_ADMIN_API_KEY) and must show the requested-and-enforced
capability set, not a vague "sandboxed" badge — especially on native Windows,
where container backends ship unsandboxed. Container-exemption detection must
use the structured engine.kind: container declaration, never a substring match
on "podman"/"docker" in argv (which is spoofable and validates nothing).
When zero-code is not enough, the adapter path is a manifest change, not a new
architecture. An engine with a genuinely non-OpenAI protocol, request-time
payload transformation beyond the named-transform registry, or an out-of-band
streaming head selects adapter.kind: "binary" / "container" and ships an
adapter program that owns the engine and does the translation behind the
capability contract. Such an adapter honors a short boot contract: it is launched
with the lease + launch context (model paths, ports, grants), pulls facts from
lemond on demand, spawns or hosts the engine, translates each capability request
into the engine's own protocol, and returns a typed result — all under the
sandbox and lease, crash-isolated from lemond. Moving an engine between the
zero-code and adapter paths is an adapter manifest field — the contract,
sandbox, lease, and fact service are identical either way.
4.2.1 Binary-drop-in variants of a built-in backend (variant_of)
A common and important plugin is a fork / repackaged build of a backend that is
already built into lemond — a community llama.cpp with extra kernels
(ROCmFPX), a vendor's nightly, or an "upstream vulkan" reference. These are
wire-compatible: same llama-server, same OpenAI surface, same argv the
built-in constructs. For these, re-declaring the full argv in the manifest is
redundant and error-prone; the author only really needs to say where to get the
binary.
A dedicated flavor supports this: variant_of: "<recipe>" — the plugin is a
binary-drop-in for a named built-in backend, and lemond reuses that built-in's
procedural handling (argv construction, reserved-arg validation, capability
enable-args, platform env) against the sandboxed external binary. It inherits
every declarative base too (options, lifecycle, capability contracts). The
manifest reduces to binary provenance plus any argv deltas:
{
"api_contract_version": "1",
"recipe": "llamacpp-rocm-nightly",
"adapter": { "kind": "passthrough" },
"variant_of": "llamacpp",
"version_policy": "roll_forward", // follow github_latest, not pin (S5 opt-out)
"engine": {
"kind": "binary",
"source": "https://github.com/lemonade-sdk/llamacpp-rocm/releases/download/{version}/llama-{version}-ubuntu-rocm-{arch}-x64.zip",
"binary": "llama-server"
},
"platforms": { "linux": { "rocm": { "support": { "archs": ["gfx1151","gfx1150","gfx1100"] } } } }
}
- The trust model is preserved. The procedural logic stays in trusted,
in-lemond C++ (
variant_ofpoints at a built-in whose argv builder runs unchanged); the fork binary remains an untrusted, sandboxed subprocess. lemond never trusts the binary — it constructs the argv it would have handed the built-in and confines the external process the same way. - Distinct from the
_binconfig override.config set llamacpp.<key>_bin=works today but is a global, mutable, single-slot config mutation with no sandbox, consent, reproducibility, or named coexistence.variant_ofis a declarative, sandboxed, consent-able, version-pinned recipe; several forks can coexist as named recipes rather than overwriting one_binslot. - Distinct from declarative
extends.extendsreuses the declarative base but still requires the author to writeargv.variant_ofadditionally inherits the built-in's procedural argv handling, soargvcan be omitted (or overridden per-platform viaargv_extra). - Version policy.
variant_ofdefaults to hash/version pinning (S5); aroll_forward(github_latest) policy is an explicit opt-out for authors who want to track a moving nightly. This is the one trade the "track the nightly" benchmark and marketplace workflows need. - Honest caveat. Full inheritance couples the fork to the built-in's current
argv behavior — usually desired (the fork tracks upstream) but not always what
the fork's binary expects. The manifest therefore allows per-platform
argv_extra/reserved_argsoverrides on top of the inheritance.
This is the mechanism that collapses the fork registry in the "Backend Battle
Nightly" benchmark
(PR #3069) — four llama.cpp
forks, each a short variant_of: "llamacpp" manifest instead of an ad-hoc
config set + retry dance — and it is exactly the "massively simplified by 2951"
that the review there flagged.
4.3 The capability contract
Closed, typed, versioned. The set of capability kinds maps to the existing
server_capabilities.h interfaces (chat_completion, completion,
responses, embeddings, reranking, transcription, image_*, tts,
audio_generation, model_3d_*, classification). New kinds are added by a
reviewed, additive contract change — a plugin cannot invent routes.
The transport is a first-class, essential spec (Phase-1b critical path), and binary payloads need a side channel. Several things the capability contract needs but the design must not leave implicit:
- Transport and framing. lemond↔adapter control/data rides a Unix-domain
socket (or loopback on Windows), with explicit framing, message versioning,
request-id multiplexing, cancel-vs-completion semantics, backpressure, and a
max message size. RPC channel auth is peercred-PID-match plus a per-lease
0700socket — peercred alone verifies the uid, not that the peer is this adapter. - Binary payloads (audio, images) must not be base64-in-JSON. Base64 inflates ~33% and forces full buffering, which kills streaming latency. Audio/image is carried over a shared-memory or FD-passing side channel alongside the RPC, not encoded in the JSON envelope. This is dictated by §13's streaming-latency requirement, and the transport RFC must cover it.
The common control plane is UDS-everywhere where possible; the engines that cannot speak UDS (cpp-httplib llama.cpp/whisper) are exactly those forcing the loopback data plane (§5.6), so the honest posture is "UDS control plane + peercred-authenticated loopback data plane for legacy engines," not one assumed transport.
5. The five contract primitives
The lemond↔adapter contract is exactly five primitives. Together they carry the whole of what a backend needs from the server and what the server needs from a backend:
5.1 Fact service — lemond as just-in-time fact authority
lemond holds a trusted SystemInfoCache and ModelInfo. Rather than pre-
compute and push every fact a plugin might need (which would force lemond to
enumerate the universe of model/hardware state), lemond answers on demand:
PLUGIN -> { "op":"fact.get", "facts":["devices","igpu","system_info",
"resolve_checkpoint"], "scope": {...} }
LEMOND -> { "op":"fact.result", "facts":{ ... only the adapter's own slot ... } }
Read-only, secret-free, scoped to the adapter's own slot (no cross-model
leak), authoritative. The plugin never peers raw /sys//proc; it asks lemond.
Static vs. dynamic facts. Two kinds of fact behave differently and are served differently:
- Static facts (
igpu,rocm_arch,model_info,resolve_checkpoint) are stable for a given model/device; the adapter may query them JIT at any time. - Dynamic facts (
vram_pressure, memory pressure) are not safe to query in isolation — the world can change between the query and the adapter acting on it. These are returned atomically inside the lease grant (§5.2), so the adapter reasons over reserved state, not a stale snapshot.
Why this matters: supports_embeddings / supports_reranking are just
model_info.type (an enum lemond already holds); iGPU detection is
SystemInfo::get_has_igpu(). These are facts lemond already computes — they
live in C++ only because there was no channel to an out-of-process adapter.
The fact service provides that channel.
5.2 Resource broker / lease protocol — single-writer coordination
Admin approval is consent to capability; it is not scheduling. Even with perfect approval, two plugins cannot arbitrate against each other — only lemond sees all resident models, live device usage, and memory pressure. So arbitration is not a trust gate; it is a coordination problem no single plugin can solve locally. lemond becomes a resource broker that maps resources + config + preferences + realtime status into a just-in-time entitlement per plugin:
PLUGIN -> { "op":"lease.acquire", "want": {"slot_policy":"exclusive_npu",
"devices":["npu0"], "mem_mb":0, "fd_sockets":1} }
LEMOND -> { "op":"lease.grant", "lease_id":"…", "entitlement":{
"devices":["npu0"], "mem_mb":4096,
"sockets": [ {"fd": 7, "uds":"/run/lemonade/rec-{lease_id}.sock"} ],
"npu_exclusive":true, "ttl_sec":120,
"dynamic_facts": {"vram_pressure":0.41, "reserved_mem_mb":4096} } }
PLUGIN -> { "op":"lease.heartbeat" } / LEMOND -> { "op":"lease.revoke",
"reason":"lru_evict|memory|admin", "grace_ms":5000 }
The grant carries the leased socket FDs (§5.6) and the dynamic facts (§5.1) atomically: the adapter acts on the exact reserved memory/device state, not on a value fetched earlier that may have changed. Reclamation is physical (revoke → grace → kill the PID namespace/cgroup/job (§7)), not negotiated.
Entitlements are cooperative over physical VRAM — state this honestly. The
lease is atomic over lemond's own bookkeeping (which adapter holds what), but
lemond does not own the GPU: mem_mb has no kernel enforcement unless a
delegated cgroup's memory.max exists, and an unrelated process can consume
VRAM immediately after a grant. Where a delegated cgroup is available it is
enforced; otherwise it is advisory. The lease protocol also needs epochs /
sequence numbers (so heartbeat and revoke cannot cross on the wire), a rule
for in-flight requests at revoke, and TTL auto-extension during a long
generation. NPU exclusivity should reuse the router's existing path rather than
being a second, parallel arbiter.
5.3 Model inventory RPC — "ask the backend for its models"
For plugins that self-manage their model catalog, lemond must be able to relay
user intent without downloading (this mirrors BackendOps::discover_models,
which already exists):
LEMOND -> { "op":"model.inventory" }
PLUGIN -> { "op":"model.inventory.result", "models":[{"id":"…","status":"ready"}] }
LEMOND -> { "op":"model.pull", "model":"…" } // relay user intent
5.4 Checkpoint-form declaration — imported-form + read grants
"my model dir must contain X" is a read-permission / declaration problem, not
a trust problem. The plugin declares its expected form (plain GGUF, a directory
with genai_config.json, a .onnx file, an index.json); lemond grants a
scoped read and verifies completeness; the plugin inspects itself within its
grant.
5.5 Global response pipeline / safe-response-fields — the API edge
The plugin already knows the local paths (it loaded the model); the question is
only what the client sees. Today only LlamaCppServer normalizes the
response model field (and only on the non-streaming path — the SSE stream
passes the backend's own model string through), so the status quo is smaller and
leakier than "one mechanical helper." This strengthens the case for a global
response pipeline applied uniformly, driven by a per-capability
"safe-response-fields" contract, and it must explicitly cover the streaming
path, not just single JSON responses — otherwise streaming responses continue
to leak local paths and secrets. This is a presentation concern at the
API/telemetry edge, not a per-plugin trust gate.
5.6 And the two pieces that hold them together
- Socket custody via FD/UDS passing — lemond owns the network edge and
passes it to the adapter, rather than handing out a port number and checking
reachability. It
bind()s a Unix-domain socket (Linux/macOS) or a loopback listener (Windows) itself and passes the file descriptor to the adapter on spawn (the systemdLISTEN_FDSidiom). The adapter forwards that FD to its engine. This eliminates port collisions and TOCTOU races (lemond never trusts that "whoever bound 127.0.0.1:N" is the intended engine); custody is by FD, so there is no port to hijack.- Honest primary path for the real fleet. FD custody covers engines that
accept an inherited listening socket (vLLM/uvicorn via
--uds/--fd, podman socket-activation). Engines that expose only--host/--port(cpp-httplib llama.cpp, whisper.cpp — the majority) cannot take an inherited FD without patching upstream. For those, the shippable primary is loopback-port-with-peer-verification: lemond binds and forwards to127.0.0.1:port, and verifies the connected peer is the spawned engine viaSO_PEERCRED/LOCAL_PEERPID/ the Windows PID owner — not a bare reachability check. FD/UDS custody is then an opportunistic optimization, not the assumed default. - No veth-in-host is needed (and is not safely buildable unprivileged).
Creating a veth pair into the host namespace needs
CAP_NET_ADMIN, which an unprivilegedlemonddoes not have; and unprivileged user namespaces are restricted on several distros. Where an isolated namespace is desired, use pasta (passt, the rootless-podman userspace networking default) or a lemond-proxiedsocketpair— neither requiresCAP_NET_ADMIN. Evaluate user-namespace availability at runtime and degrade honestly. - Platform mechanics: FD/UDS passing is the Unix ideal; on Windows the
equivalent is handle inheritance via
CreateProcesswithPROC_THREAD_ATTRIBUTE_HANDLE_LIST(orWSADuplicateSocket), not a raw FD. The architectural custody concept is identical; the OS API differs and is codified in the Phase 1b RFC.
- Honest primary path for the real fleet. FD custody covers engines that
accept an inherited listening socket (vLLM/uvicorn via
- The capability contract (§4.3) bounds public behavior.
No shell, ever. Adapter and container commands are always launched from
typed argv arrays (command + args: string[]) via execvp/CreateProcess,
never from a shell-parsed string. Custom arguments are validated against a
reserved-flag allowlist before reaching the engine, and container recipes
separate the image, mounts, and devices as structured fields so a recipe cannot
inject --privileged, host mounts, or shell operators through {custom_args}.
5.7 The capability RPC also carries request control and trace
Two things the capability contract must carry that are easy to omit:
- Request cancellation (
cancel_request). LLM generation is expensive; on client disconnect the signal must propagate to the adapter immediately so it can drop the compute batch. The capability RPC includes acancel_requestmessage per in-flight request id, not just global teardown. - Distributed trace propagation. Because the adapter runs out-of-process,
the RPC payloads carry trace context so lemond can stitch spans across the
process boundary into OpenInference/OpenTelemetry. These follow the W3C
Trace Context standard (
traceparent/tracestate), so the Gateway's telemetry interoperates with standard collectors without custom mapping in the adapter.
6. The named-transform registry
The zero-code adapter stays "data, not code" by exposing a small, versioned, closed set of named transforms lemond ships. A manifest selects a name (data); lemond runs the vetted implementation. Selecting a transform never supplies arbitrary code.
When is an adapter actually needed? Only when the adaptation is procedural. The decision rule is: if a backend's needs are expressible as a bounded set of known transformations — field renames, prompt wrapping, image-defaults merges, size resolution, forced defaults — then the named-transform registry covers it, no adapter program is required, and the process tree stays at two tiers (lemond + sandboxed engine). A separate sandboxed adapter program is only introduced when the backend requires genuine procedural computation the registry deliberately does not express — bespoke protocol translation, binary MIME sniffing/multipart, out-of-band streaming, or request-time logic that can't be a named, bounded hook. So the registry is not a fallback set of happy-path transforms; it is the default answer, and the adapter is the exception reserved for open-ended procedural work. A healthy registry covers the common cases; the adapter exists only for the cases the registry is intentionally closed against.
This "covers most cases" is about the population of out-of-tree engines a user would plug in — which are predominantly schema-conformant, OpenAI-compatible wrappers — not about the 15 in-tree backends. Among those 15, a large fraction are high-touch precisely because maintainers wrote bespoke C++ for engines that don't fit a standard schema (§10); those stay in-tree. The registry targets the easy majority of new engines; the adapter targets the hard minority. The two are not in tension once "most cases" is read as "most engines Lemonade is asked to add," not "most existing built-ins."
The named-transform registry is one of several declarative surfaces — it is not the whole of "no adapter." Many things that look like procedural logic are actually declarative, just not request/response rewriting. The other surfaces that keep a backend adapter-free:
capability_enable_args— a backend declares "I support capability X; enable it with argument A." lemond appends the arg when the model's own type needs it (matchingmodel_info.type, which the recipe already knows). This is how--embeddings/--rerankingresolve: they are a capability declaration plus an enable-arg, not runtime branching. The same field also carries unconditional runtime args (e.g.--jinja --metrics) — apply-always, a degenerate case.- Recipe-declared backend args — static per-recipe arguments the backend declares it wants.
- Advertised lifecycle capabilities — e.g.
downsize: supportedplus a known backend operation to invoke; "supports downsize" is a boolean the backend advertises, and lemond orchestrates the fixed op. reserved_argsmerge validation — because lemond is already the one merging{custom_args}into the argv array, the reserved set is a validation rule in the merger: lemond rejects a user arg that collides with--port/-mbefore the array is built. Declarative schema + a reject rule, not adapter logic.- Per-platform env / install / support — already covered by the platform
matrix and the no-managed-install path (library paths become platform
enventries; per-arch builds viasupport).
The unifying line: an adapter is needed only for open-ended procedural
computation — bespoke protocol translation, binary MIME sniffing + multipart,
out-of-band streaming, or arbitrary per-request logic that no bounded declarative
surface can express. If a backend's needs are expressible as a combination of
these declarative fields (named transforms, capability_enable_args,
recipe args, lifecycle capabilities, merge validation, platform env), no adapter
program is required. This is the correct and complete statement of the rule;
the named-transform registry is the request/response slice of it.
Every entry below is grounded in a real C++ backend, not invented:
| name | kind | purpose | source impl |
|---|---|---|---|
legacy_max_tokens |
request | alias max_completion_tokens→max_tokens |
json_utils.cpp:69 |
sanitize_grammar_bounds |
request | strip oversized min/max Length/Items from tool schemas (llama #17473) | llamacpp_request.cpp:98 |
fit_max_tokens |
round-trip | pre-fit max_tokens via /tokenize |
vllm_server.cpp |
echo_request_model |
response | response.model = request model |
llamacpp_server.cpp:621 |
image_defaults |
request | merge steps/guidance_scale/sampler/seed | thenoise_server.cpp:207 |
resolve_size |
request | "WxH"/w/h → width/height |
TheNoise :191 |
image_prompt_xml |
request | wrap params into <sd_cpp_extra_args> |
sdcpp_server.cpp:403 |
field_map |
request | rename fields (prompt→caption) |
ThinkSound |
default_field |
request | force fields (kokoro model/stream) |
Kokoro |
model_field_rewrite |
request | set model to backend checkpoint name | FLM |
Deferred to the external-adapter mode (not zero-code): whisper multipart translation, trellis MIME sniffing + multipart, moonshine 3-port streaming head, SD ESRGAN secondary-subprocess upscale, and any custom per-engine rewrite beyond the closed set.
What keeps the registry non-Turing-complete
- Closed, versioned, additive vocabulary.
- Intent-only, no control flow — a transform rewrites a structured message; it cannot loop, branch arbitrarily, or open endpoints.
- Vetted implementation in lemond — the manifest never supplies code.
- Ordered + scoped per capability, individually consentable and auditable.
The registry is deliberately non-scripting: there is no generalized scripting, only named, bounded hooks over real implementations drawn from today's backends. The "Turing-complete JSON" failure mode is avoided by construction.
7. Security model
- Sandbox wraps the outermost untrusted process — the engine for
built-in/passthrough, the adapter (which contains the engine) for
external-adapter plugins (see §4.0 tier table). Filesystem + network
confinement via kernel allow-lists, with default-deny network egress: Linux
Landlock + seccomp-bpf (
no_new_privs), macOS Seatbelt (sandbox_init), and Windows via WSL2 (Landlock against the WSL2 Linux kernel). The sandbox engine is nono, which is the natural cross-platform home (its--block-net/ network allow-list realizes the default-deny egress). Nono is invoked one of two ways (see §13):- Embedded via C FFI (preferred). Compile the nono Rust library into
lemondand callSandbox::apply()through its C FFI bindings, using the fork + sandbox + exec pattern: lemondfork()s, the child applies the capability set (generated in-process from the manifest) andexec()s the engine. Sandbox primitives apply to the calling process and its descendants, so the sandbox must be applied in the child, never to lemond itself. This collapses the tree fromlemond → nono → enginetolemond → engine(no supervising wrapper process), removes the separate nono binary as a runtime dependency, and keeps lemond self-contained. - CLI wrapper (fallback). Spawn
nonoas a supervising wrapper around the engine, for platforms/arches without a vendored nono library. Native-Windows kernel sandboxing is not currently offered by nono (WSL2-only); that remains a follow-on platform decision. The irony worth stating: thedlopenproposal removes the exact boundary the WG exists to create. Out-of-process is not a nice-to-have — it is the security model.
- Embedded via C FFI (preferred). Compile the nono Rust library into
- Teardown must reap the whole tree, not just the immediate child.
PR_SET_PDEATHSIGand process-group teardown alone will leak a grandchild (e.g. the adapter crashes and itsllama-servergets reparented to init, silently holding VRAM). Teardown is an orchestrator responsibility layered on the sandbox: on Linux and WSL2 it uses PID namespaces + cgroup v2 (or, in the container path, runc already provides them); a native-Windows path would use Job Objects with kill-on-close if/when that path exists. Reclamation kills the namespace/cgroup/job, so the kernel reaps every descendant — no VRAM zombies. - Secret isolation as a prerequisite. Scrub
LEMONADE_*+LEMONADE_<PROVIDER>_API_KEYfrom child env by default; pass only an explicitenv_allowlist; keep runtime cloud keys in lemond's memory only. This applies to built-ins too, independent of plugin work.- Secrets are a filesystem concern too, not just an env one. A read grant
on a cache root can hand a sandboxed backend credential files that live
there (e.g.
~/.cache/huggingface/token,stored_tokens; other model repos keep their credentials similarly). The model read grant must therefore be scoped to the specific checkpoint file or the snapshot subtree, never a parent directory that also holds credentials. Because grants are default-deny, correct scoping excludes credential files by construction — no per-repo deny-list is needed. Env scrubbing alone does not close this: an over-broad grant gives the secret back through the filesystem.
- Secrets are a filesystem concern too, not just an env one. A read grant
on a cache root can hand a sandboxed backend credential files that live
there (e.g.
- Default-deny grants, honest declaration. The manifest's
sandboxblock is what the kernel enforces; an over-privileged recipe is more visible and more easily rejected. - Consent is an authenticated action, and re-consent on widening. lemond is
headless and multi-client, so "the user reviews and approves grants" needs a
home: approval is a
LEMONADE_ADMIN_API_KEY-authenticated action, which protects against remote and accidental installs (it is not protection against same-user local malware, which lies outside any sandbox's remit). Any update that widens a grant (sandbox,facts_scope,network) triggers mandatory re-consent. The consent UI shows the enforced-not-declared posture per host — because on some targets (native Windows, container backends) the OS sandbox is not the containment, and the user must see what is actually enforced, not what the manifest merely declares. Optional publisher signing layers over the sha256 TOFU pin.
The FactService is itself a security improvement: because lemond answers facts
from its trusted cache, the untrusted adapter never needs raw /sys//proc
access — a smaller sandbox grant, not a larger one.
8. Trust boundary: what stays in lemond, what moves to the adapter
Every adaptation concern in a backend resolves into one of three classes, and that classification determines where the concern lives:
- Class A — Stays in lemond (architectural, not per-backend). Secrets custody (A1 provider/cloud keys held only by lemond, handed out as scoped handles; A6 backend-needed credentials delivered via explicit allowlist, never ambient); global coordination (A2 — resource arbitration/scheduling is the broker's single-writer job, §5.2); process/API-edge custody (A5 — lemond owns the socket/process/response boundary and reclamation, §7) + the sandbox.
- Class B — Moves to the adapter once lemond exposes a fact channel. Hardware
and model metadata, answered by lemond's FactService (§5.1). This is how
"hardware introspection" and "model capabilities" — things every backend needs
to decide flags — become pluggable without exposing raw
/sys//proc. - Class C — Moves to the adapter (mechanical). Field maps, prompt wrap, multipart, image-defaults, companion-file discovery, job-poll loops, streaming shape.
Two specific cases are worth stating plainly.
Resource arbitration is coordination, not a gate. Admin approval is consent to capability; it is not scheduling. Even fully-approved plugins cannot arbitrate against each other — only lemond sees all resident models, live device use, and memory pressure. So arbitration is about scheduling, and it is structurally impossible for any single plugin to do locally. lemond remains the broker (entitlements, leases, eviction) whether a backend is in-tree or a plugin.
argv integrity is the plugin's job; output presentation is lemond's edge. lemond does not build the adapter's engine argv — the plugin does; lemond holds socket custody (FD/UDS passed on spawn, §5.6) and the plugin validates its own engine's reserved flags. Output sanitization (hiding local paths from clients) is a global response-pipeline step, applied at the API/telemetry edge.
The consequence: the only things that keep a backend in lemond are secrets and process/API-edge custody + the sandbox — both orthogonal to whether a backend is in-tree or pluggable. Architecture, not trust, stays in lemond.
9. Lifecycle and recipe intersection
Mapping the 15 in-tree backends (plus the containerized prototype) against the ideal WrappedServer lifecycle
(LOADING → READY ⇄ IN_USE → DOWNSIZING/DOWNSIZED → EVICTING → UNLOADED)
reveals a small set of orthogonal lifecycle axes that actually differ:
- Spawn shape — single-subprocess / multi-port (Moonshine: 3) / secondary-subprocess (SD: ESRGAN upscale) / none (Cloud).
- Readiness —
/healthdefault vs. custom (FLM →/api/tags). - Model ownership — lemond-downloaded / self-managed (FLM) / no-local-model (Cloud).
- Hardware conditionality — at load (llama iGPU mmap) vs. at request.
- Downsize/restore — only llama.cpp has real KV-erase via
/slots. - Request adaptation — field-remap / prompt-wrap / multipart / sniff / job-poll / model-normalize.
- Streaming — SSE / byte-stream / non-SSE / TCP out-of-band.
- Imported-checkpoint form — plain /
genai_config.json/.onnx/index.json/ companion.gguf.
The key finding: most lifecycle is generic and reusable (port lease, spawn, health, LRU/eviction, watchdog, state machine, secret scrubbing, sandbox). The per-backend delta is a short, enumerable list, and that list is the plugin contract surface. The motivating question is therefore answerable in bounded terms: which lifecycle axes must a plugin express? — not an open-ended can the hardware-conditional C++ of a given backend be turned into data at all? (it cannot, which is why the adapter is code).
9.1 Where recipes constrain the lifecycle
Recipes (server_models.json) and the backend descriptors jointly decide what
a load can do, and a plugin contract must honor these junctions:
- Checkpoint types are the recipe↔lifecycle junction. Checkpoints are not a
single
mainfile: recipes carrymain,draft(spec-decode),mmproj(vision),npu_cache(whisper NPU device cache), andtext_encoder+vae(SD/split models). A recipe's checkpoint set determines which artifacts lemond provisions before spawn and which argv shape results. self_manages_downloadsflips provisioning direction. For FLM, the backend owns its model catalog; lemond must not try to download, and must tolerate the model appearing only after the backend pulls it. The plugin contract needs an explicit "model readiness" handshake here, not just a health probe. This is distinct from "no local model" (Cloud), which has no checkpoints to fetch at all — it does not setself_manages_downloads.- Egress ⇄ download validation rule. A recipe that declares
self_manages_downloads: truemust also declare outbound network rights, because it has to fetch its own model; lemond rejects a manifest as invalid otherwise. This rule applies to self-managed-download backends only — a no-local-model backend (Cloud) must not be granted host egress on the strength of being "self-managed," because it isn't.- Egress must be a host allowlist, not a bare bool. A self-managed
backend that gets
allow_egress: trueholds both outbound network and read access to the weights — exfiltrating them is then within policy. Replace the coarse boolean with an enforced egress host allowlist (e.g.huggingface.co,cdn-lfs.huggingface.co), realized via a host-filtering proxy or pasta, and acknowledge the residual risk in the doc.
- Egress must be a host allowlist, not a bare bool. A self-managed
backend that gets
- Egress ⇄ download validation rule. A recipe that declares
slot_policy(standard/exclusive_npu/coexist_by_type/unmetered) is the router-level resource contract. A plugin recipe maps to one of these so eviction and slot logic stay in the trusted router.dynamic_modelsdecouples "what models exist" fromserver_models.json(FLM provider catalog, Cloud discovery). A plugin declaring it supplies the model-inventory hook (§5.3).model_readsis a plugin-declared format contract, not just aformtag. A recipe lists the checkpoint roles it reads and their required-vs-optional status, and — for GGUF — the quant families it can read (supported_quants). This is declarative, not procedural: it lets lemond filter or reject models by format at model-cache build / load time (reusingfilter_models_by_backend), so a plugin is never asked to serve a checkpoint format it cannot read. A community llama.cpp fork that adds new GGUFs (e.g. ROCmFPX quants upstream llama.cpp cannot read) declares exactly which families it supports; the vanilla backend does not list them, so a ROCmFPX-quantized model is only offered to the fork. Seeexample-rocmfpx-passthrough.jsonandplugin-model-recipe-interaction.mdfor the concrete shape and the related gaps (plugin-named roles, typed options, provisioning hooks).
These junctions drive the manifest's model_management, model_reads, and
lifecycle fields (§4.1).
9.2 Design consequences
- The launch bundle carries the full resolved checkpoint set, not just a
single
mainpath. Multi-role artifacts and compound models are the norm. - A plugin declares its lifecycle "personality" up front — spawn shape, readiness type, ownership direction, downsize support, adaptation kind, streaming shape, checkpoint form — because that is the manifest's real content (it feeds the consent dialog and the sandbox grant).
- Provisioning is directional — lemond-owned (download then hand paths) vs. self-managed (wait for the backend to provision) — handled by the model- inventory RPC and a model-readiness handshake, not a single download path.
- Multi-port and secondary-subprocess backends are declared properties, not
hidden assumptions (the base
WrappedServerassumes a single port; Moonshine and SD's ESRGAN upscale prove that is a simplification).
10. Candidacy: which backends could be plugins
10.0 How existing backends map
Assessed against the eight lifecycle axes (§9), the 15 in-tree backends (plus the containerized prototype) fall into three natural tiers. The distribution is the point: a solid minority are clean, mechanical plugins; the flagship workhorses are not, by design.
| Candidacy | Backends |
|---|---|
| High | TheNoise, OpenMoss, ThinkSound, Kokoro, Moonshine, containerized (born-plugins) |
| Medium | OnnxRuntime, Whisper, stable-diffusion, AceStep, Trellis, RyzenAI, FastFlowLM, vLLM |
| Low | llama.cpp, Cloud (no subprocess, architecturally lemond's) |
Natively pluggable (High): TheNoise (image-defaults merge, size resolve,
/text2image body), OpenMoss (spawn + .extras.gguf companion discovery),
ThinkSound (field-map + dit/t5/vae companions), Kokoro (index.json
resolution + forced model/stream), Moonshine (multi-port + TCP streaming),
and containerized recipes (born plugins). These are mechanical, self-contained
adaptation — the natural prototype starting point.
Bounded adaptation (Medium): OnnxRuntime (exact-one .onnx selection),
Whisper (multipart protocol translation is pluggable, while the .rai NPU-cache
fetch, its path-traversal guards, and NPU/CPU switching stay in the coordinator),
stable-diffusion (prompt-XML wrap + split/single branching are pluggable; the
secondary ESRGAN upscale subprocess is a two-spawn shape), AceStep (async
/synth+/lm job poll), Trellis (binary MIME sniffing + multipart), RyzenAI
(spawn + passthrough; the ExclusiveNpu policy is router enforcement, not a
backend-body property), FastFlowLM (self-managed provision + NPU validate +
custom readiness, driven by the model-inventory and fact primitives), vLLM
(quant detection + ROCm shim + CWSR door + max_tokens pre-fit — expressible as
facts + plugin-owned argv + mechanical transforms, kept in-tree mainly for
risk).
Kept in-tree (Low): llama.cpp (flagship; iGPU-mmap, capability
flags, reserved-flag handling, real KV-erase downsize) — and Cloud (no
subprocess; secrets custody makes it architecturally lemond's by design).
"Kept in-tree" here means kept as a coordinator-managed WrappedServer for
stability, not that it is trusted: its engine is sandboxed like every other
engine (see §4.0), so it earns no trust exemption from being built in.
Note on llama.cpp: each of its ostensibly "high-touch" pieces is actually a
declarative surface (§6) — --embeddings/--reranking are capability
enable-args matched to model_info.type, --load-mode none is a per-device
default, reserved-flag handling is reserved_args merge validation, and
KV-erase downsize is an advertised lifecycle capability. So llama.cpp is
expressible as a zero-code passthrough; it sits in the Low row purely as a
flagship-stability choice, not because the Gateway cannot express it.
10.1 Candidacy is a portfolio choice, not a capability limit
The tier a backend lands in reflects how much it is worth externalizing, not whether the Gateway can express it. The five contract primitives (§5) — the fact service, the lease broker, the model-inventory RPC, the checkpoint-form declaration, and the response pipeline — cover the concerns that had kept the "heavier" backends from looking pluggable at all:
- Self-managed model ownership (FLM) → a declared model-inventory RPC.
- Imported-checkpoint form (
.onnxdir /genai_config.json/index.json) → a checkpoint-form declaration with a scoped read grant. - llama reserved-flag collision → a
reserved_argsmerge-validation rule (§6); lemond rejects colliding user args before building the argv array, holding socket custody (FD/UDS, §5.6). - iGPU mmap-disable → a per-device default (
--load-mode noneon iGPU), decided from a hardware fact or user override — not plugin logic. - Local-path hiding → a global response pipeline step.
- vLLM quant / shim / CWSR /
max_tokens→ facts + plugin-owned argv + mechanical transforms. - NPU exclusivity and eviction → the lease broker (coordination, not trust).
- Cloud secrets → secret custody, which is architecturally lemond's by design.
The genuinely inexpressible set is therefore very small — secrets and process/API-edge custody + the sandbox, none of which is per-backend. Under this lens, RyzenAI, FastFlowLM, and vLLM resolve to Medium (their earlier "Low" was largely a lack of the right contract primitives, not a hard capability limit). The only backends that remain "Low" are llama.cpp (kept in-tree for flagship stability, not capability) and Cloud (no subprocess; architecturally lemond's by design). Every remaining choice about externalizing a backend is about how much it buys today (flagship stability, install burden, concentrated domain knowledge), not about whether the Gateway can express it.
10.2 Migration does not gate adoption
This is a design rule, stated once and firmly:
Adopting the Gateway and porting existing in-tree backends are independent decisions. Neither is a prerequisite for the other.
- The Gateway is purely additive — one new
GatewayServerclass plus an out-of-tree contract. It removes, rewrites, or gates nothing. - In-tree backends remain coordinator-managed C++ indefinitely by default, with their engines sandboxed under the same zero-trust baseline as plugins (§4.0). "Stays in-tree" is a packaging choice, not a trust claim.
- Migration is opportunistic, cost-benefit-driven, and reversible — never required for adoption.
- Adoption is proven by one non-trivial plugin working well, not by migrating the fleet.
Coexistence is the intended end state. The strongest objection to out-of- process plugins assumed that adopting them forces the whole fleet to be re-expressed. It does not. The Gateway is adopted on its own merits, alongside the existing in-tree backends (which keep their engine sandboxes either way), and each backend is moved to plugin form only if and when that pays off.
11. Mapping onto the existing codebase
What stays: the capability interfaces (server_capabilities.h) as the
capability vocabulary; subprocess orchestration (spawn, port, health, watchdog,
eviction, PR_SET_PDEATHSIG) in WrappedServer; slot policy, auth/CORS/
quad-prefix, client API; install/version-pin flow.
What changes: one generic GatewayServer : WrappedServer parses the
manifest, launches the adapter under the sandbox, and maps capability RPC to
the I*Server interfaces. ExternalBackendServer
(PR #2880) is a degenerate
case where the adapter is pure HTTP passthrough. Built-ins stay as
coordinator-managed WrappedServer C++, upgraded to enforce secret scrubbing
and engine sandboxing (zero-trust baseline, §4.0). The marketplace distributes
manifest + pinned adapter artifact (binary/container + sha256).
The simplification dividend: lemond stops owning every engine's adaptation logic. It owns one small, stable protocol and one sandbox/lifecycle engine.
12. Comparison of the options
| Axis | JSON descriptors | In-process dlopen | Capability Gateway |
|---|---|---|---|
| Procedural adaptation | ✗ | ✓ | ✓ (in adapter) |
| Trust/privilege boundary | ✓ out-of-process | ✗ same as lemond | ✓ sandboxed subprocess |
| Crash isolation | ✓ | ✗ | ✓ |
| Hardenable by kernel sandbox | ✓ | ✗ (bypasses it) | ✓ (sandbox is the adapter) |
| No mandatory host runtime | ✓ | ✓ | ✓ (binary or container) |
| Marketplace submission friction | low | ~ (ABI/build) | ✓ (pinned binary/image + tiny manifest) |
| New capability kinds | needs lemond changes | can drift | additive reviewed change |
| Secret isolation | needs work | needs work + unsafe | designed in |
13. What is genuinely open
Honest risks and decisions remain, to be resolved in the RFC process:
- Adapter ecosystem bootstrap. Zero-code handles the common path; the first real external adapter is a larger authoring step than a one-line descriptor. The named-transform registry + passthrough template mitigates this.
- Protocol stability/versioning. The capability contract needs an ABI/transport freeze before marketplace adoption.
- Cross-platform sandbox posture and scope. The sandbox boundary
(filesystem + network confinement) is available on all three platforms via
nono: Linux Landlock, macOS Seatbelt (a deprecated Apple API — works but unsupported upstream), and Windows via WSL2 (Landlock; feature coverage depends on the WSL2 kernel — "~85%" holds only for the stock 6.6 kernel, and rises with a rolling kernel; native Windows unsupported). Linux is the mature first target. Landlock's network limits must be stated honestly: its net rules are port-scoped, not address-scoped ("allow the control port" allows it to any host), cover TCP only (UDP/ICMP exfiltration sails through), and the network hooks are absent entirely below kernel 6.7 / Landlock ABI v4 (so "default-deny egress via Landlock" is simply unavailable on Ubuntu 22.04's 5.15). Where possible, prefer UDS-everywhere plus a seccompAF_INET/AF_INET6deny for clean, address/ABI-independent egress-default-deny, and reserve Landlock-net for the genuine outbound-TCP case. Sandbox invocation is itself a decision (§7): prefer embedded via C FFI (fork + sandbox + exec, no supervising wrapper, self-contained lemond), keeping the CLI wrapper only as a fallback for platforms without a vendored nono library. Two things are deliberately treated as orchestrator extensions, not sandbox primitives, and are outside the initial sandbox work: process-tree teardown / resource reclamation (the broker's job; see §7 — solvable per-platform, no off-the-shelf primitive on macOS) and GPU custody / VRAM allocation (the resource broker's job, §5.2 — the sandbox grants the device, the broker decides which device and how much). The Windows posture is WSL2-gated; native Windows sandboxing is a follow-on platform decision. The consent UI must reflect enforced-not-declared posture per host, since native Windows and container backends ship unsandboxed. - Performance of the extra loopback hop. Negligible for token streaming; benchmarks needed for streaming TTS/audio.
- Supply chain for binaries. Pin + sha256 + consent baseline; optional code signing later; sandbox is the primary protection.
- Wrapping vs. owning the engine. Recommendation: adapter owns/spawns the engine and presents one face to lemond.
Open RFC questions (from the trust-boundary work + review): who is authoritative
when a fact and a plugin observation disagree (recommend lemond); FD/UDS socket
custody semantics and the fallback path for engines that cannot take an
inherited FD; "safe-response-fields" granularity; lease/revocation protocol;
whether the fact service needs a dry-run/preview mode; and how to propagate
cancel_request and trace context across the RPC that the process boundary
unavoidably introduces.
14. Suggested roadmap
- Adopt the process-boundary framing in the charter — replace "declarative JSON descriptors as the plugin" with "manifest + sandboxed adapter program behind a versioned capability contract."
- Land the secret-scrubbing + default-deny baseline for all backend subprocesses, independent of plugin work — the highest-value security fix.
- Prototype
GatewayServer+ one real adapter (start with a mostly- mechanical backend: TheNoise → OpenMoss → a containerized recipe, each proving one primitive) to prove adaptation + sandbox together, and to exercise the fact service (a fact-drivenigpu/embedding decision). - RFCs: Phase-1a manifest schema; Phase-1b capability contract + transport;
Phase-1c fact service + lease protocol (the broker); Phase-2 model inventory
- checkpoint-form + response pipeline.
15. Verification appendix
Two things this proposal is often judged on: whether it holds up against the concerns raised during the charter review, and whether its claims about the existing prototype are accurate. Both are grounded in the primary sources (review threads and the actual branch code), not assumed.
15.1 Design constraints surfaced by the charter review
The Working Group charter (PR #2951) review raised a small set of substantive concerns that this design must meet. They are stated here as design constraints:
| Concern | What the design must satisfy |
|---|---|
| JSON can't express arbitrary backend behavior | A plugin must carry real procedural logic (the adapter program), not a JSON template that would degenerate into a scripting language. |
| In-process code inherits lemond's privileges | Untrusted plugin code must run out-of-process, under the sandbox, never in lemond's memory space. |
| New capability kinds / endpoints | The public surface stays closed; new capabilities are additive, reviewed contract changes, not plugin-opened routes. |
| Marketplace / build friction | Distribution must not require a complex plugin-authoring build; pinned binaries/containers + a tiny manifest keep friction low. |
| Model/preparation ownership | With self-managed backends, lemond relays user intent and lets the backend own its catalog (a declared inventory contract), rather than fighting over downloads. |
| Secret handling for model pulls | Backends that legitimately need a token receive it via an explicit, scoped allowlist — never ambient environment. |
This document's premise directly follows from these constraints: the trust boundary sits at the process boundary, lemond is a fact authority + resource broker + API presenter, and adaptation is code that runs sandboxed.
15.2 Prototype & sandbox reality check (read the code, not the prose)
These findings come from reading the prototype branch
(feat/custom_backends
and feat/custom_backends_nono)
directly:
ExternalBackendServeris a passthrough envelope, not an adapter. Its capability methods are thinforward_request(path, request)calls; it spawns the external binary, expands a bounded token map at launch, health-probes, then dumbly proxies HTTP. No payload rewrite, no protocol translation, no request-time introspection. This confirms the key review concern and marks the exact missing layer the Gateway's adapter program supplies (out-of-process, under the sandbox).- Env scrubbing is real in the POC.
build_sanitized_env(process_linux.cpp) filtersLEMONADE_*,*_API_KEY,*_TOKEN,*_SECRET,*_PASS,*_AUTH, and undeclared GPU-variability vars from the child env. On the branch its reach is uneven by platform: Linux is always scrubbed; Windows scrubs only when extra env is passed (a bare spawn inherits the full environment); macOS skips it on theposix_spawnpath used for backends. The filter is also substring-based (API_KEY,TOKEN, ...), which over-scrubs (e.g.TOKENIZERS_PARALLELISM) and can under-scrub creatively named secrets — arguing for the manifest'senv_allowlistmodel. - Sandboxing wires by default in the branch.
ProcessManager::start_processresolves aSandboxPolicyfrom config when none is passed;nonoread/write grants, device nodes,--allow-port,--block-net, and NPU sysfs grants are built; container backends (podman/docker) are auto-exempted.process_linux.cppaddssetpgid+ whole-process-group teardown.
Taken together, the prototype proves out-of-process + sandbox + secret-scrubbing are viable; it just lacks the adapter-translation leg and the contextual pull protocol the Gateway adds. This supports (rather than undercuts) the direction.
17. Glossary
Terms and codes used in this document, defined for a standalone reader.
| Term | Definition |
|---|---|
| lemond | The Lemonade HTTP server process (the executable lemond); the trusted coordinator this design keeps under its control. "Trusted" refers to lemond's own code (secret custody, arbitration, the process/API edge) — the smallest unavoidable trusted core — never to the backends it manages, whose engines are all sandboxed regardless of origin (§4.0). |
| adapter (plugin) | The sandboxed, out-of-process program that owns a backend engine's adaptation. adapter.kind is binary/container (a shipped program) or passthrough (no program — lemond's own generic/handling adapter). |
passthrough |
The adapter.kind value meaning "no shipped adapter program is needed": lemond's own launcher + HTTP proxy using launch-time tokens and/or variant_of inherited handling — the zero-code path where lemond launches a standard-HTTP engine and proxies capability endpoints to it. Not a built-in WrappedServer — lemond's generic adapter. |
variant_of |
A binary-drop-in plugin flavor (§4.2.1): the recipe inherits a named built-in backend's procedural argv/handling against a sandboxed external binary, so the manifest is mostly binary provenance. |
version_policy |
How a recipe's binary is versioned: pin (hash/version, the secure default) or roll_forward (follow github_latest) — an explicit opt-out for "track the nightly" workflows. |
| named-transform registry | The bounded, versioned set of vetted request/response rewrite hooks lemond ships (§6); one of the declarative surfaces, not the whole of "no adapter." |
| capability_enable_args | Manifest field declaring "I support capability X; enable it with argument A" (model-conditioned via model_info.type, or unconditional). |
| fact service | lemond's read-only, scoped, secret-free introspection channel (§5.1); static vs dynamic facts (§5.1). |
| lease / entitlement | The broker's grant of a resource to an adapter (§5.2): device, memory, socket FDs, exclusivity, TTL, and dynamic facts. Cooperative over physical VRAM (see §13). |
| FD/UDS socket custody | lemond binds the socket and passes the file descriptor/FD to the adapter (§5.6); the no-port-to-hijack mechanism. |
| nono | The kernel-sandbox engine (nono.sh, from the nolabs project): Landlock on Linux, Seatbelt on macOS, Landlock-under-WSL2 on Windows. lemond delegates filesystem/network confinement to it. Invoked either embedded via C FFI (Sandbox::apply, fork + sandbox + exec, preferred) or as a CLI wrapper (fallback) — see §7/§13. |
| A1 / A6 | Class-A secret-custody codes: provider/cloud keys held only by lemond (A1); backend-needed credentials delivered via explicit allowlist (A6), never ambient. |
| A2 | Class-A resource-coordination code — scheduling/arbitration is the broker's single-writer job (§5.2), not a plugin gate. |
| A5 | Class-A process/API-edge custody — lemond owns the socket/process/response boundary and teardown (§7). |
| CWSR door | A gfx1151 AMD workaround in the vLLM backend: needs_gfx1151_cwsr_fix() enriches a startup timeout error; distinct from --enforce-eager (keyed on non-discrete-HBM arch). |
| ROCm shim | A generated sitecustomize.py that vLLM's ROCm path injects (via PYTHONPATH) to prevent its CUDA NVML probe from faulting on AMD-only systems. |
| cross-platform posture | Linux = full sandbox (Landlock); macOS = Seatbelt (deprecated Apple API, works); Windows = WSL2-only (native Windows sandbox unsupported); §13. |