Files
suaveplan/openspec/archive/2026-04-27-events-spec-tightening/proposal.md
2026-05-27 23:29:53 +01:00

3.9 KiB

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