Event Service Agent Kata

ADR-0013: CorrelationId Propagation Through Pure Domain Events

Status: Accepted

Problem

After refactoring to pure domain events (PL-23, PR#36), the correlationId propagation chain is broken. Timer adapter sets correlationId: Option.none() in message envelopes, preventing distributed tracing across async boundaries.

Core tension: Domain events should represent business facts (pure), but message envelopes require infrastructure metadata (correlationId, causationId) for observability.

Context

Current State (Post-PL-23)

Root Cause

Port signature changed from publishDueTimeReached(timer, firedAt) to publishDueTimeReached(event). Adapter no longer has access to timer.correlationId.

Requirements

  1. Distributed Tracing: All messages in request flow share same correlationId (ADR-0009)
  2. Causality Chain: Each message knows immediate cause via causationId
  3. Domain Purity: Events represent business facts, not infrastructure concerns
  4. Hexagonal Architecture: Ports don’t expose domain aggregates to adapters
  5. Multi-Tenancy: All operations scoped by tenantId

Architectural Constraints

Options

Option A: PublishContext Parameter (Explicit)

Pass metadata alongside domain event via separate context object.

```typescript ignore // Port signature interface PublishContext { readonly correlationId: Option.Option readonly causationId: Option.Option }

declare const publishDueTimeReached: (event: DueTimeReached, context: PublishContext) => Effect<void, PublishError>

// Workflow yield * eventBus.publishDueTimeReached(dueTimeReachedEvent, { correlationId: timer.correlationId, causationId: Option.none(), })

// Adapter const envelope = new MessageEnvelope({ payload: event, correlationId: context.correlationId, causationId: context.causationId, // … })


**Pros**:

- ✅ Preserves domain purity (metadata separate from event)
- ✅ Explicit dependencies (clear what's needed)
- ✅ Flexible (extend context without changing events)
- ✅ Type-safe (compiler enforces context parameter)

**Cons**:

- ⚠️ Extra parameter in every publish call
- ⚠️ Context must be extracted from aggregate in workflow
- ⚠️ Two sources of truth (event + context)

**Trade-offs**: Verbosity for explicitness; workflow must handle extraction.

---

### Option B: Enrich Domain Events with Metadata

Include infrastructure metadata in domain event schema.

```typescript ignore
// Domain event with metadata
export class DueTimeReached extends Schema.TaggedClass<DueTimeReached>()('DueTimeReached', {
	...ServiceCallEventBase.fields,
	reachedAt: Schema.DateTimeUtc,
	correlationId: Schema.optionalWith(CorrelationId, {
		as: 'Option',
		exact: true,
	}),
	causationId: Schema.optionalWith(EnvelopeId, {
		as: 'Option',
		exact: true,
	}),
}) {}

Pros:

Cons:

Trade-offs: Simplicity at cost of architectural purity.


Option C: Adapter Queries Aggregate

Adapter fetches aggregate to get metadata before wrapping.

```typescript ignore // Adapter queries persistence const publishDueTimeReached = (event: DueTimeReached) => Effect.gen(function* () { const persistence = yield* TimerPersistencePort const timer = yield* persistence.find(event.tenantId, event.serviceCallId)

	const envelope = new MessageEnvelope({
		payload: event,
		correlationId: Option.flatMap(timer, (t) => t.correlationId),
		// ...
	})
}) ```

Pros:

Cons:

Trade-offs: Performance and architectural violations.


Option D: Pass Aggregate to Adapter

Domain events carry aggregate reference.

```typescript ignore declare const publishDueTimeReached: (event: DueTimeReached, timer: TimerEntry) => Effect<void, PublishError>


**Pros**:

- ✅ No extra DB query
- ✅ Access to all aggregate context

**Cons**:

- ❌ Adapter depends on domain model (breaks hexagonal architecture)
- ❌ Port signature couples to domain aggregate
- ❌ Workflow must pass full aggregate (leaks domain into port)
- ❌ Doesn't work with events without source aggregates

**Trade-offs**: Very tight coupling between layers.

---

### Option E: Ambient Context (Effect Context) ⭐ **CHOSEN**

Use Effect's Context system to carry metadata implicitly.

```typescript ignore
// 1. Define Context Tag (platform package)
export class MessageMetadata extends Context.Tag('MessageMetadata')<
	MessageMetadata,
	{
		readonly correlationId: Option<CorrelationId>
		readonly causationId: Option<EnvelopeId>
	}
>() {}

// 2. Port signature requires context (R parameter)
declare const publishDueTimeReached: (event: DueTimeReached) => Effect<void, PublishError, MessageMetadata> // ← Requires context

// 3. Workflow provisions context (per-request data)
yield *
	eventBus.publishDueTimeReached(event).pipe(
		Effect.provideService(MessageMetadata, {
			correlationId: timer.correlationId, // Extract from aggregate
			causationId: Option.none(),
		}),
	)

// 4. Adapter consumes context (type-safe)
const metadata = yield * MessageMetadata // Type-driven requirement
const envelope = new MessageEnvelope({
	payload: event,
	correlationId: metadata.correlationId,
	causationId: metadata.causationId,
	// ...
})

Pros:

Cons:

Trade-offs: Less visible dependencies (implicit in R parameter) vs cleaner signatures; requires Effect knowledge.

Decision

Option E: Ambient Context (Effect Context) — ACCEPTED (2025-11-05)

Rationale

  1. Ecosystem Alignment: We’re fully committed to Effect-TS patterns (Layers, Schema, tagged errors). Ambient Context is the idiomatic Effect way to handle cross-cutting concerns.

  2. Type Safety: R parameter makes requirements explicit in type system. Compiler enforces context provisioning at call sites.

  3. Scalability: As we add more modules (Orchestration, Execution), this pattern scales naturally without signature changes.

  4. Domain Purity: Preserves clean separation between domain events (business facts) and infrastructure metadata.

  5. Testing Ergonomics: Effect.provideService makes test setup explicit and composable with existing Layer patterns.

  6. Future-Proof: Easy to add more metadata fields (tracing spans, user context) without breaking existing code.

Implementation Plan

See docs/plan/correlation-context-implementation.md for 14-task breakdown (PL-24, ~7h estimate).

Key Components:

Consequences

Positive

Negative

Neutral

Relationship to OpenTelemetry (ADR-0009)

Clarification: MessageMetadata Context is orthogonal to OpenTelemetry:

Bridge: Inject correlationId into OTEL span attributes for cross-referencing.

Migration

Validation

References

Appendix: Industry Patterns

Event Sourcing

Domain-Driven Design (DDD)

Microservices Observability

Message Brokers