Event Service Agent Kata

ADR-0010: Identity Generation Strategy

Status: Accepted

Problem

Who generates entity identifiers (TenantId, ServiceCallId, CorrelationId, EnvelopeId) and when? Options include:

  1. Client-generated — External systems provide IDs
  2. Application-generated — Application code creates IDs before persistence
  3. Database-generated — Database assigns IDs on INSERT (AUTOINCREMENT, SERIAL, triggers)
  4. Broker-generated — Message broker assigns IDs during publish

Each approach has implications for idempotency, event correlation, outbox pattern, and API contracts.

Context

System Requirements

Identity Types

Database-Generated IDs: Why They Don’t Work

```typescript ignore // ❌ PROBLEM: Can’t use ID until AFTER insert const result = await db.insert(serviceCall).returning([‘id’]) const serviceCallId = result.id // Only available NOW!

// ❌ Can’t include in idempotency check BEFORE insert // ❌ Can’t publish events referencing serviceCallId within same transaction // ❌ Can’t return serviceCallId to client immediately (need DB roundtrip) // ❌ Can’t log with serviceCallId before persistence


**Fundamental issue:** Outbox pattern requires publishing events **within the same transaction** as aggregate insert. Events must reference the aggregate ID. Database-generated IDs aren't available until **after** the insert completes, creating a chicken-and-egg problem.

### Application-Generated IDs: Why They Work

```typescript ignore
// ✅ SOLUTION: Generate ID BEFORE insert
const serviceCallId = clientProvidedId ?? crypto.randomUUID() // UUID v7 preferred

// ✅ Can check idempotency BEFORE insert
const existing = await db.findBy({ tenantId, serviceCallId })
if (existing) return existing

// ✅ Can include ID in domain events
const event = ServiceCallSubmitted({ serviceCallId /* ... */ })

// ✅ Can insert aggregate + publish events in same transaction
await db.transaction(async (tx) => {
	await tx.insert(serviceCall)
	await tx.outbox.append(event) // References serviceCallId!
})

UUID v4 vs UUID v7

Decision: Use UUID v7 for time-series data (ServiceCallId, EnvelopeId).

Decision

Identity Generation Responsibility Matrix

Identity Type Generated By When Format Notes
TenantId External (Tenant Provisioning) Before API call UUID v7 Provided in request path/header
ServiceCallId Application (API or Client) Before DB insert UUID v7 Accept from client (idempotency) or generate
CorrelationId Application (API Module) Request entry UUID v7 Generated per request for tracing
EnvelopeId Application (Publisher) Before publish UUID v7 Generated when wrapping message

No IdGeneratorPort

Decision: Do NOT create an IdGeneratorPort abstraction.

Rationale (YAGNI):

Use crypto.randomUUID() directly in application code. If UUID v7 library needed, import directly (not a port).

API Pattern: Accept Optional Client IDs

```typescript ignore // API accepts optional serviceCallId for idempotency interface SubmitServiceCallRequest { serviceCallId?: ServiceCallId // Client-provided (idempotent retry) tenantId: TenantId name: string // … }

// Handler logic const serviceCallId = req.body.serviceCallId ?? Schema.make(ServiceCallId)(crypto.randomUUID())

// Now have ID BEFORE any DB operation


This enables:

- **Idempotent retries** — Client can retry with same ID
- **Client-side ID generation** — For advanced clients who need immediate reference
- **Server-side generation** — Simple clients omit ID, server generates

## Consequences

### Positive

✅ **Idempotency works naturally** — Have `(tenantId, serviceCallId)` before DB insert\
✅ **Outbox pattern works** — Events can reference ServiceCallId within same transaction\
✅ **Immediate availability** — Can log, trace, return ID without DB roundtrip\
✅ **Event correlation** — All messages reference ServiceCallId from creation\
✅ **Client flexibility** — Clients can provide ID for idempotency or let server generate\
✅ **Better DB performance** — UUID v7 insertion order improves index locality\
✅ **Simpler architecture** — No IdGeneratorPort, no extra abstraction

### Negative

⚠️ **Validation required** — Must validate client-provided IDs at API boundary (see Migration below)\
⚠️ **Application responsibility** — Application must generate IDs (can't delegate to DB)\
⚠️ **Collision risk** — Theoretical (but UUID v7 has 74 bits randomness = negligible risk)

### Migration: Brand.nominal → Schema.brand

**Status**: ✅ **Migration Complete** (as of PL-14)

**Previous problem:** `shared.ts` used `Brand.nominal` (type-only, no runtime validation):

```typescript ignore
// ❌ OLD: No validation!
export type TenantId = string & Brand.Brand<'TenantId'>
export const TenantId = Brand.nominal<TenantId>()

// API handler (UNSAFE):
const tenantId = req.body.tenantId as TenantId // Accepts ANYTHING!

Current implementation: Migrated to Schema.brand with UUID v7 validation:

```typescript ignore // ✅ CURRENT: Runtime validation with UUID7 schema import * as DateTime from ‘effect/DateTime’ import * as Effect from ‘effect/Effect’ import * as Schema from ‘effect/Schema’ import { TenantIdBrand } from ‘./tenant-id.schema’ import { UUID7 } from ‘./uuid7.schema’

export class TenantId extends UUID7.pipe(Schema.brand(TenantIdBrand)) { static readonly makeUUID7 = (time?: DateTime.Utc) => Effect.gen(function* () { const uuid7 = yield* Uuid7Service.randomUUIDv7(time) return TenantId.make(uuid7) // Validated UUID7 → branded TenantId })

static readonly decode = (value: string) => Schema.decode(TenantId)(value) // Validates UUID7 format! }

Effect.gen(function* () { // API handler (SAFE): const tenantId = yield* TenantId.decode(req.body.tenantId) // ✅ Validates UUID7 format! Rejects invalid input with ParseError }) ```

Completed in: packages/schemas/src/shared/
Branded types migrated:

Pattern: All extend UUID7.pipe(Schema.brand(UniqueSymbol)) for double-branding

References