# OpenSpec Change: events-spec-tightening - **Change ID**: events-spec-tightening - **Package**: `@suaveplan/events` - **Status**: PROPOSED - **Date**: 2026-04-27 ## Summary Tighten `openspec/specs/events/spec.md` to cover behaviors already shipped by `@suaveplan/events`: `emitAsync` timeout (`EVENTS.EMIT.TIMED_OUT`), abort (`EVENTS.EMIT.ABORTED`), envelope `traceId` plumbing, and the `onEmitError` observability hook. Resolve the `createBus` vs `new EventBus` API ambiguity (impl ships only the class) and add an explicit async listener-throw scenario. Pin every gap behind a `*.spec-conformance.test.ts` so future drift exits the 5-gate non-zero. ## Current state `@suaveplan/events` is fully implemented: - `EventBus` exposes `new EventBus({ clock?, idGenerator? })`; no `createBus` factory. - `emitAsync(type, payload, { signal?, timeoutMs?, traceId? })` — `withTimeout` wraps the dispatch chain and rejects with `EventsError("EVENTS.EMIT.TIMED_OUT")` when `timeoutMs` elapses. - Per-listener `signal.aborted` check throws `EventsError("EVENTS.EMIT.ABORTED")` with `cause === signal.reason`. - `wrap` accepts and emits `traceId` on `EventEnvelope`. - `withObservability(bus, adapter)` registers an interceptor that calls `adapter.onEmit?` / `onEmitComplete?` / `onEmitError?`. - `EVENTS_CODES` no longer carries `SCHEMA.REJECTED` (delegated to the future `events-schema` interceptor package). The spec, however, still says `createBus({ clock?, idGenerator? })` (line 118) and has zero scenarios for timeout, abort, `traceId`, `onEmitError`, or async listener-throw. A consumer reading the spec gets a misleading picture of the public API surface, and conformance can silently regress without a single CI signal. ## What changes 1. **Modify Requirement: Deterministic id and clock sources** — replace `createBus({ clock?, idGenerator? })` with `new EventBus({ clock?, idGenerator? })`. The class constructor is the canonical injection point. 2. **Modify Requirement: Structured errors** — add a scenario covering async listener throw (parallel to the existing sync-emit scenario), since `dispatch()` and `emit()` capture errors via independent paths. 3. **Modify Requirement: Passive observability adapter** — add a scenario covering `adapter.onEmitError(envelope, error)` firing when the dispatch chain rejects. 4. **ADD Requirement: emitAsync timeout** — codify `timeoutMs` semantics and `EVENTS.EMIT.TIMED_OUT`. 5. **ADD Requirement: emitAsync abort** — codify `signal` semantics and `EVENTS.EMIT.ABORTED` (including `cause === signal.reason`). 6. **ADD Requirement: Envelope traceId propagation** — codify that `emit`/`emitAsync` accept `traceId` and surface it on `EventEnvelope.traceId`. 7. **Add `src/conformance.spec-conformance.test.ts`** pinning the new scenarios so the 5-gate publish flow catches drift. 8. **Add `**Verified by:**` lines** to every requirement (matching the conformance convention used by `datetime`, `i18n`, and other recently-folded packages). ## Why now These are the five spec gaps surfaced during a focused enterprise-quality audit of `openspec/archive/2026-04-19-enterprise-events/`. The implementation has the behaviors today (and 100% coverage), but the spec is the contract — without scenarios, a future refactor can drop timeout handling and ship green. Tightening now is cheap (~30 minutes) and prevents both phantom enhancement requests and silent behavior regression in the substrate every domain-event-emitting package depends on. ## Impact - `@suaveplan/events` — no runtime changes. Adds one `*.spec-conformance.test.ts` (~6 scenarios). Coverage stays at 100%. - Downstream `events-metrics`, `events-otel`, `events-schema`, `events-transport` — none affected; their interceptor contract is unchanged. - Spec readers — get an accurate picture of the API including timeout / abort / traceId / onEmitError, which removes the most likely sources of misuse.