Status: Accepted
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.
tenantId, aggregateId, correlationId, causationId, timestampMs, payloadtenantId, serviceCallId, reachedAt (no infrastructure metadata)correlationId from original ScheduleTimer commandpublishDueTimeReached(event: DueTimeReached): Effect<void, PublishError>correlationId: Option.none() (BROKEN!)Port signature changed from publishDueTimeReached(timer, firedAt) to publishDueTimeReached(event). Adapter no longer has access to timer.correlationId.
correlationId (ADR-0009)causationIdtenantIdPass metadata alongside domain event via separate context object.
```typescript ignore
// Port signature
interface PublishContext {
readonly correlationId: 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.
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.
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.
Option E: Ambient Context (Effect Context) — ACCEPTED (2025-11-05)
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.
Type Safety: R parameter makes requirements explicit in type system. Compiler enforces context provisioning at call sites.
Scalability: As we add more modules (Orchestration, Execution), this pattern scales naturally without signature changes.
Domain Purity: Preserves clean separation between domain events (business facts) and infrastructure metadata.
Testing Ergonomics: Effect.provideService makes test setup explicit and composable with existing Layer patterns.
Future-Proof: Easy to add more metadata fields (tracing spans, user context) without breaking existing code.
See docs/plan/correlation-context-implementation.md for 14-task breakdown (PL-24, ~7h estimate).
Key Components:
MessageMetadata Context.Tag in @event-service-agent/platformMessageMetadata requirementEffect.provideServiceEffect.provideServicedocs/patterns/message-metadata-context.mdClarification: MessageMetadata Context is orthogonal to OpenTelemetry:
Bridge: Inject correlationId into OTEL span attributes for cross-referencing.
makeEnvelope claims)docs/plan/correlation-context-implementation.md (PL-24)