# Lenso Web

General-purpose backend Web Interfaces and Plugins for Lenso.

This repository owns:

- `lenso.http.endpoint@1`, provided by backend Plugins that own HTTP behavior;
- `lenso.http.stream-endpoint@1`, provided when a response must remain incremental;
- `lenso-web-ingress-plugin`, which assembles immutable routes and owns HTTP transport;
- `lenso.http.client@1`, required by backend Plugins that call upstream HTTP APIs;
- `lenso-http-egress-plugin`, which provides policy-bounded outbound HTTP transport;
- credential-evidence extraction before a target-owned Auth decision; and
- protocol response mapping after the target Plugin completes.

It does not own portable Kernel semantics, authentication policy, business
authorization, Console/UI behavior, Web Shell, Browser Adapter, or a global
route registry.

This document makes no release, target, or production-maturity claim. Those
facts require their exact CI, registry, or deployment receipts.

## Current packages

- `lenso-capability-http-endpoint`
- `lenso-capability-http-stream-endpoint`
- `lenso-capability-http-client`
- `lenso-http-auth`
- `lenso-http-egress-plugin`
- `lenso-openapi-plugin`
- `lenso-web-ingress-plugin`
- `lenso-web-host`

`lenso-http-egress-plugin` and `lenso-openapi-plugin` use the ordinary source-first native
authoring path: struct Plugins declare configuration, typed Capability Ports,
provided Capabilities, and lifecycle hooks; linked factory registration and
Provider endpoints are generated. App Composition still decides whether an
Instance exists and exactly which bindings it receives.

`lenso-web-ingress-plugin` remains the deliberate compatibility exception. It is an
endpoint-free consumer of `many lenso.http.endpoint@1` and
`many lenso.http.stream-endpoint@1`; its public Factory also
accepts Host-owned middleware, listener observation, and lane-replica handles.
The current struct authoring macro cannot inject those Host-only values into a
consumer-only Plugin. Ingress routing nevertheless uses the generated typed
`ManyPort<EndpointClient>`; raw request handles are confined to generated
Capability projection code and test fixtures.

The Capability crates keep their generated Rust bindings. Bun consumers import
the matching TypeScript projections from `@lenso/bun`, which locks the source
revision and verifies each projection independently.

`WebIngressConfig` is immutable Plugin Instance configuration from the Resolved
App Plan and defaults to an ephemeral loopback listener. App Composition may
explicitly bind a fixed private or public address; deployment policy stays with
the host. Ingress serves HTTP/1.1 and cleartext HTTP/2 prior knowledge, accepts
one `Authorization` credential, replaces untrusted external request identifiers,
removes credential and hop-by-hop headers before dispatch, enforces body/head,
concurrency, and Endpoint deadline limits, and cooperatively cancels work on
client disconnect or App shutdown. Request bodies are collected frame-by-frame
under the configured limit and avoid a copy when Hyper supplies one data frame.

Customized configuration uses
`crates/lenso-web-ingress-plugin/config.schema.json`. All fields are optional:

```json
{
  "bind_address": "0.0.0.0:8080",
  "max_request_body_bytes": 1048576,
  "max_request_head_bytes": 16384,
  "max_concurrent_requests": 128,
  "max_connections": 1024,
  "request_head_timeout_millis": 10000,
  "request_body_timeout_millis": 30000,
  "connection_idle_timeout_millis": 60000,
  "shutdown_grace_timeout_millis": 30000,
  "request_timeout_millis": 30000,
  "session_cookie": {
    "name": "__Host-lenso-session",
    "csrf_cookie_name": "__Host-lenso-csrf",
    "csrf_header_name": "x-csrf-token"
  }
}
```

`session_cookie` is optional. Both configured Cookie names must use the
`__Host-` prefix. Ingress maps the exact session Cookie to protocol-neutral
credential scheme `session`. Unsafe Cookie-authenticated
methods require the configured CSRF Cookie and request Header to match; missing
or mismatched evidence is rejected before Endpoint dispatch. `Authorization`
continues to work when no session Cookie is selected. Supplying both credentials
is ambiguous and fails closed. Cookie and CSRF protocol headers are never
forwarded to the Endpoint Plugin.

An existing composition with empty Ingress configuration continues to receive
these defaults without a schema. Only customized Plan configuration needs the
schema.

`max_connections` is a hard listener-group budget. The idle deadline is
suspended while any parsed request is active and starts at the final request's
completion, so it limits keep-alive connections without imposing an absolute
connection lifetime. `request_body_timeout_millis` is a total body-read
deadline. `request_head_timeout_millis` configures Hyper's
HTTP/1 header deadline; an HTTP/2 connection with no active parsed stream is
still bounded by the connection idle deadline. HTTP/2 advertises
`max_concurrent_requests` as its per-connection stream ceiling; a peer that
opens excess simultaneous streams receives `REFUSED_STREAM` and may retry,
while the same semaphore remains the global execution ceiling across
connections. On App shutdown, admitted connections receive
`shutdown_grace_timeout_millis` to drain after Hyper begins graceful shutdown;
the default matches the Endpoint request deadline, after which the socket and
its live-connection permit are released.

Ingress admission and Kernel Endpoint admission are separate resolved-Plan
boundaries. A Host should explicitly apply the Ingress execution ceiling to
every `lenso.http.endpoint@1` binding it creates; the helper returns the
copyable queue/concurrency pair without moving policy into the Capability
Descriptor:

```rust,ignore
let (queue_capacity, max_concurrency) = ingress_config.endpoint_admission_limits();
let binding = HostBinding::new(
    PluginInstanceId::new("lenso.web-ingress", "default"),
    CAPABILITY_ID,
    "http-endpoints",
)
.with_admission(RequestAdmissionPlan::new(queue_capacity, max_concurrency));
```

A Host may deliberately choose a stricter Endpoint binding. Omitting the
override inherits the generic source-first provider default of one execution
slot and no queue, which is usually inappropriate for an HTTP provider.

Route providers are resolved through immutable `many` bindings. They describe
their method/path table during activation, before the App Ready Gate opens;
route collisions fail startup instead of changing behavior at runtime.

Streaming providers use distinct `describe_stream` and `handle_stream`
operations so one cohesive Plugin can provide both HTTP capabilities without
Rust method-name collisions. `handle_stream` first emits exactly one `head`
frame containing status and headers, then zero or more `chunk` frames containing
body bytes, followed by a terminal event. Ingress never buffers those chunks.
Malformed ordering or transport-owned response headers fail closed. Global
Ingress middleware can inspect and modify the response head; it does not buffer
or rewrite the stream body.

Endpoint providers can put route attributes directly on handlers. The outer
`#[endpoint]` attribute collects them at compile time and generates both the
Capability description and handler dispatch:

```rust,ignore
#[lenso::plugin]
#[derive(Clone, Debug)]
struct OrdersHttp {}

#[derive(serde::Deserialize)]
struct CreateOrder {
    total_cents: u64,
}

#[derive(serde::Serialize)]
struct CreatedOrder<'a> {
    id: &'a str,
}

#[endpoint]
impl OrdersHttp {
    #[post("orders.create", "/orders")]
    async fn create(
        &self,
        Json(_order): Json<CreateOrder>,
    ) -> Result<(StatusCode, Json<CreatedOrder<'static>>), Problem> {
        Ok((StatusCode::CREATED, Json(CreatedOrder { id: "order-42" })))
    }
}
```

`#[lenso::plugin]` declares the Plugin boundary. `#[endpoint]` publishes its
HTTP Endpoint Capability and generates route description and dispatch from the
same handler declarations. `Json<T>` and `(StatusCode, T)` implement
`IntoResponse`; `Problem` is the typed intentional-error response. Lower-level
response and header helpers remain available for unusual responses.

The supported method attributes are `get`, `post`, `put`, `patch`, `delete`,
`head`, `options`, and `query`. Each handler declares its stable route ID and path.
`http_endpoint!` remains available for existing providers and generated source
that prefers one explicit route table.

Attribute-authored handlers may use `Path<T>`, `QueryParams<T>`, `Json<T>`,
`Option<Json<T>>`, `Body`, `Headers`, and `RequestId` extractors. `Body` preserves
raw bytes, while `Headers::get` performs case-insensitive lookup. Middleware on
the `impl` applies to every route;
route-owned middleware follows it, before extraction:

```rust,ignore
#[endpoint]
#[middleware(trace_all)]
impl OrdersHttp {
    #[middleware(trace_request)]
    #[get("orders.read", "/orders/{order_id}")]
    async fn read(
        &self,
        context: InvocationContext,
        Path(path): Path<OrderPath>,
    ) -> Result<HandleResponse, EndpointHandleInvocationError> {
        self.read_authenticated(context, path.order_id).await
    }
}
```

The named async middleware method receives `InvocationContext` and
`HandleRequest`, then returns `MiddlewareOutcome::next` with an enriched
context/request or `MiddlewareOutcome::response` to short-circuit. Multiple
provider-wide and route middleware declarations run in order, with
provider-wide middleware first. Custom protocol-local extractors can implement
`FromRequest<Provider>`; extractors are asynchronous, can access the provider's
activation-time clients, and can enrich the context seen by later extractors
and the handler.

The macro rejects empty or malformed routes, duplicate route identifiers, and
duplicate exact method/path pairs during compilation. Web Ingress still owns
method/path matching, path-parameter extraction, semantic path-shape and
cross-provider collision detection, and transport limits. Endpoint handlers
own authentication orchestration, request decoding, business Capability calls,
and intentional HTTP responses.

A linked native Host that only needs Ingress plus the Endpoint Plugins in the
binary can skip a handwritten Plan:

```rust,ignore
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), lenso_web_host::WebHostError> {
    lenso_web_host::NativeWebHost::new()
        .plugin::<GreetingsHttp>()
        .bind("127.0.0.1:8080".parse().unwrap())
        .run()
        .await
}
```

```sh
cargo run -p lenso-web-greetings-app-example
```

`NativeWebHost` is a Web Host preset. Inventory admits linked Factories.
`.plugin::<T>()` selects a default Instance; `.plugin_with` / `.instance` pass
configuration; `.plugin_on_lane`, `.plugin_with_on_lane`, and
`.instance_on_lane` record explicit Execution Lane placement;
`.resolve_plan()` exposes the exact immutable Plan for an advanced Runner
integration; `.factory` installs a hand-written native Factory for the single-lane path;
`.with_ingress_config` supplies limits, deadlines, cookies, and WebSocket policy;
`.with_middleware` adds one Ingress middleware chain; `.with_diagnostics` adds
one Host-owned observer for Endpoint failures; `.with_tower_middleware` /
`.with_tower_layer` adapt a Tower pre-dispatch policy; `.with_adapter` adds
Bun/WASI/process Adapters. `.with_replicated_ingress` creates lane-local
Ingress customization; `.with_replicated_lane` creates the complete
lane-local native registry and Adapter catalog; `.with_replicated_ready_timeout`
sets the replicated Ready deadline; `.start_replicated` binds one shared listener
and starts one Kernel per declared lane; `.run_replicated` adds the private
`LocalSet` and Ctrl-C lifecycle. Ingress is a Host default.
Enabled HTTP, stream, and WebSocket Endpoint providers are bound to it.
The middleware chain and diagnostics observer are shared by native and
`start_event` Hosts. Middleware runs after transport normalization and unwinds
response hooks in reverse order. A diagnostics observer sees only trusted
request/route/provider identity and the internal Runtime Failure; it cannot
change routing or the HTTP response. A Tower policy receives a cloned normalized
request and returns `Continue` or `Respond`;
it does not replace Ingress's streaming/WebSocket service. Linking a crate
without `.plugin` does not start it. Unique Capability
requirements still derive. Call `start` from an existing `LocalSet` when a
test needs the bound address. For route-contract tests, `start_event` exposes
the same ingress behavior without opening a TCP socket:

```rust,ignore
let app = NativeWebHost::new().plugin::<GreetingsHttp>().start_event().await?;
let response = app.handle(Request::get("/missing").body(Bytes::new())?).await?;
assert_eq!(response.status(), 404);
app.shutdown().await?;
```

`handle_response` is the streaming/WebSocket-capable variant when a test or
embedded Host must preserve the response body kind instead of buffering it.
Both running Host handles expose `route_manifest()` after activation, so route
contract tests can assert the exact immutable dispatch surface.

The Host deliberately inherits Lenso's portable local execution lane: native
Plugin state and Endpoint futures may be `!Send`/`!Sync`, so `start` runs on a
Tokio `current_thread` runtime and `LocalSet`. `start_replicated` preserves that
boundary by letting `ReplicatedNativeApp` own one current-thread Kernel per
Plan-declared lane. It builds the Ingress factory and inventory-linked native
registry inside each lane, then uses `WebIngressListenerCoordinator` for one
shared listener. Lane-local custom middleware, hand-written Factories, and
non-native Adapters must be created through the `with_replicated_*` builders;
ordinary single-lane `Rc` values are rejected rather than shared unsafely.

For authenticated HTTP, Ingress selects one `Authorization` credential into
`HandleRequest::credential`; it does not decide identity or permission. The
`lenso-http-auth` integration crate handles Auth invocation, stable `401`/`403`
responses, and assertion attachment while the application owns meaningful
actor types:

```rust,ignore
struct UserActor { subject: String }

impl AuthenticatedHttpActor for UserActor {
    const KIND: &'static str = "user";

    fn from_assertion(assertion: &ActorAssertion) -> Self {
        Self { subject: assertion.subject().to_owned() }
    }
}

impl FromRequest<OrdersHttp> for UserActor {
    fn from_request<'a>(
        provider: &'a OrdersHttp,
        context: &'a mut InvocationContext,
        request: &'a HandleRequest,
    ) -> ExtractorFuture<'a, Self> {
        extract_authenticated_actor(provider, context, request)
    }
}
```

The Endpoint provider implements `AuthClientSource` to return its explicitly
bound activation-time `AuthClient`. A handler can then request `UserActor`
directly; an endpoint for a distinct credential actor kind can define
`AdminActor` in the same way. These `AuthenticatedHttpActor` types are edge
authentication projections, not
permission shortcuts: roles, tenant access, resource ownership, and every final
authorization decision remain in the target business Plugin, which verifies
the attached assertion. Release clients during deactivation rather than
resolving bindings per request.

`lenso-http-auth` is optional integration glue. Neither portable HTTP
Capability crate nor the Web Ingress library has a normal dependency on it;
removing the helper and its application integration leaves unauthenticated or
otherwise authenticated Endpoint providers usable without a Kernel branch.

The SDK is additive: existing providers that implement `EndpointProvider`
directly continue to work through the same immutable activation and dispatch
path.

## Optional OpenAPI 3.1 documents

OpenAPI is an opt-in Plugin, not an Ingress mode. Linking `lenso-openapi-plugin` does
not add a route or change an App. App Composition enables it by selecting one
`lenso.openapi` Instance, binding the Endpoint providers to document to that
Instance, and binding the Instance's own HTTP Endpoint to Web Ingress. Removing
that package selection, Instance, and those bindings removes the document
without changing the business Endpoints.

For a generated local Rust Web App, `lenso app add @lenso/openapi --root .`
creates a pinned shared source link and an explicit
`plugins/lenso.openapi/default.toml` selection. Its generated Host binds the
document Plugin's `many` Endpoint requirement to the `web` Slot; the document
Endpoint itself and Endpoint providers in other Slots are not document inputs.
After `lenso app build`, `lenso app check --root dist`, and
`lenso app start --from dist`, fetch the actual `/openapi.json` from that App
for `lenso-web-client generate openapi.json src/generated/lenso-api.ts`.
`lenso plugins disable lenso.openapi default --root dist` changes the built
App's Plugin Root; `lenso app start --from dist --root dist` uses that external
Root instead of the immutable build snapshot and no longer serves the document
route. This is a native local-Host path; it does not imply
Workers or Wasm Endpoint qualification.
The generated HTML home route remains an undocumented-response fallback in
this document; client generation does not provide a typed HTML response for it.

Endpoint authors may attach an OpenAPI 3.1 Operation Object while retaining the
stable route declaration as the source of `operationId`, method, and path:

```rust,ignore
#[endpoint]
impl OrdersHttp {
    #[get("orders.read", "/orders/{order_id}")]
    #[openapi({
        summary: "Read an order",
        responses: {
            "200": {
                description: "Order",
                content: {
                    "application/json": {
                        schema: {
                            "type": "object",
                            required: ["id"],
                            properties: { id: { "type": "string" } }
                        }
                    }
                }
            }
        }
    })]
    async fn read(&self) -> Result<HandleResponse, EndpointHandleInvocationError> {
        // ...
    }
}
```

The JSON-like syntax accepts ordinary identifier keys without quotes. Keys such
as `"type"`, `"application/json"`, and `"$ref"` stay quoted. String, number,
boolean, `null`, array, and nested object values are supported. The previous
JSON string form remains compatible.

`http_endpoint!` accepts the same syntax through
`openapi = openapi_operation!({ ... })`. The authoring macros validate the DSL
at compile time and reject a separately declared `operationId`. A route without
metadata remains valid and receives an `Undocumented response.` fallback only
when an OpenAPI document is assembled.

### Strict public API contracts

An Endpoint that is deliberately published for generated clients can opt into a
strict check without changing App Composition or creating a second request/
response DTO. Add `#[openapi_contract]` beside the existing `#[openapi(...)]`
metadata. The typed values already used by the handler remain the source of the
checked representation:

```rust,ignore
use lenso_capability_http_endpoint::{Json, JsonSchema, Path, endpoint};
use lenso_capability_http_endpoint::response::{Problem, StatusCode};

#[derive(serde::Deserialize, JsonSchema)]
struct CreateOrder { id: String }

#[derive(serde::Serialize, JsonSchema)]
struct CreatedOrder { id: String }

#[endpoint]
impl OrdersHttp {
    #[post("orders.create", "/orders/{account_id}")]
    #[openapi({ /* the complete authored OpenAPI Operation Object */ })]
    #[openapi_contract(
        success = 201,
        errors = [(422, "invalid_order")]
    )]
    async fn create(
        &self,
        Path(path): Path<OrderPath>,
        Json(input): Json<CreateOrder>,
    ) -> Result<(StatusCode, Json<CreatedOrder>), Problem> {
        # todo!()
    }
}
```

The Plugin package must make the `schemars` derive available (for example,
`schemars = "1.2"`), then derive `JsonSchema` for each public `Path`,
`QueryParams`, `Json` request body, and JSON response value. At OpenAPI Plugin
activation, the existing generated Endpoint description carries a temporary
type-derived fragment. The document assembler compares it with the author's
Operation Object and rejects drift in the route ID, method, path, path/query
parameters, JSON request body, JSON success response, and known RFC 9457
`Problem` responses. The fragment is removed before `/openapi.json` is served.
The comparison also rejects non-default path/query serialization settings, so
the emitted operation remains compatible with an ordinary OpenAPI-generated
client rather than only looking superficially similar.

`Json<T>` has an implicit `200` success response. For
`(StatusCode, Json<T>)`, declare the exact `success = 2xx` status. A `Problem`
handler error must enumerate its stable error codes through
`errors = [(4xx_or_5xx, "code")]`; JSON, path, and query extractor failures
are included automatically. Strict contracts intentionally support the typed
extractors `Path<T>`, `QueryParams<T>`, `Json<T>`, and `Option<Json<T>>`, plus
`RequestId` and `InvocationContext`. Raw bodies, headers, requests, and custom
extractors stay outside this first representation-level check rather than being
silently documented incorrectly.

`#[openapi_contract]` is opt-in. Existing Endpoint providers, private routes,
and routes with ordinary `#[openapi]` metadata continue to assemble exactly as
before; they are not forced to supply complete public-client contracts.

The selected `lenso.openapi` Instance uses immutable configuration validated by
`crates/lenso-openapi-plugin/config.schema.json`:

```json
{
  "title": "Orders API",
  "version": "1.0.0",
  "description": "Public order operations",
  "document_path": "/openapi.json",
  "servers": [{"url": "https://api.example.com"}],
  "components": {
    "securitySchemes": {
      "bearer": {"type": "http", "scheme": "bearer"}
    }
  }
}
```

The Plugin never infers a public server address from its listener and never
discovers Endpoint providers globally. Only providers explicitly bound to its
`many lenso.http.endpoint@1` requirement appear in the document. Swagger UI,
Redoc, pages, assets, and navigation remain with the application or Console UI
owner rather than this repository.

Ingress-owned failures have stable JSON codes. Missing routes return `404`,
method mismatches return `405` with `Allow`, malformed evidence returns `400`,
body/head limits return `413`/`431`, invalid or rejected Endpoint responses
return `502`, unavailable Endpoint execution returns `503`, and Endpoint
deadlines return `504`. Every Ingress-produced response carries a generated
`x-request-id` and `x-content-type-options: nosniff`.

Hosts may install `WebIngressDiagnostics` on the concrete Ingress factory to
observe an Endpoint Runtime Failure together with its trusted request ID, route
ID, and bound provider index before the failure is mapped to a generic `503` or
`504`. Diagnostics receive neither credentials nor request bodies and cannot
change routing or the HTTP response:

```rust,ignore
#[derive(Debug)]
struct DevelopmentDiagnostics;

impl WebIngressDiagnostics for DevelopmentDiagnostics {
    fn endpoint_runtime_failure(&self, event: WebIngressEndpointFailure<'_>) {
        tracing::warn!(
            request_id = event.request_id(),
            route_id = event.route_id(),
            provider_index = event.provider_index(),
            failure = ?event.failure(),
            "HTTP Endpoint failed",
        );
    }
}

let ingress = WebIngressFactory::new().with_diagnostics(DevelopmentDiagnostics);
```

Hosts can install transport-wide policy on one concrete Ingress factory.
Global Ingress middleware runs after request-head/body limits, request-ID
replacement, hop-by-hop filtering, and credential isolation. Request steps run
in declaration order before route matching; response steps run in reverse
order, including for short-circuited responses:

```rust,ignore
use futures::future::LocalBoxFuture;
use lenso_kernel::RuntimeFailure;
use lenso_web_ingress_plugin::{
    WebIngressFactory, WebIngressMiddleware, WebIngressMiddlewareOutcome,
    WebIngressRequest, WebIngressResponse,
};

#[derive(Debug)]
struct AccessLog;

impl WebIngressMiddleware for AccessLog {
    fn identity(&self) -> &'static str {
        // Include immutable settings so replicated lanes can compare policy.
        "access-log:json:v1"
    }

    fn before_request<'a>(
        &'a self,
        request: &'a mut WebIngressRequest,
    ) -> LocalBoxFuture<'a, Result<WebIngressMiddlewareOutcome, RuntimeFailure>> {
        tracing::info!(method = %request.method(), path = %request.uri().path());
        Box::pin(async { Ok(WebIngressMiddlewareOutcome::Continue) })
    }

    fn after_response<'a>(
        &'a self,
        _request: &'a WebIngressRequest,
        response: &'a mut WebIngressResponse,
    ) -> LocalBoxFuture<'a, Result<(), RuntimeFailure>> {
        tracing::info!(status = response.status().as_u16());
        Box::pin(async { Ok(()) })
    }
}

let ingress = WebIngressFactory::new().with_middleware(AccessLog);
```

This seam is suitable for transport-wide access logging, CORS, compression,
and coarse network admission policy. Middleware can mutate the normalized
method, URI, headers, and body, or return an intentional response. It cannot
override Ingress-owned response headers, reintroduce credential or hop-by-hop
headers to an Endpoint, bypass immutable transfer limits, or access Hyper's
connection body. Middleware failures map to `503`.

Authentication remains Endpoint orchestration: use `UserActor` or `AdminActor`
extractors backed by the Auth Plugin so the target Plugin can still make the
final authorization decision. Do not turn global Ingress middleware into a
business identity or permission registry.

Hosts that replicate one App across Runner lanes can bind once and create one
Ingress factory per lane through `WebIngressListenerCoordinator`. The
coordinator opens the Ready Gate only after every replica publishes the same
canonical route manifest and ordered middleware identity sequence, distributes
accepted sockets round-robin, keeps connection and request budgets global to
the listener group, and gives each lane a fair local request bound as a second
containment boundary. Acceptor failures are propagated to every lane instead
of silently stopping the listener. Every identity must include its immutable
configuration:

```rust,no_run
let coordinator = WebIngressListenerCoordinator::bind(config, lane_count).await?;
let factories = (0..lane_count)
    .map(|lane_index| {
        WebIngressFactory::replicated_at(&coordinator, lane_index).map(|factory| {
            factory.with_middleware(AccessLog)
        })
    })
    .collect::<Result<Vec<_>, _>>()?;
```

`replicated_at` is preferred when a Runner builds lane-local factories: the
explicit slot keeps a lane mapped to the same replica even when lane threads
start in a different order. `replicated` remains available for sequential
factory construction and allocates the next free slot.

The coordinator is only the transport fan-out. `NativeWebHost::start_replicated()`
now packages the native Runner composition around it: it resolves one Plan,
creates one deterministic replica slot per declared lane, builds each
lane-local `NativePluginRegistry` from inventory, and starts
`lenso_runner::ReplicatedNativeApp` with one `ExecutionAdapterCatalog` per
lane. Use the ordinary `NativeWebHost::start()` for a single Kernel; never call
it once per lane, because that would create independent listeners and bypass
the Runner's cross-lane Plan validation.

For a lane-local custom Adapter or hand-written Factory, extend the complete
catalog supplied to `with_replicated_lane` rather than constructing a second
Runner. The callback runs in the target lane, so it may safely create
`!Send`/`!Sync` Plugin state there:

```rust,no_run
let app = NativeWebHost::new()
    .plugin::<GreetingsHttp>()
    .plugin_on_lane::<OrdersHttp>("orders")
    .with_replicated_lane(|_lane, registry| {
        let mut catalog = ExecutionAdapterCatalog::single(registry);
        catalog = catalog
            .with_adapter(MyLaneLocalAdapter)
            .map_err(|error| error.to_string())?;
        Ok(catalog)
    })
    .start_replicated()
    .await?;
```

The default lane builder already supplies the native catalog, so this callback
is only needed for additional lane-local implementations. The single-lane
`.factory(...)` installer is intentionally rejected by `start_replicated`; add
the hand-written factory inside this callback once per lane instead.

`HttpEgressConfig` requires at least one exact `http` or `https` origin. The
binding and immutable origin list are the caller's outbound authority. Egress
rejects origin changes, user info, URL fragments, authority/hop-by-hop request
headers, and oversized transfer evidence. It does not follow redirects, use
system proxies, set a Referer, store cookies, or retry automatically. Total and
connect timeouts plus concurrency and head/body limits are owned by each Egress
Instance. Egress negotiates HTTP/2 over TLS by default while retaining HTTP/1.1
compatibility. An Instance may instead require HTTP/1.1 or HTTP/2 prior
knowledge, including cleartext h2c for trusted internal origins:

```json
{
  "allowed_origins": ["http://inventory.internal:8080"],
  "http_version": "http2_prior_knowledge"
}
```

HTTP/3 is not currently shipped: Reqwest 0.13 exposes its HTTP/3 client only
behind the upstream `reqwest_unstable` configuration, while Ingress still needs
a Host-owned TLS credential source and QUIC/UDP listener lifecycle.

## Verify

```sh
cargo fmt --all -- --check
cargo check --locked --workspace --all-targets
cargo test --locked --workspace
cargo clippy --locked --workspace --all-targets -- -D warnings
```

For a bounded portable HTTP Endpoint proof, run
`cargo test --locked -p lenso-web-ingress-plugin --test portable_http_component`.
The fixture compiles the same business handler into a native provider and a
real Wasm Component, then sends six loopback HTTP requests through Web Ingress
to each: method/path matching, non-UTF-8 request and response bytes, 404/405,
domain rejection, and runtime failure. This is local Native Host plus Wasmtime
evidence. It does not qualify workerd or deployed Workers, outbound HTTP,
streaming, WebSocket, authentication, or production resource ceilings.
The Component above runs through the Native Wasmtime Adapter. The workspace
also has a bounded `lenso app build --target workers` path for one verified
V4/V6 Workers Component variant. It packages a locked Plan, Jco bindings, the
pinned `@lenso/workers-runtime` HTTP event adapter, and a `workers-build.json`
build receipt. See `crates/lenso-engine-app/assets/workers-app-README.md` for
the request-only local-workerd limits. A build receipt or Node handler test is
not a real workerd run: the exact output needs its own HTTP corpus. Neither
this Native test nor a local workerd pass qualifies deployed Workers, streams,
WebSockets, outbound HTTP, cancellation, or production resource ceilings.

The dispatch-only release workflow publishes an explicitly authorized package
set from a landed `main` SHA through configured crates.io Trusted Publishers
and GitHub OIDC. Dependency-aware
publication releases the Capability crates before the dependent
`lenso-web-ingress-plugin` and `lenso-http-egress-plugin` Plugins, and workspace CI fully
verifies every package archive.

The independent-process benchmark compares transport-only Axum, the bridge,
and the complete Lenso path without sharing a Tokio runtime between client and
server. Filters keep short local runs reproducible:

```sh
LENSO_HTTP_BENCH_BODY_BYTES=0 \
LENSO_HTTP_BENCH_CONNECTIONS=8 \
cargo bench -p lenso-web-ingress-plugin --bench http_ingress_process
```

The Web execution profile adds the decision evidence needed before introducing
a different Web scheduler:

```sh
cargo bench -p lenso-web-ingress-plugin --bench web_execution_profile
```

It runs the same echo and delayed handlers through bare Axum, policy-equivalent
Axum transport, the bounded bridge, and the complete Lenso native path. The
report includes repeated throughput, unloaded p50/p99 latency, sampled server
CPU and RSS, and classified saturation outcomes. A two-lane Lenso fixture also
reports per-lane request counts for one, two, and eight HTTP/1.1 keep-alive
connections.

`LENSO_HTTP_BENCH_REQUESTS`, `LENSO_HTTP_BENCH_CONNECTIONS`, and
`LENSO_HTTP_BENCH_SERVER` can reduce the matrix. Process CPU/RSS sampling uses
the host `ps` command and prints `unavailable` when the host cannot provide it.
Treat results as host-specific evidence: a work-stealing Web profile is only
justified if repeated runs show a material user workload problem that cannot be
fixed at the Ingress routing or operation-granularity boundary. The benchmark
does not change the portable Kernel or imply live Instance migration.

### Event hosts

`lenso-web-ingress-plugin` also exposes `WebIngressEventFactory` for an event-owned
App using an event Driver. Disable the default `native` feature to compile the
shared HTTP path for `wasm32-unknown-unknown`. The event factory uses the same
Plugin descriptor, Plan-bound generated Endpoint clients, routing, credentials
and middleware as the native listener. See [the event ingress seam](web/design/event-http-ingress.md)
for the host integration contract, buffered-only scope and qualification limits.

`lenso-http-egress-plugin` exposes `HttpEgressEventFactory` for an explicitly
injected event transport, with a `workers` feature and the JS-owned abortable
Fetch bridge. It reuses the native exact-origin and transfer policy. See
[event HTTP Egress](web/design/event-http-egress.md) for supported configuration,
per-event injection, cancellation and target qualification requirements.
