4.7 KiB
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-examplefull-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
// 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
enteredAND (completedORrejected) 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),
skippedMUST 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-pipelinechange. - Wire-format serialisation of events (JSON-line audit sink format). Audit sink stays as-is; PipelineEvents is in-process observability only.