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

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-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

// 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.