Zum Inhalt springen

15 — engagement-marketing (Design)

Status: Draft · Date: 2026-05-23 · Owner: engagement + legal Depends on: 02 (magic-link session + DOI signed-token pattern), 04 (bid/outbid signals, AuctionDO server-only audience reads), 05 (saga settlement writes), 06 (WS hub), 08 (dashboard settings host), 09 (Web Push pipeline — reused verbatim), 12 (consent/legal, data-rights export/erase), 13 (quality policy), 14 (design system). Binding refs: master D5/D7/D8 (sealed-bid), D14 (Resend), D28 (top-3 per-bid WS), D43 (no cookie-consent vendor), and the new D50–D57 introduced by this spec. Extends engagement epic #9; implementation tracked by epic #722 (34 issues, #724–#757).


Purpose. A multi-level marketing engine that contacts and converts advertisers to bid. Today nowyourlink fires exactly one transactional notification per trigger (outbid, closing-soon, won/lost/this_close) through a production-grade but single-channel Web Push pipeline (spec 09). This sub-system turns that into a layered engine — Web Push primary, email fallback + campaigns — driven by four trigger families:

  • auctions ending soon (closing-soon — exists, kept),
  • being outbid / dropping in rank (outbid — exists, hardened; plus a new positive climbed-to-#1),
  • interesting calendar days to bid (holidays / national days — new),
  • win-back of lapsed bidders + a new-auction-open nudge to past bidders (new).

…while never crossing the absolute sealed-bid line (D5/D7/D8) or the GDPR/DSGVO + German UWG §7 line.

In scope (15 owns): the EngagementSignal envelope; the canonical engagement_state projection + cross-day audience resolvers; the marketing consent & preference stack (double-opt-in, RFC 8058 unsubscribe, preference center, classifier); the email channel (suppression/bounce wiring, outbound bus, marketing render functions); cross-channel frequency capping; the campaign engine (definition store, exactly-once send ledger, holiday model, scheduler, admin console); and first-party measurement (send-ledger funnel).

Reused, not rebuilt (spec 09 / existing infra): POST /push/send (VAPID delivery, KV dedupe, 8/24h cap, multi-device fan-out, EN/DE i18n, deep-links); the OUTBID_QUEUE + closing-soon cron triggers (kept on their direct seams); @nowyourlink/email + the Resend adapter; the cron/queue substrate; the feature-flag console; the provisioned-but-dead notify-queue.

Out of scope / deferred (D57, YAGNI): the generic @nowyourlink/orchestrator package, per-country quiet-hours, rolling-window KV frequency counters, open/click attribution + the Resend attribution webhook, A/B holdout-lift analytics, the bid-hot-path conversion hook, and an in-app notification inbox. Each is listed in §11 so future agents do not re-scope them into the MVP of this sub-system.

Relationship to spec 09. Spec 09 (engagement-features) owns the push transport + the cinematic/viral surfaces and remains feature-complete. This spec owns the marketing engine that rides on top: audiences, consent, email, campaigns, measurement. Where this spec extends a 09 surface (new PUSH_KINDS), it does so additively.


Each layer is independently testable, attaches to a verified existing seam, and is sequenced so no marketing send can precede the consent infrastructure and the data/suppression/frequency foundation precedes every trigger. Canonical names below are binding; synonyms are forbidden (D54).

2.0 Cross-cutting invariants (apply to every layer)

Section titled “2.0 Cross-cutting invariants (apply to every layer)”
  • Sealed-bid (D5/D7/D8) is structural, not review-only. No payload, copy, ledger column, or admin query may carry a bid amount, gap-to-leader, who-outbid/who-you-passed, competitor identity, or any rank beyond 3. Enforced by: .strict() zod at the queue boundary; a per-template var allow-list ({date},{day},{holiday_label}, and {rank} validated to 1|2|3 = the recipient’s OWN coarse rank only — the existing push.this_close already renders #{rank}; reject any var named amount/gap/margin/bid/competitor/position) + a CI lint over push.{en,de}.json and email templates; and a schema test that the measurement reader’s SQL references none of bids/invoices/saga_runs/daily_spotlight//ranked-bidders//settlement-snapshot.
  • Consent is one binding source of truth. TOPIC_CLASSIFICATION (in packages/legal) is the single map; Signal, Orchestration, Campaign, and Data-Model import it, never re-classify locally. Email marketing topics are DOI-gated; transactional topics bypass.
  • Best-effort, non-blocking everywhere. Every side effect is .catch()-swallowed or deadline-bounded; it never fails the bid hot path or the saga/payment critical path.
  • Every dedupe / frequency key embeds user_id (the #436 global-key bug).
  • Banned + DSGVO-erased + bounced users are excluded in the SQL — baked into partial-index WHERE clauses and re-asserted defensively in resolvers.

A new packages/engagement-signals exports an EngagementSignal zod discriminated union, .strict() per member, with a common header { type, user_id (min 1), auction_id (YYYY-MM-DD), emitted_at, dedupe_key }. It is the single sealed-bid enforcement boundary for orchestrated signals.

Producers and their disposition (verified against the repo):

  1. bid.outbid — corrected & hardened (master D49). The LIVE producer (auction-do.ts:2130-2154) fires whenever the previous rank-1 holder loses #1 — including #1→#2 and #1→#3 — and the consumer dedupes per displacing bid_id (outbid-consumer.ts:70, out:{auction}:{displaced}:{bidId}). That is a repeatable, probe-able amount-inference oracle already live in prod. Fix (its own 2-reviewer PR before any new rank signal): collapse the dedupe to one push per (auction, user) (out:{auction}:{user}) so repeated probing yields at most one response. Product decision (confirmed this round): keep firing on lost-#1 even if the bidder is still in the top-3 — do not narrow to “left the top-3 entirely”. Stays on its direct seam; not migrated onto the queue.
  2. auction.closing_soon — kept on its direct seam (no rewrite). The working cron lane (closing-soon.ts:84) keeps calling /push/send per active bidder with its per-user dedupe key. Structurally, closing_soon may be emitted only by the active-bidder cron lane, and the campaign audience grammar rejects topic=closing_soon so it can never become a participation oracle for non-bidders.
  3. rank.climbed_to_top — new, #1-boundary only, at-most-once (D50). A new DO producer co-located in the same diff.changed block reuses the union id→user map already built (no new read). Emits for the just-placed bidder who took #1, excluding self-raise, and at most once per (auction, user) via a DO-side per-user “already-notified-top” marker — never re-firing on lead re-takes (kills the oscillation oracle; its safety does NOT inherit from settlement-final this_close). Carries no amount/margin/who-passed.
  4. auction.opened — new, win-back. A dedicated daily cron lane emits for TODAY only (not the 366-day precreate walk). Audience is the consenting-past-bidder reader (§2.2) — never the settlement-only user_bids mirror. Marketing-gated; flag-off until consent infra lands.
  5. calendar.day_of_interest — new, holiday/national-day. Daily cron lane behind engagement-calendar-enabled + marketing consent, reading the seeded holiday_calendar for the user’s country/locale. Carries day_slug + name_key (i18n key, never literal copy). No third-party holiday API (preserves D43).
  6. won / lost / this_close — unchanged, direct, bypass engagement orchestration. Stay on the saga bestEffortPush seam; terminal, must-fire, exactly-once inside step.do; explicitly NOT routed through engagement orchestration/frequency-capping (a “you won” must never be frequency-capped). Rate-cap exemption is won/lost only (exempt_rate_limit:true); this_close stays subject to the shared 8/24h cap — do not change it.

The EngagementSignal envelope carries only genuinely new cross-channel signals (auction.opened, calendar.day_of_interest, and optionally rank.climbed_to_top); push-only transactional-adjacent signals do not need it.

Two audience classes, two sources:

  • Live, day-scoped, server-only (today’s active bidders via /active-bidders; the specific outbid bidder, event-carried) — reused as-is, never materialised into D1 (materialising recreates the forbidden per-day enumeration and goes stale at the DO boundary).
  • Cross-day, durable, lifecycle — served by the single canonical projection table engagement_state (§4). Resolvers: lapsedBidders, pastBiddersForNewAuction (lapsed minus today’s-active set), calendarDayAudience (projection ∩ country), cohortByStage. Every resolver enforces banned=0 AND erased=0 in SQL (baked into partial indexes) and is EXPLAIN-verified index-served.

Anti-enumeration rule (sealed-bid). No index makes WHERE last_bid_date = '<single day>' an efficient equality probe returning a bidder list; resolvers use range predicates only (< ? / >= ?) over the whole population. A query-plans.test.ts forbid-rule asserts no SCAN engagement_state / TEMP B-TREE, and review forbids single-day equality on last_bid_date. last_coarse_rank is dropped from the projection (no consumer needs it; persisting+exporting coarse rank is a new leak surface). Positive-rank eligibility is computed live in the DO at emit time, never persisted.

Audience is consent-agnostic: it produces a candidate user_id set; the consent layer gates non-transactional email after.

Section titled “2.3 Consent & Preference (the legal gatekeeper, packages/legal)”

TOPIC_CLASSIFICATION (binding, single source of truth):

topicclassemail gate
outbid, closing_soon (active bidders)transactionalbypass; default-ON, suppressible
won/lost/this_close/account/security/3ds/settlement_failure/data_rightstransactional must-deliverbypass; NOT suppressible
new_auction_open, outbid_recovery (email), winback, holiday/calendarMARKETINGDOI required

notification_preferences.product_email defaults 0 (off until opt-in). The classifier is keyed on (topic); a producer broadening a transactional topic’s audience must change the topic.

resolveContactGate(input) → {allow}|{allow:false, reason} — pure, synchronous, fail-closed, the single choke point both push and marketing-email sends pass. Order: banned → transactional must-deliver (allow REGARDLESS of bounce/verify — 3ds/data_rights/settlement_failure/security/account) → email_unverified → email_bounced → transactional suppressible (default-ON, honour an explicit topic OFF) → marketing (consent confirmed AND topic toggle). The bounce/verify checks gate ONLY suppressible-transactional + marketing mail — must-deliver mail is never blocked by email_bounced_at or an unverified address (§6 guardrail 8). Block reasons are an internal enum, never surfaced in copy (sealed-bid). The gate is consent only — frequency/quiet-hours/rate-cap belong to Orchestration.

Double-opt-in capture reuses the packages/data-rights signed-token pattern, but the kind enum is widened from export|delete to add marketing_consent_confirm and unsub, and every endpoint asserts the exact kind after validateToken (closing the confused-deputy that could let an unsub token confirm consent or vice-versa — fabricating/destroying Art.7 proof) (D53). Flow: request → pending row (capturing requested_at, hashed/truncated IP per Art.5(1)(c), consent_text_version) → DOI email (transactional seam apps/api/emails.ts, NOT the engagement queue, confirm-CTA-only, no marketing offer, no List-Unsubscribe — tested) → confirm flips to confirmed (idempotent). Withdrawal is a new revoked state, never a delete (proof until erasure). Latest-state determination: the authoritative current state is derived from the append-only marketing_consent_audit ordered by (user_id, created_at, id) with revoked winning equal-timestamp ties, and is materialised into the single marketing_consent current row under a revoke-sticky write guard (a confirm never overwrites a same-or-later revoke). The gate reads that materialised marketing_consent.state.

RFC 8058 one-click unsubscribe. POST /u/:token is explicitly exempt from all session/CSRF/auth middleware (the token is the only auth), idempotent, and atomically flips both consent state and preference rows in one D1 batch; the unsub-token secret lifecycle is pinned so year-old emails still unsubscribe. List-Unsubscribe + List-Unsubscribe-Post: List-Unsubscribe=One-Click (byte-exact) on marketing email only. A preference center extends the existing /app/settings Notifications card (per-topic × per-channel toggles, marketing master switch with confirmed_at, read-only must-deliver topics).

Web Push — reuse /push/send verbatim. Add three non-exempt PUSH_KINDS (climbed_to_top, new_auction_open, calendar_nudge) + EN/DE body_key rows + deepLinkFor branches. Copy is sealed-bid-safe; climbed_to_top may state the user’s OWN rank #1 only. All engagement kinds send exempt_rate_limit:false (only the saga sets true, for won+lost; this_close is NOT exempt — do not change it). Dedupe keys embed user_id.

Email B0 — suppression + bounce (HARD PREREQUISITE). Provision SUPPRESSION_KV (all envs); pass createKvSuppressionList into createEmailService. Add POST /webhooks/resend on apps/webhook (Svix raw-body signature verify, dedupe, enqueue to q-email-bounce). Single-owner the bounce pipeline: the q-email-bounce consumer drives suppression KV + markEmailBounced (resolve recipient→user_id by lowercased email; suppress unconditionally, markEmailBounced only when a row exists; only hard-bounce/complaint sets the flag, never a soft bounce). Before this goes live, audit every reader of email_bounced_at / resolveDeliverableUser — must-deliver transactional mail (3DS, data-rights ack, settlement-failure) must not be suppressed by a bounce flag set by a marketing-driven event; a covered test asserts a soft bounce does not block a 3DS email.

Email B1 — outbound bus. Reuse the already-provisioned dead notify-queue (add the missing dispatch branch). Consumer handleNotifyEmailBatch(messages, deps): KV dedupe (email:dedupe:<key>), fail-closed resolveDeliverableUser + marketing-consent re-check, render, EmailService.send, SendResult→ack/retry, per-message try/catch. B2 clones the emails.ts render-fn pattern (workerd-safe manual date formatting). B3 injects the List-Unsubscribe headers on marketing email only.

selectChannel({hasPushSubscription, marketingConsent, lastChannelOutcome}) → 'push'|'email'|'none' — push primary if push:idx:<user> non-empty, else email if deliverable+consent, else none. Consumed by Orchestration; the channel layer re-checks fail-closed at send time.

2.5 Orchestration & Frequency (right-sized; deferred where speculative — D57)

Section titled “2.5 Orchestration & Frequency (right-sized; deferred where speculative — D57)”

For Phase 2 (push-only engagement) there is no orchestrator package and no engage-queue: reuse the closing-soon template directly with the existing /push/send 8/24h cap as the frequency backstop. The notify-queue consumer that carries rank.climbed_to_top (and any push-only auction.opened/calendar emission) in Phase 2 dispatches straight to /push/send — it is never a no-op; only the cross-channel frequency cap, channel selection, and the email leg are layered onto that same consumer in Phase 4. The generic decision core, rolling-window KV counters, per-country quiet-hours, and an engage-queue are introduced only when marketing email ships (Phase 4), and then minimally.

When it lands, the cross-channel cap is a single last_contacted_at min-gap read on engagement_state (D1, durable). Critical fix: because /push/send writes its dedupe key BEFORE the rate-cap check (push-routes.ts:399 then :403), a rate-capped engagement push is lost for 24h, not deferred. So Orchestration owns its cap before /push/send — a would-be-capped engagement push is never sent (assert orchestrate() returning frequency_capped/push_rate_capped makes zero /push/send calls); Measurement treats status='rate_limited' as terminal-for-the-day. Engagement is doubly subordinate: its own marketing cap, then the shared 8/24h backstop, so it can never starve transactional outbid/closing. Quiet-hours DEFER (re-enqueue with delaySeconds) for campaign-class sends rather than DROP, using a static country→UTC-offset table (no workerd Intl), pinned in a unit test; DROP only for time-critical closing_soon.

2.6 Campaign & Scheduling (operable surface)

Section titled “2.6 Campaign & Scheduling (operable surface)”

Tables prefixed mkt_ (avoids collision with existing campaign_reports): mkt_campaigns (definition: topic, audience_selector JSON, channel, template_key, schedule, status, feature_flag_key, holdout_pct, approved_by_sub) and mkt_campaign_sends (ledger, PK (campaign_id, user_id) = the exactly-once anchor via INSERT … ON CONFLICT DO NOTHING).

Audience selector is a zod-validated AND-of-predicates DSL compiling to {sql, binds, indexHint}, every compilation EXPLAIN-clean and always ANDing banned_at IS NULL AND email_bounced_at IS NULL. It reads users attributes + the canonical engagement_state projection — never user_bid_summary/user_settlement_status/bids. The grammar rejects topic=closing_soon.

Scheduler = hourly mkt-dispatch cron (deconflicted slot, producer only) + the reused notify-queue for fan-out. calendar_day campaigns filter by a static country→local-morning-UTC-hour map (reminder-grade, DST ±1h documented). Holdout is deterministic hash(campaign_id+user_id) % 100 < holdout_pct → control suppressed + ledgered. (Holdout-bucket send-gate logic + lift analytics are cut from v1; the column stays for future-proofing.)

Holiday model holiday_calendar is a checked-in static seed file (a wrong holiday is a reviewed PR), no PII, no vendor.

Dark-launch via four per-type mkt-*-enabled registry flags (kill-switch per family). Admin /campaigns (CF-Access-gated, admin_actions-audited, design-system CVA per D42): list, author (predicate-chip selector, template_key dropdown — no free-text → no blank sends), test-send, approve (author≠approver, logged), manual send.

2.7 Measurement (sequenced LAST, minimal first — D57)

Section titled “2.7 Measurement (sequenced LAST, minimal first — D57)”

v1 = the mkt_campaign_sends ledger only (sent/suppressed/bounced outcomes) — delivery + suppression metrics with zero hot-path coupling. There is no separate notification_events table (it overlapped the send ledger — consolidated). Open/click attribution, the Resend attribution webhook, A/B holdout lift, and the bid-hot-path conversion hook are deferred to a later phase once real campaign volume exists.

When conversion attribution lands, it is a best-effort, deadline-bounded post-accept side effect on the live bid.placed path, writing only converted_at + conversion_auction_id (boolean + day, never amount). Sealed-bid invariant: a schema/query test forbids any admin/export query that JOINs the ledger to /ranked-bidders//settlement-snapshot/bids/invoices/daily_spotlight on (user_id, auction_id), and every per-cell conversion metric has a minimum-cohort floor (k-anonymity, suppress cells < N users) so a single-advertiser funnel cannot be read off the dashboard. The admin surface clones analytics-reader.ts (guardD1 + 60s isolate cache + EXPLAIN notes). Ledger growth is bounded by a retention/rollup follow-up.


DO bid commit ─► bid.outbid (rank-1 loss, 1/(auction,user)) ──► DIRECT /push/send (unchanged seam)
└► rank.climbed_to_top (#1, 1/(auction,user), NEW)─► notify-queue ─► push dispatch (P2; +freq-cap P4) ─► /push/send
cron closing ─► closing_soon (active bidders) ──► DIRECT /push/send (unchanged seam)
cron opener ─► auction.opened (NEW, audience=§2.2, marketing) ┐
cron calendar ─► calendar.day_of_interest (NEW, flag+consent) ├► notify-queue ─► Consent gate ─► email/push
admin campaign─► mkt-dispatch ─► mkt_campaign_sends claim ───────┘ (+ suppression KV + bounce backstop)
saga settle ─► won/lost/this_close ───────────────────────────► DIRECT /push/send (unchanged; won/lost rate-cap-exempt, this_close not)
Resend bounce ─► /webhooks/resend ─► q-email-bounce ─► suppression KV + markEmailBounced (single owner)

4. Data model (new D1 tables / KV / queues)

Section titled “4. Data model (new D1 tables / KV / queues)”

All new contact/consent/preference/ledger rows join both the data-rights erase sweep and the Art.15/20 export bundle (§6). Migrations are sequential under one owner (next after 0034): 0035 engagement_state, 0036 consent+preference, 0037 mkt_campaigns+sends, 0038 holiday_calendar.

objectkindpurposekey shape / indexeserase / export
engagement_stateD1the single per-user projection + cross-channel frequency home; serves all cross-day audiences; sealed-bid-safe (dates+flags, no amount/rank/ad)PK user_id; locale, country, has_first_bid, last_active_at, last_bid_date, last_won_date, lifecycle_stage(prospect|active|lapsing|lapsed|dormant), last_contacted_at, banned, erased, updated_at. Partial indexes (last_bid_date) WHERE has_first_bid=1 AND banned=0 AND erased=0, (country,last_bid_date) same WHERE, (lifecycle_stage) WHERE banned=0 AND erased=0. Range-only on last_bid_date. last_coarse_rank deliberately omittedERASE: SET erased=1, country='XX', last_contacted_at=NULL (row kept, drops from every audience). EXPORT: engagementState section
notification_preferencesD1per-(user,topic,channel) toggle + quiet-hours + per-user freq override; sparse; absence = gate defaultPK (user_id,topic,channel); enabled; quiet_hours_start/end; frequency_cap_day; updated_at. product_email default 0ERASE: DELETE WHERE user_id=?. EXPORT: all rows
marketing_consentD1the materialised CURRENT DOI opt-in state per (user,channel,purpose); only confirmed is deliverablePK (user_id,channel,purpose) = one current row; state(pending|confirmed|revoked); state_changed_at; consent_text_version; requested_ip_hash; requested_at/confirmed_at/revoked_at; doi_token_hash. idx_mktconsent_target (purpose,channel,state,user_id). The current row is derived from marketing_consent_audit ordered by (created_at, id) with revoked winning equal-timestamp ties, written under a revoke-sticky guard (a confirm never overwrites a same-or-later revoke)ERASE: DELETE WHERE user_id=?. EXPORT: all rows
marketing_consent_auditD1append-only Art.7(1) proof ledgerid (ulid) PK; user_id; channel; purpose; event(opt_in_requested|opt_in_confirmed|revoked); source; ip_hash; user_agent; created_at. idx (user_id, created_at, id) composite cursorERASE: anonymise user_id (keep row) (Art.17(3) proof-of-prior-consent), null ip_hash/user_agent. EXPORT: all rows
mkt_campaignsD1campaign definition (operator data, no user PII)id (ulid) PK; name; topic; audience_selector JSON; channel; template_key; schedule_kind+payload; status(draft|scheduled|live|done); feature_flag_key (CHECK ∈ the mkt-*-enabled set); holdout_pct; approved_by_sub/at. idx (status, schedule_kind)ERASE: untouched (no PII). EXPORT: excluded
mkt_campaign_sendsD1per-campaign send ledger = exactly-once anchor and v1 measurement sourcePK (campaign_id, user_id) (INSERT ON CONFLICT DO NOTHING); channel; outcome(queued|sent|suppressed|bounced|failed|holdout); dedupe_key (embeds user_id); sent_at; error. idx (campaign_id, outcome) (k-anon floor), idx (user_id, created_at) for erase. No JOIN to amount-bearing tables (schema test)ERASE: DELETE WHERE user_id=?. EXPORT: all rows
holiday_calendarD1per-country national-day/holiday model, seeded from a checked-in static file; no PII, no vendor (D43)PK (country, date); kind; slug; name_en; name_de; active. idx (date) WHERE active=1ERASE: n/a. EXPORT: excluded
SUPPRESSION_KVKVemail suppression list written by the bounce consumer, read before every sendsuppress:<lowercased-email> -> reason(bounce|complaint|manual); prod + -preview; added to shared_core() heredocsERASE: delete suppress:<rawEmail> using the email captured BEFORE anonymize. EXPORT: n/a
q-email-bounce (+ -preview, + DLQ)queueResend bounce/complaint ingest → single-owner suppression KV + markEmailBounced (hard-bounce/complaint only). The only genuinely new queuemessage {email, event_type, occurred_at}; consumer resolves email→user_id; DLQ = saga-deadletter-queue. Wired via terraform queue_names (prod+preview) + producer (apps/webhook) + consumer (apps/queue) + BOTH shared_core() heredocsn/a (transport)
notify-queuequeuereuse the existing dead scaffold as the engagement signal/email bus (already TF-provisioned + bound + consumer-declared)body = EngagementSignal | {user_id, template, locale, vars, dedupe_key, campaign_id?}; add the missing dispatch branch in apps/queue/src/index.tsn/a (transport)

Push-subscription KV keys (push:idx/push:sub, device PII never erased today) join the erase sweep (D56).


5. Sealed-bid guardrails (binding invariants)

Section titled “5. Sealed-bid guardrails (binding invariants)”
  1. No notification/email/push/copy/ledger column/admin query may EVER contain a bid amount (own or others’), the gap to the leader, who outbid you / who you passed, a competitor identity, or any rank beyond 3. The only position info permitted: binary “you’ve been outbid on {date}” and the bidder’s OWN coarse rank 1|2|3|null.
  2. bid.outbid stays a SINGLE push per (auction, user) — the dedupe key drops bid_id — so repeated probe bids cannot generate a per-probe oracle response. Confirmed: keep firing on lost-#1 (not narrowed to top-3-exit); any change is a 2-reviewer auction-do PR.
  3. rank.climbed_to_top fires AT MOST ONCE per (auction, user) via a DO-side per-user marker, never re-firing on lead re-takes — preventing a live in/out-of-#1 oscillation oracle. #1 boundary only; #2/#3 climb is forbidden.
  4. Every EngagementSignal member is .strict() zod at the queue boundary so a smuggled amount/rank>3/competitor field fails .parse(); an invalid payload dead-letters loudly, never silently acks.
  5. Per-template var allow-list ({date},{day},{holiday_label}, and {rank} ∈ {1,2,3} = the recipient’s OWN coarse rank only) enforced at parse time + a CI lint over push + email templates. The {rank} validator rejects any value outside 1|2|3 (no rank > 3). Structural, not copy-review.
  6. closing_soon = transactional is structural: emittable ONLY by the active-bidder cron lane, and the campaign audience-selector grammar REJECTS topic=closing_soon — it can never become a participation oracle for non-bidders.
  7. Cross-day audience is served ONLY by engagement_state with RANGE predicates on last_bid_date (never single-day equality returning a bidder list); a query-plans.test.ts forbid-rule enforces it. Live day-scoped audiences stay on server-only /active-bidders, never materialised into D1.
  8. last_coarse_rank is dropped from engagement_state; positive-rank eligibility is computed live in the DO, never persisted, never exported.
  9. The measurement ledger has NO amount/rank/competitor column and a schema test forbids any admin/export query JOINing it to amount-bearing surfaces on (user_id, auction_id). Every per-cell conversion metric has a minimum-cohort (k-anonymity) floor.
  10. Server-only audience/ranked/settlement endpoints remain network-isolated same-account .fetch(); never publicly exposed.
  11. Saga terminal pushes (won/lost/this_close) stay on the direct seam, bypassing engagement frequency-capping/quiet-hours.

Section titled “6. Legal guardrails (GDPR/DSGVO + German UWG §7)”
  1. TOPIC_CLASSIFICATION in packages/legal is the SINGLE binding classifier; Signal/Orchestration/Campaign/Data-Model import it, never re-classify. new_auction_open, outbid_recovery-email, winback, and holiday/calendar are MARKETING (DOI-required) for email; product_email defaults 0.
  2. No non-transactional engagement email may be sent before: prior explicit opt-in + double-opt-in + logged proof + a working unsubscribe (Art.6(1)(a) + UWG §7). Confirmed: strictly DOI-only — no UWG §7(3) existing-customer carve-out. resolveContactGate fail-closes on consent state ≠ confirmed.
  3. The marketing EMAIL channel is hard-blocked in production until the Art.15/20 export bundle (#497, 501 stub today) actually returns the user’s marketing_consent + marketing_consent_audit + notification_preferences rows (D52). Push + transactional email may ship first.
  4. Signed-token kind enum widened to {export, delete, marketing_consent_confirm, unsub}; every endpoint asserts the EXACT expected kind after validateToken (D53). token.ts is a 2-reviewer change.
  5. The DOI confirmation email is transactional, confirm-CTA-only (no marketing offer, no List-Unsubscribe), sent on the apps/api/emails.ts seam NOT the engagement queue, and tested to contain only the confirm link. Consent proof stores a hashed/truncated IP (Art.5(1)(c) minimisation) + consent_text_version + timestamp with a documented retention period.
  6. RFC 8058 one-click POST /u/:token is exempt from all session/CSRF/auth middleware (token is the only auth), idempotent, and atomically flips BOTH consent state and preference rows in one D1 batch; the unsub-token secret lifecycle is pinned so year-old emails still unsubscribe. List-Unsubscribe-Post is byte-exact List-Unsubscribe=One-Click.
  7. The authoritative latest consent state is derived from the append-only marketing_consent_audit by (user_id, created_at, id) with revoked winning equal-timestamp ties (no created_at-only cursor on a compliance log), and materialised into the marketing_consent current row under a revoke-sticky guard (a confirm never overwrites a same-or-later revoke).
  8. Before markEmailBounced goes live, every reader of email_bounced_at / resolveDeliverableUser is audited; only hard-bounce/complaint sets the flag; must-deliver transactional mail (3DS, data-rights ack, settlement-failure) is not suppressed by a soft bounce (tested).
  9. Every new contact/preference/consent/ledger row joins BOTH the erase sweep and the export bundle. marketing_consent_audit is anonymised-not-deleted on erase (Art.17(3)); preference + consent-state + ledger rows are deleted; push:idx/push:sub device-PII keys are deleted; suppress:<email> is deleted on erase (confirmed — Art.17 minimisation, re-registration re-learns bounces). The erase sweep runs only inside the post-anonymize success block (after the unpaid-invoices gate), capturing the RAW email before anonymize; a blocked erase never half-strips rows.
  10. banned + DSGVO-erased + bounced users are excluded in the audience SQL (baked into partial-index WHERE + defensively re-asserted).
  11. No new tracking vendor or cookie — preserves D43. Resend is the only declared subprocessor (D14). Engagement measurement uses ONLY first-party channel webhooks + the send ledger, never a third-party pixel/SDK; email targeting is never driven off the cookie-banner ConsentState. The two “marketing” concepts (cookie:marketing vs email_marketing) stay namespaced.
  12. Holiday data is a checked-in static seed (a wrong holiday is a reviewed PR), not a third-party holiday API.

These append to master §2 after D49 — the bid.outbid one-push-per-(auction, displaced-user) collapse for this sub-system is already recorded as master D49 on develop (cited by epic #722); this spec references it rather than re-numbering it. Full prose lives in the master decision log; summarised here:

  • D50 — EngagementSignal bus over the reused notify-queue; rank.climbed_to_top #1-boundary, at-most-once via a DO marker (the bid.outbid dedupe collapse is master D49). The richer coarse-rank signal stays ephemeral in-session WS (D28).
  • D51 — Marketing engagement sub-system (spec 15) with per-type mkt-*-enabled dark-launch flags; marketing email topics require double-opt-in; TOPIC_CLASSIFICATION (packages/legal) is the single binding classifier; product_email defaults OFF.
  • D52 — The marketing EMAIL channel is hard-blocked from production until the Art.15/20 export bundle (#497) returns the consent/preference rows. Push + transactional email may ship first.
  • D53 — Widen the data-rights signed-token kind enum to add marketing_consent_confirm + unsub; every endpoint asserts the exact expected kind after validateToken.
  • D54 — Consolidate the four overlapping per-user projections into ONE canonical engagement_state (also homes last_contacted_at); drop last_coarse_rank; reconcile the mkt_ table prefix vs the existing campaign_reports; assign migrations sequentially under one owner.
  • D55 — New-queue/new-KV provisioning is wired by adding the resource key to BOTH the production and preview heredocs of scripts/affected-workers.sh shared_core() (and scripts/test-affected-workers.sh), NOT by editing deploy.yml/preview.yml -target lists. Reuse the provisioned notify-queue as the bus; add exactly one new queue, q-email-bounce.
  • D56 — Single-owner the bounce pipeline in q-email-bounce (hard-bounce/complaint only, markEmailBounced only when a user row exists); push-subscription KV keys join the erase sweep.
  • D57 — Defer the generic @nowyourlink/orchestrator package, the engage-queue, rolling-window KV frequency counters, per-country quiet-hours, the separate notification_events table, the Resend attribution webhook, A/B holdout-bucket send-gate + lift analytics, and the bid-hot-path conversion hook. v1 measurement = the mkt_campaign_sends ledger; quiet-hours, when added, DEFER rather than DROP.

Sequenced so no marketing send precedes consent infra, and the data/suppression/frequency foundation precedes every trigger. Each phase is shippable and reversible behind a flag.

PhaseGoalIssues
1 — FoundationLand the EngagementSignal + resolveContactGate contracts, the canonical engagement_state + consent/preference tables (sequential migrations), the suppression+bounce+markEmailBounced deliverability path, and the structural sealed-bid guards — none of which sends marketing.#724 (F1), #725 (F2), #726 (F3), #727 (F4), #728 (F5), #729 (F6), #730 (F7), #731 (F8), #732 (F9)
2 — Push-only engagementHarden the live outbid oracle, add the #1-boundary climbed_to_top, wire the notify-queue, the cross-day audience resolvers + lifecycle cron — engagement riding existing push consent + the 8/24h cap.#733 (P2A), #756 (P2B), #734 (P2C), #735 (P2D), #757 (P2E), #736 (A1), #737 (A2)
3 — Consent captureDouble-opt-in capture (widened token kinds), RFC 8058 one-click unsubscribe, preference center, the Art.15/20 export bundle (unblocks email), and the erase sweep.#738 (C1), #739 (C2), #740 (C3), #741 (C4), #742 (C5), #743 (C6)
4 — Marketing email + campaign engineEmail outbound bus + render fns, channel selection, the min-gap frequency cap, mkt_campaigns/sends ledger, the selector compiler, holiday model, dark-launch flags, the dispatch cron, and the admin /campaigns console — all DOI-gated, flag-dark-launched, after export exists.#744 (E1), #745 (E2), #746 (E3), #747 (K1), #748 (K2), #749 (K3), #750 (K4), #751 (K5), #752 (K6)
5 — MeasurementSurface the send-ledger funnel (k-anonymised, no amount joins); retention/rollup; spec 13 §10.2 recipe + docs publication.#753 (M1), #754 (M2), #755 (M3)

Critical path: F3 → F4 → C1 → C2 → C5 → E1 → K1 → K2 → K5 → M1 (#726 → #727 → #738 → #739 → #742 → #744 → #747 → #748 → #751 → #753).

CODEOWNERS 2-reviewer issues: F2 (#725, legal), F5 (#728, terraform), F9 (#732, auction/saga), P2A (#733, auction), P2B (#756, auction), C1 (#738, legal), C2 (#739, legal).

Per-phase acceptance criteria are consolidated in the acceptance companion (M3, #755) and live on each issue.


9. Confirmed product decisions (this planning round)

Section titled “9. Confirmed product decisions (this planning round)”
  • Outbid push (master D49): one push per (auction, displaced user); keep firing on lost-#1 even when still #2/#3 (do not narrow to top-3-exit).
  • Email legal basis: strictly double-opt-in for all marketing email — no UWG §7(3) existing-customer carve-out.
  • Erasure: delete suppress:<email> on GDPR erase (Art.17 minimisation; a re-registration re-learns bounce status).
  • Config defaults (tunable without deploy): lifecycle stages 30 / 90 / 180 days; marketing frequency cap ≥72h gap and ≤1/week; ledger retention 180 days; holiday seed = DE public holidays + a small EN/global starter set. These are starting points open to legal/product tuning (see §12 follow-ups).

Error handling (uniform). Every producer emit / ledger write / attribution flip is best-effort and swallowed; queue consumers ack on a typed “no”, retry only on a transport throw, DLQ on poison. The DOI email and all must-deliver transactional mail never route through engagement orchestration. The bid-hot-path side effects add zero synchronous reads beyond the already-built id→user map and are deadline-bounded.

Testing (spec 13). 100% coverage; verify-the-zero probes on every new type-aware rule; EXPLAIN QUERY PLAN assertions on every resolver/reader; check-drift.test.ts updated for all new CHECK enums; the sealed-bid template lint + the no-amount-join test are required CI components, not review habits; a smuggled-amount-fails-.parse() test on every new signal; a soft-bounce-does-not-block-3DS test; an unsub-token-rejected-at-consent-endpoint test; a capped-engagement-push-makes-zero-/push/send-calls test.


Explicitly out of MVP for this sub-system; do not re-scope into earlier phases without a new decision:

  • generic @nowyourlink/orchestrator package + engage-queue;
  • per-country quiet-hours and rolling-window KV frequency counters (v1 uses a single last_contacted_at min-gap);
  • open/click attribution + the Resend attribution webhook + a separate notification_events table;
  • A/B holdout-bucket send-gate logic + lift analytics (the holdout_pct column is kept for future-proofing);
  • the bid-hot-path conversion-attribution hook;
  • an in-app notification inbox / bell / history.

12. Open follow-ups for product/legal sign-off

Section titled “12. Open follow-ups for product/legal sign-off”

These do not block the foundation phases but are flagged for confirmation as the relevant phase approaches:

  • Lifecycle thresholds (active→lapsing 30d, lapsed 90d, dormant 180d) drive win-back volume + frequency-cap pressure — confirm before Phase 2 (A2/#737).
  • Marketing frequency caps (≥72h gap, ≤1/week) and the quiet-hours window — confirm initial values before Phase 4 (E3/#746, K5/#751); they live in config so ops can tune without deploy.
  • Holiday seed dataset — which days count as “days of interest” for DE and the EN/global set; needs product sign-off to avoid spammy or culturally-off nudges (the seed is a checked-in file — K3/#749).
  • mkt_campaign_sends retention window before rollup/archive — confirm the raw-row retention period (default 180d — M2/#754).