# Capability Spec Delta: delivery-tracking (add-delivery-tracking) **Package**: `@suaveplan/delivery-tracking` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Channel-agnostic event ingest with lifecycle dedupe The package SHALL accept `CreateDeliveryEventInput` records, persist them via a pluggable `DeliveryEventStore`, and suppress duplicate `opened`/`delivered`/`bounced`/`unsubscribed`/`complained` events per `messageId`. Other event types MUST be allowed to repeat. #### Scenario: First lifecycle event is recorded; the second is dropped - **WHEN** two `opened` events arrive for the same `messageId` - **THEN** the first call returns the persisted event and the second call returns `null` #### Scenario: Non-lifecycle events are not deduped - **WHEN** two `clicked` events arrive for the same `messageId` - **THEN** both calls return persisted events **Verified by:** `src/core/tracker/tracker.test.ts::"dedupes lifecycle events per messageId"` AND `src/core/tracker/tracker.test.ts::"does not dedupe non-lifecycle events"` --- ### Requirement: Zod boundary validation on all inbound events The package SHALL parse every `CreateDeliveryEventInput` with `createDeliveryEventInputSchema` before any I/O. Invalid input MUST raise `DeliveryTrackingError(INVALID_INPUT)`. #### Scenario: Empty tenantId is rejected - **WHEN** `tracker.track({ ...input, tenantId: "" })` is called - **THEN** a `DeliveryTrackingError` whose `code` is `DELIVERY_TRACKING.INVALID_INPUT` is thrown **Verified by:** `src/core/tracker/tracker.test.ts::"throws DeliveryTrackingError(INVALID_INPUT) on schema violation"` --- ### Requirement: Typed EventBus emission The package SHALL emit `delivery:` topics on an `EventBus` for every persisted event, allowing subscribers to react without polling the store. #### Scenario: A persisted `sent` event triggers the `delivery:sent` topic - **WHEN** a `sent` event is tracked - **THEN** every listener subscribed to `delivery:sent` receives the persisted `DeliveryEvent` **Verified by:** `src/core/tracker/tracker.test.ts::"emits a typed bus event matching eventType"` --- ### Requirement: Injectable Clock and IdGenerator The package SHALL accept `clock` and `idGenerator` options from `@suaveplan/types` so tests can pin timestamps and IDs deterministically. #### Scenario: Pinned clock and id generator produce deterministic events - **WHEN** the tracker is constructed with `clock: () => 1_700_000_000_000` and `idGenerator: () => "id-N"` - **THEN** the resulting event has `timestamp.getTime() === 1_700_000_000_000` and `id === "id-N"` **Verified by:** `src/core/tracker/tracker.test.ts::"uses injected Clock + IdGenerator for deterministic ids"` --- ### Requirement: OpenTelemetry counter set The package SHALL emit `delivery_tracking.tracked.total`, `delivery_tracking.deduped.total`, `delivery_tracking.webhook_parsed.total`, `delivery_tracking.token_issued.total`, and `delivery_tracking.token_rejected.total` counters and MUST allow tests to assert them via a real `MeterProvider` (no mocks). #### Scenario: tracked.total reflects every persisted event - **WHEN** two events are tracked and one is deduped - **THEN** the `delivery_tracking.tracked.total` snapshot from the in-memory exporter is `2` #### Scenario: Instrument set is lazy + cached - **WHEN** `getDeliveryTrackingInstruments()` is called twice in a row - **THEN** both calls return the identical object reference **Verified by:** `src/core/tracker/tracker.test.ts::"emits tracked + deduped counters via real MeterProvider"` AND `src/otel/instruments.test.ts::"constructs the instrument set lazily and caches it"` --- ### Requirement: Backend-agnostic persistence contract The package SHALL define `DeliveryEventStore` covering record + read + stats operations, ship an `InMemoryDeliveryEventStore`, and ship a `SurrealDeliveryEventStore` that uses positional SurQL via `SurrealAdapter.query(sql, params)`. #### Scenario: InMemory store supports filter + paging - **WHEN** events of multiple types are recorded and `getByCampaign("c1", { eventType: "sent", channel: "sms", limit: 1, offset: 1 })` is called - **THEN** the returned slice respects all four filters #### Scenario: SurrealDB store threads filters into the SQL - **WHEN** `store.getByCampaign("c1", { eventType, channel, from, to, limit, offset })` is called - **THEN** the emitted SQL contains every filter predicate and `LIMIT`/`START` clauses **Verified by:** `src/stores/memory/memory-store.test.ts::"returns events for a campaign and supports filters + paging"` AND `src/stores/surreal/surreal-store.test.ts::"getByCampaign threads filters + paging into the SQL"` --- ### Requirement: Stable typed error codes on every IO path The package SHALL raise `DeliveryTrackingError` with stable codes (`INVALID_INPUT`, `INVALID_TOKEN`, `UNSUPPORTED_PROVIDER`, `STORE_FAILURE`, `QUERY_FAILURE`) and MUST never leak driver-level exceptions across the public surface. #### Scenario: SurrealAdapter write failure becomes STORE_FAILURE - **WHEN** `SurrealAdapter.query` throws inside `record(event)` - **THEN** the caller receives `DeliveryTrackingError` with `code === DELIVERY_TRACKING.STORE_FAILURE` #### Scenario: SurrealAdapter read failure becomes QUERY_FAILURE - **WHEN** `SurrealAdapter.query` throws inside `getByCampaign` - **THEN** the caller receives `DeliveryTrackingError` with `code === DELIVERY_TRACKING.QUERY_FAILURE` **Verified by:** `src/stores/surreal/surreal-store.test.ts::"wraps adapter errors in DeliveryTrackingError(STORE_FAILURE)"` AND `src/stores/surreal/surreal-store.test.ts::"wraps adapter errors thrown during select in QUERY_FAILURE"` --- ### Requirement: Time-series aggregation The package SHALL bucket campaign events by `hour`, `day`, or `week`. The result MUST be empty when the campaign has no events and MUST contain a `computeStats`-derived `stats` field per bucket otherwise. #### Scenario: Hourly bucketing distributes events across buckets - **WHEN** three `sent` events at `10:00`, `10:30`, and `11:15` are recorded for `c1` and `getTimeSeriesStats("c1", "hour")` is called - **THEN** the first bucket has `stats.sent === 2` and the second has `stats.sent === 1` **Verified by:** `src/core/aggregator/aggregator.test.ts::"buckets events by hour"` --- ### Requirement: Open-pixel and click-link tokens The package SHALL provide `TrackingPixelGenerator` and `TrackingLinkWrapper` which encode `(messageId, contactId, campaignId)` (and `originalUrl` for clicks) as URL-safe opaque tokens. Decoding a malformed token MUST raise `DeliveryTrackingError(INVALID_TOKEN)`. #### Scenario: Pixel token round-trips - **WHEN** `gen.generatePixelUrl(m, u, c)` is called and the resulting token is parsed via `gen.parsePixelToken(token)` - **THEN** the parsed payload equals `{ messageId: m, contactId: u, campaignId: c }` #### Scenario: Malformed token raises INVALID_TOKEN - **WHEN** `decodeToken("!!!not-base64!!!", "secret")` is called - **THEN** a `DeliveryTrackingError` with `code === DELIVERY_TRACKING.INVALID_TOKEN` is thrown **Verified by:** `src/core/tracking-pixel/tracking-pixel.test.ts::"parses its own token back into the payload"` AND `src/core/tracking-pixel/token-codec.test.ts::"throws DeliveryTrackingError(INVALID_TOKEN) on malformed token"` --- ### Requirement: Bulk HTML link rewriting The package SHALL provide `wrapAllLinks(html, messageId, contactId, campaignId?)` which rewrites every `` in the input HTML with a click-tracking URL, preserving the surrounding attributes. #### Scenario: Every anchor is rewritten - **WHEN** `wrapAllLinks(htmlWithTwoAnchors, m, u)` is called - **THEN** the output HTML contains two click-tracking URLs and the original `href`s are gone **Verified by:** `src/core/tracking-link/tracking-link.test.ts::"rewrites every in an HTML body"` --- ### Requirement: Pluggable provider webhook parsers The package SHALL parse SendGrid, Mailgun, AWS SES, and a `generic` shape out of the box and MUST allow custom providers to be registered at construction time or at runtime. Unknown providers MUST raise `DeliveryTrackingError(UNSUPPORTED_PROVIDER)`. #### Scenario: A custom provider parser can be registered at runtime - **WHEN** `parser.registerProvider("brevo", { parse: ... })` is called and `parser.parse("brevo", payload)` runs - **THEN** the custom parser's output is returned #### Scenario: An unknown provider is rejected - **WHEN** `parser.parse("mystery", { body: {} })` is called - **THEN** a `DeliveryTrackingError` with `code === DELIVERY_TRACKING.UNSUPPORTED_PROVIDER` is thrown **Verified by:** `src/core/webhook-parser/webhook-parser.test.ts::"supports registering a custom provider parser"` AND `src/core/webhook-parser/webhook-parser.test.ts::"raises UNSUPPORTED_PROVIDER for an unknown provider"`