# `RequestPipeline` — §6 11-step per-request driver _(no submodules)_ ## Purpose `RequestPipeline` implements the design-doc §6 per-request data flow exactly: tenant resolve, CSRF check, session resolve, RBAC gate, rate limit, idempotency, request validation, plugin handler, response validation, audit emission, and telemetry close. The order matters — every later step assumes earlier steps' invariants, and reordering would silently regress the security boundary. > The driver was split into sibling modules during the foundation Wave-2 prep (pipeline split per CLAUDE.md file-size budget; behaviour unchanged). The class in this file is the public entry point and constructor; the orchestration body lives in `request-pipeline-driver.ts`, the per-step gates in `request-pipeline-steps.ts`, the Zod validators in `request-pipeline-validation.ts`, the audit / telemetry / failure-response builders in `request-pipeline-emitters.ts`, and pure helpers (request-id, URL parse, body reader, ctx builder) in `request-pipeline-helpers.ts`. Every existing test passes against the split without modification — that is the success metric. Every step is wrapped by an `ErrorBoundary` so a throw at any point yields a fully-shaped `ErrorEnvelope` instead of an unhandled promise rejection. Tests + production both go through `pipeline.handle(request)` and inspect the returned `PipelineResult.response` / `.envelope`. ## Features - Tenant resolver chain is honoured first — non-public routes that fail to resolve a tenant return `HOST.TENANT_NOT_RESOLVED` (400) before any other work happens. - Session resolver runs against the configured `SessionResolverChain`; resolver throws map to `HOST.SESSION_REQUIRED` via the boundary. - CSRF runs only on non-GET/HEAD requests with `csrf: true`, and is skipped on `streaming: true` routes per design. - RBAC compares `principal.scopes` to `route.options.auth`. Missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total` counter. - Rate limit bucket keyed by principal id (when present) else tenant id; bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429). - Idempotency-Key consumed for `idempotent: true` routes; replay → `HOST.IDEMPOTENCY_REPLAY` (409). - Zod request validation: body (JSON only), params, query. Failure → `HOST.REQUEST_VALIDATION_FAILED` (400) carrying up to 10 Zod issues in `details`. - Plugin handler invoked with a `RequestContext` carrying capabilities, principal, tenant, span, request id, and the parsed body/params/query attached as discoverable fields. - Response validation strictness `enforce` (default) → 500 on mismatch; `warn` → success path + `api.validation.failures_total{where=response.warn}`. - Audit envelope emitted for `auditable: true` routes (admin-API sink shared by default). - Per-request OTel span tree: `api.request` root + `plugin.handler` child. Counter + histogram emitted on every response. ## Usage: Basic ```ts import { RequestPipeline } from "@suaveplan/api-host/request-pipeline"; import { createHostInstruments } from "@suaveplan/api-host/otel"; const pipeline = new RequestPipeline( { router, auth: sessionResolverChain, tenant: tenantResolverChain, capabilities, rateLimit, idempotency, csrf, audit, logger, span: spanFactory, }, createHostInstruments({ hostId: "h1", hostVersion: "0.1.0" }) ); const result = await pipeline.handle(request); return result.response; ``` ## Usage: Advanced `responseValidation: "warn"` is the recommended dev-mode setting — the handler-supplied response propagates even on a schema mismatch but the counter spike makes the violation visible: ```ts const pipeline = new RequestPipeline( { ...adapters, responseValidation: "warn" }, instruments ); ``` Custom `requestId` + `now` injection point lets snapshot tests stay deterministic: ```ts let clock = 0; const pipeline = new RequestPipeline( { ...adapters, now: () => (clock += 1), requestId: () => "01J5DETERMINISTIC", }, instruments ); ``` For streaming routes (`options.streaming === true`), request/response validation buffering is bypassed and the handler's `Response` body flows through unchanged — `X-Request-Id` is the only header the pipeline appends. ## API Reference ```ts class RequestPipeline { constructor( options: RequestPipelineOptions, instruments: HostInstruments ); handle(request: Request): Promise; } interface PipelineResult { response: Response; status: number; route: string | null; plugin: string | null; outcome: "success" | "failure"; envelope?: ErrorEnvelope; tenantId: TenantId | null; } const PIPELINE_STEPS: readonly PipelineStepName[]; ``` ## Implementation Notes The pipeline runs route-match *first* (technically step 2 in §6) so later step gates (`auth`, `csrf`, `rateLimit`, `idempotent`) can consult per-route policy. The §6 ordering of "intake → match" is preserved at the engine adapter; this driver assumes the intake has already happened (we receive a fully-formed `Request`). Step 1 "HTTP intake" is the router adapter's job and outside the pipeline's surface. Tenant resolution returns a stub `{ id: "public", slug: "public" }` context for public routes that failed to resolve — keeping downstream ctor invariants ($ctx.tenant$ is never null) at the cost of one sentinel that's easy to recognise and filter from telemetry. Non-public routes still return 400 on `null`. Per-step counters are derived directly from the boundary failure: the boundary emits `api.errors_total` for every captured throw, the pipeline adds the dedicated counters (`api.auth.failures_total`, `api.rbac.denials_total`, `api.tenant.unresolved_total`, `api.rate_limit.exceeded_total`, `api.idempotency.replays_total`, `api.validation.failures_total`) so a §12.3 spec slice is one counter lookup, not a regex over `code` labels. Response validation runs after the handler returns. JSON-shape mismatches → 500 (enforce) / counter (warn). Non-JSON responses skip validation by design — a `Response` of an arbitrary media type is the plugin's contract with the engine.