15 — engagement-marketing (Design)
15 — engagement-marketing (Design)
Section titled “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).
1. Purpose & scope
Section titled “1. Purpose & scope”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.
2. Architecture — the seven layers
Section titled “2. Architecture — the seven layers”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 existingpush.this_closealready renders#{rank}; reject any var named amount/gap/margin/bid/competitor/position) + a CI lint overpush.{en,de}.jsonand email templates; and a schema test that the measurement reader’s SQL references none ofbids/invoices/saga_runs/daily_spotlight//ranked-bidders//settlement-snapshot. - Consent is one binding source of truth.
TOPIC_CLASSIFICATION(inpackages/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
WHEREclauses and re-asserted defensively in resolvers.
2.1 Signal (triggers)
Section titled “2.1 Signal (triggers)”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):
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 displacingbid_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.auction.closing_soon— kept on its direct seam (no rewrite). The working cron lane (closing-soon.ts:84) keeps calling/push/sendper active bidder with its per-user dedupe key. Structurally,closing_soonmay be emitted only by the active-bidder cron lane, and the campaign audience grammar rejectstopic=closing_soonso it can never become a participation oracle for non-bidders.rank.climbed_to_top— new, #1-boundary only, at-most-once (D50). A new DO producer co-located in the samediff.changedblock 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-finalthis_close). Carries no amount/margin/who-passed.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-onlyuser_bidsmirror. Marketing-gated; flag-off until consent infra lands.calendar.day_of_interest— new, holiday/national-day. Daily cron lane behindengagement-calendar-enabled+ marketing consent, reading the seededholiday_calendarfor the user’s country/locale. Carriesday_slug+name_key(i18n key, never literal copy). No third-party holiday API (preserves D43).won/lost/this_close— unchanged, direct, bypass engagement orchestration. Stay on the sagabestEffortPushseam; terminal, must-fire, exactly-once insidestep.do; explicitly NOT routed through engagement orchestration/frequency-capping (a “you won” must never be frequency-capped). Rate-cap exemption iswon/lostonly (exempt_rate_limit:true);this_closestays 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.
2.2 Audience (targeting)
Section titled “2.2 Audience (targeting)”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 enforcesbanned=0 AND erased=0in SQL (baked into partial indexes) and isEXPLAIN-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.
2.3 Consent & Preference (the legal gatekeeper, packages/legal)
Section titled “2.3 Consent & Preference (the legal gatekeeper, packages/legal)”TOPIC_CLASSIFICATION (binding, single source of truth):
| topic | class | email gate |
|---|---|---|
outbid, closing_soon (active bidders) | transactional | bypass; default-ON, suppressible |
won/lost/this_close/account/security/3ds/settlement_failure/data_rights | transactional must-deliver | bypass; NOT suppressible |
new_auction_open, outbid_recovery (email), winback, holiday/calendar | MARKETING | DOI 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).
2.4 Channel (delivery)
Section titled “2.4 Channel (delivery)”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.
3. Cross-layer data flow
Section titled “3. Cross-layer data flow”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/sendcron 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/pushadmin 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.
| object | kind | purpose | key shape / indexes | erase / export |
|---|---|---|---|---|
engagement_state | D1 | the 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 omitted | ERASE: SET erased=1, country='XX', last_contacted_at=NULL (row kept, drops from every audience). EXPORT: engagementState section |
notification_preferences | D1 | per-(user,topic,channel) toggle + quiet-hours + per-user freq override; sparse; absence = gate default | PK (user_id,topic,channel); enabled; quiet_hours_start/end; frequency_cap_day; updated_at. product_email default 0 | ERASE: DELETE WHERE user_id=?. EXPORT: all rows |
marketing_consent | D1 | the materialised CURRENT DOI opt-in state per (user,channel,purpose); only confirmed is deliverable | PK (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_audit | D1 | append-only Art.7(1) proof ledger | id (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 cursor | ERASE: anonymise user_id (keep row) (Art.17(3) proof-of-prior-consent), null ip_hash/user_agent. EXPORT: all rows |
mkt_campaigns | D1 | campaign 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_sends | D1 | per-campaign send ledger = exactly-once anchor and v1 measurement source | PK (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_calendar | D1 | per-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=1 | ERASE: n/a. EXPORT: excluded |
SUPPRESSION_KV | KV | email suppression list written by the bounce consumer, read before every send | suppress:<lowercased-email> -> reason(bounce|complaint|manual); prod + -preview; added to shared_core() heredocs | ERASE: delete suppress:<rawEmail> using the email captured BEFORE anonymize. EXPORT: n/a |
q-email-bounce (+ -preview, + DLQ) | queue | Resend bounce/complaint ingest → single-owner suppression KV + markEmailBounced (hard-bounce/complaint only). The only genuinely new queue | message {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() heredocs | n/a (transport) |
notify-queue | queue | reuse 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.ts | n/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)”- 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.
bid.outbidstays a SINGLE push per (auction, user) — the dedupe key dropsbid_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.rank.climbed_to_topfires 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.- Every
EngagementSignalmember 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. - 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. closing_soon = transactionalis structural: emittable ONLY by the active-bidder cron lane, and the campaign audience-selector grammar REJECTStopic=closing_soon— it can never become a participation oracle for non-bidders.- Cross-day audience is served ONLY by
engagement_statewith RANGE predicates onlast_bid_date(never single-day equality returning a bidder list); aquery-plans.test.tsforbid-rule enforces it. Live day-scoped audiences stay on server-only/active-bidders, never materialised into D1. last_coarse_rankis dropped fromengagement_state; positive-rank eligibility is computed live in the DO, never persisted, never exported.- 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.
- Server-only audience/ranked/settlement endpoints remain network-isolated same-account
.fetch(); never publicly exposed. - Saga terminal pushes (won/lost/this_close) stay on the direct seam, bypassing engagement frequency-capping/quiet-hours.
6. Legal guardrails (GDPR/DSGVO + German UWG §7)
Section titled “6. Legal guardrails (GDPR/DSGVO + German UWG §7)”TOPIC_CLASSIFICATIONinpackages/legalis the SINGLE binding classifier; Signal/Orchestration/Campaign/Data-Model import it, never re-classify.new_auction_open,outbid_recovery-email,winback, andholiday/calendarare MARKETING (DOI-required) for email;product_emaildefaults 0.- 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.
resolveContactGatefail-closes on consent state ≠confirmed. - 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_preferencesrows (D52). Push + transactional email may ship first. - Signed-token kind enum widened to
{export, delete, marketing_consent_confirm, unsub}; every endpoint asserts the EXACT expected kind aftervalidateToken(D53).token.tsis a 2-reviewer change. - The DOI confirmation email is transactional, confirm-CTA-only (no marketing offer, no List-Unsubscribe), sent on the
apps/api/emails.tsseam 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. - RFC 8058 one-click
POST /u/:tokenis 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-Postis byte-exactList-Unsubscribe=One-Click. - The authoritative latest consent state is derived from the append-only
marketing_consent_auditby(user_id, created_at, id)withrevokedwinning equal-timestamp ties (no created_at-only cursor on a compliance log), and materialised into themarketing_consentcurrent row under a revoke-sticky guard (aconfirmnever overwrites a same-or-laterrevoke). - Before
markEmailBouncedgoes live, every reader ofemail_bounced_at/resolveDeliverableUseris 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). - Every new contact/preference/consent/ledger row joins BOTH the erase sweep and the export bundle.
marketing_consent_auditis anonymised-not-deleted on erase (Art.17(3)); preference + consent-state + ledger rows are deleted;push:idx/push:subdevice-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. - banned + DSGVO-erased + bounced users are excluded in the audience SQL (baked into partial-index WHERE + defensively re-asserted).
- 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.
- Holiday data is a checked-in static seed (a wrong holiday is a reviewed PR), not a third-party holiday API.
7. Decision log additions (D50–D57)
Section titled “7. Decision log additions (D50–D57)”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 —
EngagementSignalbus over the reusednotify-queue;rank.climbed_to_top#1-boundary, at-most-once via a DO marker (thebid.outbiddedupe 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-*-enableddark-launch flags; marketing email topics require double-opt-in;TOPIC_CLASSIFICATION(packages/legal) is the single binding classifier;product_emaildefaults 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 aftervalidateToken. - D54 — Consolidate the four overlapping per-user projections into ONE canonical
engagement_state(also homeslast_contacted_at); droplast_coarse_rank; reconcile themkt_table prefix vs the existingcampaign_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()(andscripts/test-affected-workers.sh), NOT by editing deploy.yml/preview.yml-targetlists. Reuse the provisionednotify-queueas the bus; add exactly one new queue,q-email-bounce. - D56 — Single-owner the bounce pipeline in
q-email-bounce(hard-bounce/complaint only,markEmailBouncedonly when a user row exists); push-subscription KV keys join the erase sweep. - D57 — Defer the generic
@nowyourlink/orchestratorpackage, the engage-queue, rolling-window KV frequency counters, per-country quiet-hours, the separatenotification_eventstable, the Resend attribution webhook, A/B holdout-bucket send-gate + lift analytics, and the bid-hot-path conversion hook. v1 measurement = themkt_campaign_sendsledger; quiet-hours, when added, DEFER rather than DROP.
8. Phase plan & issue map (epic #722)
Section titled “8. Phase plan & issue map (epic #722)”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.
| Phase | Goal | Issues |
|---|---|---|
| 1 — Foundation | Land 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 engagement | Harden 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 capture | Double-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 engine | Email 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 — Measurement | Surface 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).
10. Error handling & testing
Section titled “10. Error handling & testing”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.
11. Deferred capabilities (YAGNI — D57)
Section titled “11. Deferred capabilities (YAGNI — D57)”Explicitly out of MVP for this sub-system; do not re-scope into earlier phases without a new decision:
- generic
@nowyourlink/orchestratorpackage +engage-queue; - per-country quiet-hours and rolling-window KV frequency counters (v1 uses a single
last_contacted_atmin-gap); - open/click attribution + the Resend attribution webhook + a separate
notification_eventstable; - A/B holdout-bucket send-gate logic + lift analytics (the
holdout_pctcolumn 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_sendsretention window before rollup/archive — confirm the raw-row retention period (default 180d — M2/#754).