Files
suaveplan/openspec/changes/api-host-pipeline-events/proposal.md
2026-05-27 23:29:53 +01:00

87 lines
4.7 KiB
Markdown

# Host Pipeline Per-Stage Events
**Package**: `@suaveplan/api-host` (extend) + `@suaveplan/api-types` (contract)
**Status**: PROPOSED
**Type**: capability-extension (coherence-fix)
## Why
The §6 11-step request pipeline in `api-host`'s `RequestPipeline` is an opaque sequential function that returns a `Response`. Internal stages — request-id assignment, auth resolution, tenant resolution, capability check, rate-limit consumption, idempotency check, CSRF check, routing, handler dispatch, response validation, audit emission — are private state with no public observable surface.
The foundation umbrella's gate 2.8 demands "exercises the full §6 11-step pipeline end-to-end" and the foundation-acceptance §3.1 demands "Reference plugin's full integration test (design doc §13.5) green in CI". Neither can be satisfied without per-stage observability:
- A closure smoke that asserts "the request reached stage 4 (capability check)" cannot do so without a public signal.
- An operator running the host in production has no way to see which stage rejected a malformed request.
- The audit sink today emits ONE record per request — fine for accounting, useless for debugging cross-stage failures.
OTel spans MAY be emitted today but the contract doesn't promise it. Tests can't depend on what isn't contractually emitted.
## Concrete consumer
- `@suaveplan/e2e-foundation-stack-full` (Stage-8 closure smoke) — asserts each of the 11 stages by reading the PipelineEvents bus.
- Any operator-built dashboard wanting per-stage latency / rejection-rate metrics.
- `@suaveplan/api-plugin-example` `full-flow.integration.test.ts` — can replace string-parsing of audit records with structured event assertions.
## End-to-end test
- `e2e/foundation-stack-full/tests/pipeline-stages.spec.ts` — drives one request through the host, subscribes to PipelineEvents, asserts the 11 events fired in order with the expected payload shape per stage.
## What changes
### `@suaveplan/api-types` — new `pipeline-events` subpath
```ts
// packages/api-stack/api/api-types/src/pipeline-events/pipeline-events.types.ts
export type PipelineStage =
| "request-id"
| "auth"
| "tenant"
| "capability"
| "rate-limit"
| "idempotency"
| "csrf"
| "route-match"
| "handler"
| "response-validate"
| "audit";
export interface PipelineStageEvent {
readonly requestId: string;
readonly stage: PipelineStage;
readonly outcome: "entered" | "completed" | "rejected" | "skipped";
readonly durationMs?: number;
readonly cause?: { readonly code: string; readonly message: string };
readonly at: number;
}
export interface PipelineEventsBus {
emit(event: PipelineStageEvent): void;
subscribe(listener: (event: PipelineStageEvent) => void): () => void;
}
```
Zero-runtime. Lives in `api-types/pipeline-events`.
### `@suaveplan/api-host` — emit events at every stage boundary
`request-pipeline.ts` gains a `pipelineEvents: PipelineEventsBus` adapter slot on `HostAdapters`. Every stage emits `entered` on entry, `completed`/`rejected`/`skipped` on exit. Default is `NoopPipelineEventsBus` (zero-cost when not observed); operators wire `InMemoryPipelineEventsBus` (test fixture) or a custom OTel-bridging implementation in production.
The `api-host/testing/createTestHost` factory wires the in-memory bus by default so smokes can assert without setup.
### Conformance harness
`@suaveplan/api-host/testing/pipeline-events-conformance` — `runPipelineEventsConformance(host)`:
- 11 invariants, one per stage: a request that reaches stage N MUST emit `entered` AND (`completed` OR `rejected`) for stage N.
- Order invariant: stages emit in §6-spec order; no stage emits before its predecessor's `completed`.
- Skip invariant: when a stage is conditionally skipped (e.g. unauthenticated request bypasses idempotency-by-principal), `skipped` MUST fire (not silently omitted).
- Rejection invariant: when a stage rejects, no subsequent stage emits `entered`.
## Why this is not a "nice-to-have"
Stage 4's foundation-fix-gap-1..5 surfaced because Stage 4 stage boundaries weren't observable. Same root failure mode the umbrella's gate 2.8 will hit at Stage 8 — already did when the closure-smoke agent reported the spec was unwriteable without per-stage observability. §19 (the new rule) explicitly forbids shipping a primitive without its host-integration; this proposal IS the host-integration for the §6 pipeline.
## Out of scope
- Per-stage OTel span emission. PipelineEvents is the structured-event contract; OTel spans can be a bridge in a follow-up `api-host-otel-pipeline` change.
- Wire-format serialisation of events (JSON-line audit sink format). Audit sink stays as-is; PipelineEvents is in-process observability only.