Skip to content

spec: expose OpenAI request lifecycle to plugins #1331

Description

@ndizazzo

Status: Draft specification

Related umbrella: #1233. This issue specifies the plugin-platform prerequisite exposed by the Capsule Emit sidecar experiment and its findings on #1233.

Summary

Add an opt-in, permissioned OpenAI exchange lifecycle contract for installable mesh-llm plugins.

A plugin that declares this contract must be able to:

  1. inspect the original OpenAI request envelope before model dispatch;
  2. return a bounded pre-dispatch policy decision;
  3. observe the effective request and selected serving route before execution;
  4. observe the terminal response, error, denial, or cancellation;
  5. correlate every phase without becoming an HTTP reverse proxy.

The contract must cover chat completions, legacy completions, and Responses, including streaming requests. It must preserve ordinary OpenAI streaming rather than forcing the host to buffer a response.

Motivation

The current plugin architecture is primarily provider-oriented: plugins contribute operations, HTTP/MCP surfaces, external inference endpoints, and mesh capabilities. It does not let an installable plugin observe or make a policy decision about traffic arriving at mesh-llm's own OpenAI API.

The native serving plugin ABI exposes generation token lifecycle data, but not the original OpenAI request or the client-visible response. It therefore cannot compute a receipt over the API request/response pair.

OpenAiHookPolicy is a useful in-process seam, but it is compiled into the backend wrapper, currently invokes only the before-chat hook, and is not available to an installable out-of-process plugin.

The Capsule Emit experiment consequently had to run as a reverse-proxy sidecar. That proved the record format but introduced a second port, changed the request topology, buffered/re-synthesized SSE, and could only describe itself honestly as an external collector. Other policy, audit, DLP, guardrail, routing, and compliance plugins will hit the same limitation.

Goals

  • Make the request envelope available to explicitly authorized plugins.
  • Allow an authorized plugin to allow, abstain, or deny before dispatch.
  • Expose both what the client submitted and what mesh-llm actually dispatched.
  • Expose what mesh-llm actually returned to the client.
  • Preserve exact byte-level commitments alongside parsed OpenAI semantics.
  • Support streaming without changing response bytes, chunk order, or latency characteristics beyond bounded hook overhead.
  • Make evidence absence, hook failure, policy denial, backend error, cancellation, and successful completion distinct terminal states.
  • Keep the contract generic; Capsule Emit is one consumer, not a core special case.
  • Let explicitly authorized plugins consume the node's public identity/attestation bundle and obtain a narrowly scoped owner-signed delegation for a plugin signing key.
  • Preserve existing plugins and mixed plugin-protocol deployments.

Non-goals

  • Arbitrary request mutation in v1.
  • Giving a plugin raw Authorization, Cookie, API-key, or owner-control headers.
  • Giving a plugin owner-key, node-key, keystore, passphrase, keychain, or other private-key material.
  • Providing an arbitrary-byte signing oracle backed by the owner or node transport key.
  • Claiming that an API-boundary observation proves model execution.
  • Replacing the native generation lifecycle or future TEE/runtime attestation.
  • Making every plugin an observer by default.
  • Requiring an HTTP sidecar or sending core inference through a plugin endpoint.
  • Exposing these host-private lifecycle operations through MCP or public plugin HTTP projection.

Proposed contract

Declaration and authorization

Add an optional manifest declaration for an OpenAI exchange hook. A possible shape is:

openai_exchange_hook:
  contract: mesh.openai.exchange.v1
  endpoints: [chat_completions, completions, responses]
  phases: [request_received, backend_selected, exchange_finished]
  request_body_access: buffered
  response_body_access: streamed
  sanitized_headers: [x-capsule-client-nonce]
  decision_mode: admit
  response_metadata: true

The manifest is a request for access, not the grant itself.

Host configuration must explicitly grant:

  • endpoint and phase subscriptions;
  • request/response body access;
  • sanitized header names;
  • permission to make admission decisions;
  • permission to add response metadata;
  • failure policy and deadline.

Installing an ordinary plugin must not implicitly expose prompts or completions. The console/CLI should clearly warn when a plugin requests body access.

Prefer an additive manifest plus host-private service invocations over a new parallel plugin runtime. Existing InvokeService and side-stream transport can carry the control messages and large bodies. If protobuf additions are needed, they must be additive and feature-negotiated.

Lifecycle phases

1. request_received

Runs after mesh-llm has accepted and bounded the HTTP body but before routing, guardrail execution, or backend dispatch.

The envelope includes:

  • exchange_id: stable for the full exchange;
  • request_id / trace_id where available;
  • endpoint kind and API version;
  • HTTP method and normalized path;
  • observation point and origin class: local ingress, tunneled ingress, internal retry, or other explicit value;
  • sanitized allowlisted headers;
  • exact decoded request body bytes or a side-stream reference, according to the grant;
  • SHA-256 of those exact bytes, computed by the host;
  • parsed OpenAI request JSON when parsing succeeded;
  • parse status and content type;
  • receipt/correlation metadata already attached by the host.

Allowed synchronous result:

  • abstain: no policy opinion;
  • allow: explicitly permit this plugin's policy;
  • deny: stop before dispatch and return a stable OpenAI-shaped error;
  • annotations: plugin-namespaced, bounded metadata carried to later phases;
  • response metadata reservation: bounded, allowlisted response headers such as a provisional receipt ID.

V1 does not allow a plugin to rewrite the request. Mutation would require separate original/effective commitments, ordering semantics between multiple plugins, and a clearer security model.

A plugin's allow result never bypasses mesh-llm's own validation or another plugin's denial.

2. backend_selected

Runs after routing and core request transformation, but before the selected backend is invoked.

It includes:

  • the original exchange_id;
  • effective parsed request and its host-computed digest;
  • selected public model ID;
  • provider/backend kind;
  • local or remote serving target;
  • retry/attempt number;
  • available model/package/runtime identity descriptors;
  • core transformations that materially changed the request;
  • accumulated namespaced annotations.

A plugin with admission permission may deny here as well, allowing policies to require a particular model identity, provider, peer, or evidence level before bytes reach a backend.

The contract must distinguish a route claim from verified artifact or runtime evidence. Missing identity fields are represented as unavailable, never inferred.

3. exchange_finished

Exactly one logical terminal event is attempted for every accepted request:

  • completed;
  • policy_denied;
  • request_invalid;
  • backend_error;
  • transport_error;
  • client_cancelled;
  • timed_out;
  • evidence_unavailable/internal_hook_failure.

It includes:

  • exchange_id and attempt history;
  • HTTP status;
  • exact response-body bytes for buffered responses, or an ordered response transcript/side-stream for streamed responses;
  • SHA-256 of exact client-facing bytes computed at the final host emitter;
  • parsed/reassembled semantic response when available;
  • usage and timing fields;
  • selected route and model descriptors;
  • whether any bytes were sent to the client;
  • terminal evidence completeness and any dropped observer data.

For streaming, the byte tap must sit after all host transformations and immediately before socket emission. The plugin receives a copy; it is not placed inline between the emitter and client.

A streamed transcript must define whether it covers SSE frame payloads, complete encoded SSE frames, or both. The format and digest rules require published test vectors.

Streaming and payload transport

Large request bodies, multimodal inputs, and streaming outputs must use the existing negotiated side-stream mechanism rather than the long-lived control connection.

Requirements:

  • control traffic remains responsive;
  • every stream is correlated to exchange_id and phase;
  • byte order is preserved;
  • cancellation closes the observer stream;
  • bounded queues report truncation or loss explicitly;
  • best-effort observation never silently claims a complete transcript;
  • required mode applies backpressure only when the operator selected it;
  • no hook may force the host to buffer a complete SSE response merely to compute a receipt.

The host should compute wire digests while forwarding, so an observer can verify completeness without being trusted to report the digest correctly.

Multiple plugins and failure policy

Pre-dispatch decisions should execute concurrently under one host-owned deadline because v1 has no mutation ordering.

Aggregation rules:

  • any successful deny wins;
  • allow and abstain do not override a deny;
  • annotations are namespaced by plugin ID;
  • a required plugin timeout/disconnect produces a fail-closed service error;
  • a best-effort plugin timeout/disconnect allows execution and records evidence unavailable;
  • required/best-effort is host policy, not something a plugin can impose on installation;
  • repeated failures trigger a circuit breaker and visible degraded state.

No plugin may block health checks or the control plane while a request hook is running.

Response metadata

A plugin may request bounded response metadata only through an explicit grant.

  • Header names must use an approved namespace.
  • Security, hop-by-hop, CORS, content-length, content-type, and authentication headers cannot be replaced.
  • Streaming plugins may reserve a provisional correlation/receipt ID at request_received because final headers are sent before the final capsule exists.
  • Non-streaming hooks may optionally return final metadata before headers are committed, within the same deadline.
  • A response header is a reference, not evidence by itself.

Privacy and security

  • Never expose Authorization, Proxy-Authorization, Cookie, Set-Cookie, owner-control credentials, or transport secrets.
  • Header access is allowlist-only and case-normalized.
  • Inline data URLs and multimodal request bodies count as sensitive body access.
  • Logs and plugin summaries must not contain raw prompt/completion bodies.
  • Body limits, stream limits, timeouts, and concurrency limits are host-owned.
  • Host API/status surfaces must show which plugins currently have request/response access.
  • Plugin removal or permission revocation takes effect without leaving a hidden traffic path.
  • Request digests are commitments, not confidentiality; documentation must warn about dictionary attacks on low-entropy content.

Observation points and duplicate records

The envelope must state where it was observed. At minimum:

  • gateway_ingress;
  • serving_host_ingress;
  • backend_dispatch;
  • client_egress.

A routed mesh request may be visible at more than one node. exchange_id, hop_id, attempt, local_peer_id, and observation_point must make those records distinguishable. The host must not present a gateway observation as proof from the serving target.

Host identity and signing delegation

Receipt/audit plugins need to connect their statements to mesh-llm's existing identity hierarchy without reading private keys.

mesh-llm currently keeps these concepts separate:

  • owner identity: the Ed25519 identity created by mesh-llm auth init;
  • node identity: the QUIC/iroh endpoint key;
  • SignedNodeOwnership: a short-lived owner-signed certificate binding owner identity to node endpoint identity;
  • release build attestation: trusted release-signer evidence about the packaged mesh-llm executable, separate from ownership and remote runtime attestation.

Add an explicitly permissioned host service with two narrow operations.

ReadIdentityBundle

Return public, bounded evidence already held by the host:

  • local node endpoint ID;
  • SignedNodeOwnership and its local verification summary;
  • owner ID and owner signing public key when present;
  • release build attestation and verification summary when present;
  • host version/build/artifact digest carried by the release attestation;
  • installed plugin ID, version, source, and artifact/package digest when available;
  • freshness and evidence-source metadata.

The response must preserve distinctions between missing, present-unverified, verified, expired, revoked, and invalid. It must not forward a boolean such as owner_verified without the signed evidence needed to verify it.

DelegatePluginSigningKey

The plugin supplies an Ed25519 public key and requests one registered signing scope. The host constructs and signs a fixed-shape PluginSigningDelegation; it does not sign plugin-provided arbitrary bytes.

Suggested claim:

PluginSigningDelegationV1 {
  delegation_id,
  owner_id,
  owner_sign_public_key,
  node_endpoint_id,
  node_ownership_cert_id,
  plugin_id,
  plugin_version,
  plugin_artifact_digest?,
  delegated_signing_public_key,
  scope,
  issued_at_unix_ms,
  expires_at_unix_ms
}

For Capsule Emit, the initial scope is mesh.inference.capsule.sign.v1.

Requirements:

  • authenticate plugin_id from the live plugin connection, not request JSON;
  • bind installed plugin version/artifact identity from host-owned metadata;
  • bind the current node endpoint ID and owner certificate ID;
  • use canonical serialization with a fixed domain tag such as mesh-llm-plugin-signing-delegation-v1:;
  • sign only when the owner key is already available to the running host;
  • cap validity and scope through host policy;
  • make delegation issuance/renewal an explicit manifest permission;
  • return unavailable when no owner identity is loaded rather than prompting during inference;
  • never expose owner signing bytes, node secret bytes, keystore paths, passphrases, or keychain material;
  • never allow a plugin to choose arbitrary claims, owner/node IDs, plugin identity, validity beyond policy, or signing domain;
  • renew before expiry outside the request path;
  • invalidate/reissue on plugin artifact, plugin key, node identity, owner identity, or relevant certificate change;
  • include a delegation ID so revocation can be added or enforced explicitly;
  • expose bounded status and audit events without logging public keys or IDs into OTLP metrics.

The existing owner key signs delegations only at plugin startup/renewal. Per-request statements are signed by the delegated plugin key, avoiding high-frequency use of the owner key.

A verifier chain is:

  1. verify the receipt/COSE statement with the delegated plugin public key;
  2. verify PluginSigningDelegation with the owner public key;
  3. verify owner_id is derived from that owner public key;
  4. verify SignedNodeOwnership binds the same owner and node endpoint ID;
  5. apply expiry and local owner/node/delegation revocation policy;
  6. verify release build attestation independently when supplied.

The release attestation remains build provenance. Linking it into the evidence bundle does not convert it into remote runtime attestation.

A software plugin key remains exportable and can be misused until delegation expiry. The delegation improves attribution, scoping, rotation, and revocation; it does not prove that the key was used only on the named node. A future host-managed, TPM/TEE-backed, or non-exportable delegated key can strengthen the same chain.

Fallback and policy

Identity mode is host policy:

  • self_attested: plugin uses an independent key and labels it accurately;
  • owner_delegated: require a valid PluginSigningDelegation and SignedNodeOwnership;
  • owner_delegated_required: fail plugin readiness/required evidence when delegation is unavailable;
  • hardware_delegated: reserved for future attested/non-exportable keys.

No mode silently upgrades its assurance label.

Compatibility requirements

The plugin protocol is currently generation 2 and initialization checks exact equality. This feature must not strand existing plugins.

Acceptable approaches include:

  1. express the hook through existing v2 capability/service invocation and side streams, with a new optional manifest field and a package-level minimum mesh version; or
  2. introduce protocol generation 3 while changing negotiation so a new host continues to run generation-2 plugins.

Whichever path is chosen:

  • old plugins continue to run without modification;
  • a new hook plugin fails startup clearly on a host that cannot provide the declared contract;
  • unknown phases/fields are ignored only when the plugin marked them optional;
  • required permissions cannot degrade silently to no observation;
  • conformance tests cover new-host/old-plugin and unsupported-host/new-plugin behavior.

Implementation areas

Expected ownership:

  • crates/mesh-llm-plugin: manifest types/builders, lifecycle DTOs, author-facing handlers, side-stream helpers, identity-bundle/delegation DTOs, conformance fixtures;
  • crates/mesh-llm-identity: canonical PluginSigningDelegation claim and verification primitives, without plugin-runtime dependencies;
  • crates/mesh-llm-host-runtime/src/plugin: subscription registry, grants, invocation, deadlines, health/circuit-breaker integration, identity bundle projection, and constrained delegation service;
  • crates/openai-frontend: generic lifecycle seam covering chat, completions, Responses, errors, cancellation, and final emitters;
  • crates/mesh-llm-host-runtime/src/network/openai: attach origin/route context and cover local plus tunneled paths;
  • mesh-llm-config: explicit grants, failure policy, limits, and validation;
  • docs/plugins: author contract, privacy model, lifecycle state machine, and exemplar plugin.

Do not special-case Capsule Emit by plugin name.

Acceptance criteria

  • An installed test plugin can inspect a chat-completions request before backend invocation and deny based on its parsed body.
  • A denied request never reaches the backend and returns a stable OpenAI-shaped policy error.
  • An allowed request reaches the backend byte-for-byte unchanged by the plugin contract.
  • The same plugin receives the effective request and selected route before dispatch.
  • The terminal event covers non-streaming success, streaming success, validation error, policy denial, backend error, timeout, and client cancellation.
  • Host-computed request and response byte digests match independent test calculations.
  • Streaming remains live passthrough; the host does not buffer the full response and the plugin cannot reorder or rewrite chunks.
  • An observer overflow or disconnect is represented as incomplete evidence rather than a successful complete observation.
  • Authorization and cookie headers are absent from plugin fixtures and integration tests.
  • Body access is disabled without an explicit host grant.
  • A plugin without identity/delegation permission cannot read identity evidence or request a delegation.
  • An authorized plugin can read the full public identity bundle and verify it independently.
  • An authorized plugin can obtain an owner-signed delegation for its public key without receiving owner/node private material.
  • Delegation claims bind the authenticated plugin, current node endpoint, current owner certificate, scope, and bounded validity.
  • Arbitrary bytes/claims and unregistered scopes cannot be signed through the delegation service.
  • Delegation renews outside request handling and is invalidated by plugin-key/artifact, node, owner, or certificate changes.
  • Self-attested, owner-delegated, missing, expired, revoked, and invalid states remain distinct.
  • Multiple hook plugins obey deterministic deny/failure aggregation.
  • Local ingress and inbound tunneled serving paths are covered and identify their observation point.
  • Existing generation-2 plugins continue to initialize and operate.
  • An unsupported host rejects a plugin that requires this contract with an actionable error.
  • A maintained exemplar demonstrates observe-only and admission-policy modes.
  • Plugin architecture and security documentation are updated.

Alternatives considered

Keep using a reverse-proxy sidecar

Works without host changes, but changes ports/topology, only sees wire traffic outside the trust boundary, complicates streaming, and cannot distinguish internal routing/dispatch facts.

Use the native serving plugin ABI

Useful for authoritative token-generation evidence, but it does not contain the original API envelope or final client representation. It is complementary, not interchangeable.

Compile OpenAiHookPolicy into custom binaries

Provides an in-process seam, but is not installable and requires product forks. The goal is a stable capability contract.

Send every request through a plugin-provided inference endpoint

Reverses ownership and makes an observer become the backend/proxy. It also prevents the plugin from observing native and remotely routed backends generically.

Follow-up

The first consumer should be a separately scoped Capsule Emit prototype linked to #1233. That prototype must describe its evidence as API-boundary, self-attested collector evidence until a future compute-boundary or hardware-bound receipt is linked.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions