Skip to content

ADR-0025 · Campaigns, offers & the targeting primitive ​

Status: 🟡 Proposed — authored 2026-08-08, awaiting owner approval · Extends: ADR-0022, ADR-0023, ADR-0024 · Amends: ADR-0004 (first-party offers are NOT the inert ad seam), ADR-0019 (first render-time temporal gate) · Depends on: ADR-0010, ADR-0021

The one-line thesis

Five of the six pieces already exist. The genuinely hard one is that a scheduled campaign is the first time-varying thing the public card must render — and every prior design decision deliberately kept temporal gates away from rendering because they break the edge cache. The answer is scheduled purge via the outbox at campaign boundaries, not a short TTL and not client-side rendering. And a first-party vendor offer must not ride the inert promo_slot ad seam: conflating them either kills the campaign engine or opens a compliance exposure ADR-0004 exists to prevent.

Alignment ​

Yes, and this is the strongest Enterprise argument in the product. Stated in business terms because that is the reason it matters architecturally:

  • It changes what Enterprise is. Centralised billing and seat management is procurement convenience — a finance department appreciates it, nobody renews for it. "Publish a Diwali offer to 30 agents' cards in one action, watch it perform by showroom, and have it expire by itself" is an operational tool a sales head uses weekly.
  • It is a retention mechanism, not a feature. An admin who runs campaigns weekly has their working month built around QRSETU. That is the difference between a subscription and a habit.
  • It is also the platform's own growth loop, because a campaign is the thing an agent wants to share — and sharing is what puts Setu Cards in front of new customers.

So the question "does the foundation support it?" is the right one to ask before stretching automotive further.

What already exists — five of six pieces ​

RequirementExisting mechanism
Central creation, cascaded to agentsADR-0022 — an org-owned shareable resource, resolved down the tree
Targeting by showroom / division / agentADR-0023 — a subtree is a path prefix; workspace_groups handles arbitrary sets
A slot on the card to render intoADR-0019 — the manifest's closed block vocabulary
Impressions and clicksanalytics_events + the client beacon
Start/end windowsTime-bounding is an established pattern — feature_grants.effective_from/until, card_templates.available_from/until, seats assign/revoke

Nothing here needs inventing. The gaps below are real but they are additions to a foundation that already has the shape, which is the outcome this whole review sequence was checking for.

Decision ​

D1 — A first-party OFFER block is not the third-party promo_slot. They must stay separate ​

ADR-0004's amendment made resolvePromo(slotId, ctx) return null and "fail closed forever", because industries.compliance_profile/ad_restricted does not exist and a doctor's or loan agent's card carrying a third-party ad is a legal exposure, not a product gap.

A dealership publishing its own Diwali offer to its own agents' cards is a categorically different thing, and the design currently has one concept where it needs two:

promo_slot (ADR-0004)offer (this ADR)
Whose contentThird party — platform-sold or cross-vendorThe vendor's / their organization's own
Compliance riskReal — a restricted vertical may not carry itNone — a doctor advertising their own service is ordinary commerce
StatusInert, fails closed foreverLive
ResolverresolvePromo → nullresolveOffers(card, now)

Conflating them fails in both directions: the campaign engine would inherit a deliberately-dead resolver, or the ad seam would be opened before compliance_profile exists — creating exactly the exposure ADR-0004 documents. Two block types, two resolvers, one of them permanently inert.

D2 — ⚠ THE HARD PART: a scheduled campaign is the first RENDER-TIME temporal gate, and it collides with the edge cache ​

Every prior temporal decision in this platform deliberately avoided this. ADR-0019 states it outright: seasonal template windows "gate selection only, never rendering". ADR-0004's amendment states the underlying reason: content that varies per request *"makes the card uncacheable per-slug and destroys the

90% hit-rate target."* And QRS-381 established that the long tail needs long TTLs, not short ones.

A campaign breaks that rule by design. A Diwali offer starting 00:00 on 29 Oct means the HTML cached at 23:59 is wrong one minute later. Options, evaluated:

OptionVerdict
Short TTL❌ Destroys the cache target and directly contradicts QRS-381's long-tail finding.
Client-side render after paint❌ Violates ADR-0019's Tier-0 budget (the card must fully render with JS disabled), and the offer would be absent from the SSR HTML and from the OG image — losing the highest-leverage surface (G6).
Edge-computed window in a Worker🟡 Works, but adds compute to the hottest path in the product for a boundary that is known in advance.
✅ Scheduled purge at campaign boundaries, via the outboxRecommended.

Why scheduled purge is the right answer and not a workaround: a campaign's starts_at/ends_at are known when it is published. So publishing enqueues two outbox rows — purge at start, purge at end — and the mechanism that already exists for write-path invalidation (G1/G2) does the work. Deterministic, zero per-request cost, long TTLs preserved, Tier-0 intact.

Two consequences to build in:

  • Purge the OG raster on the same boundaries. A Diwali offer belongs in the WhatsApp preview — that is the first impression before anyone taps (G6). Same cache tag, same purge.
  • outbox needs scheduled_for. It was designed as a drain-now queue; campaign boundaries make it a scheduled queue. One nullable timestamp column, and it must land in the baseline because the outbox is otherwise built without a scheduler.

D3 — Extract TARGETING as a shared vocabulary. A campaign is not a grant, but it targets identically ​

The owner's targeting requirements — applicable showrooms, applicable agents or teams — are not always one subtree ("Thane and Andheri but not Pune"). So targeting needs to express a subtree or a group or an explicit set.

That is exactly feature_grants' polymorphic scope, and the right move is to reuse the vocabulary without merging the tables:

campaign_targets → same scope kinds as feature_grant_scopes
                   (platform · archetype · industry · plan · workspace_group · workspace-subtree · workspace · member)
                   exactly-one-non-null FK columns, same CHECK discipline

Deliberately NOT unified with feature_grants. A grant answers "is this available" (boolean/limit) and sits on the entitlement hot path; a campaign carries content (title, media, discount, T&Cs). Merging would put a content payload inside the resolver that runs on every screen. Unify the targeting primitive, not the tables — and the same vocabulary then serves grants, campaigns and, later, loyalty rules.

D4 — Campaign is the eleventh primitive ​

Applying the ≥3-industry gate — it passes overwhelmingly, and it is a genuine process (create → target → schedule → activate → measure → expire), not a value:

IndustryCampaign
Car dealershipDiwali offer · stock clearance · exchange bonus · AMC package
Boutique / retailFestive sale · end-of-season
SalonMonsoon package · bridal season
Restaurant / cloud kitchenCombo offer · happy hours
Coaching instituteEarly-bird admission
Dairy / tiffinRefer-a-friend · festive gift box

campaigns(workspace_id, title, description, media_id, discount_kind, discount_value, starts_at, ends_at, terms, status) + campaign_targets + campaign_items (which catalogue entries it applies to).

It is not composable from existing primitives. Schedule is "a thing happens at a time" for bookings; a campaign is a content visibility window with its own lifecycle and its own analytics. Recording that check because the primitive gate is only worth anything if it is actually applied.

D5 — A campaign NEVER mutates item prices. The effective price is resolved and then snapshotted ​

catalog_items already carries price_minor + compare_at_price_minor with discount derived. A campaign discount would be a third price source — the duplicate-source-of-truth class (QRS-249/284/287).

So: resolution order campaign discount → compare_at_price → price, computed at render and at order time, never written back to the item. And the order line snapshots the effective price plus source_campaign_id, which preserves QRS-387's snapshot rule and makes campaign revenue attributable rather than estimated.

D6 — Campaign attribution needs TYPED columns, and they are cheap only now ​

ADR-0010's rule: anything filtered, sorted, grouped or charted is a first-class typed column, not a props key. Campaign performance is precisely that, so:

  • analytics_events.campaign_id — typed column, for impressions and clicks
  • leads.source_campaign_id, orders.source_campaign_id — enquiries, test drives, bookings, conversions
  • Roll-up by showroom / division / agent is then the existing path-prefix query (ADR-0023 D1)

These are append-only and high-volume tables. Adding an attribution column later means every historical event is unattributable — there is no forward fix, the same argument as QRS-387's tax snapshot.

D7 — Loyalty/points is NOT blocked, provided ONE thing is true now ​

Assessed honestly rather than reassuringly. A points system decomposes into primitives that already exist:

Loyalty needPrimitive
Points earned / spentLedger — "money and quantity movement"
A member's points balanceBalance — per Party
Redeem against a ₹10,000 AMC at a set rateCampaign whose discount is funded by a points debit

So nothing is blocked — on one condition: Ledger and Balance must carry a unit code, not a hardcoded CHECK (currency = 'INR'). INR is one unit; POINTS is another; a dairy's LITRES is a third.

This is the single cheapest forward-compatibility decision available in this ADR — a column definition today versus retrofitting a unit dimension onto a populated money ledger, which is genuinely horrible. Recording it as the concrete answer to "don't let the foundation prevent this later."

Options considered and rejected ​

OptionWhy rejected
Copy the campaign onto each agent's card30 copies, divergence, and a mid-flight edit updates some cards and not others. Resolution over the tree (ADR-0022) is one row read by 30 cards — the same argument that decided shared inventory.
Reuse promo_slot for first-party offersInherits a deliberately-inert, compliance-gated resolver, or opens the ad seam before compliance_profile exists. D1.
Merge campaigns into feature_grantsPuts a content payload on the entitlement hot path. Share the targeting vocabulary, not the table. D3.
Store the discounted price on the itemThird price source; and reverting a campaign would have to un-write prices it may not have set. D5.
Short cache TTL to catch campaign boundariesContradicts QRS-381's long-tail finding; pays a permanent cost for a handful of known instants.
Campaign as an attributes JSON blob on the workspaceUntargetable, unschedulable, unanalysable. The exact "display data" anti-pattern ADR-0010 forbids.

Consequences ​

Schema (additive, baseline where marked ⚠): campaigns · campaign_targets · campaign_items · offer block type in the manifest + resolveOffers() · ⚠ outbox.scheduled_for · ⚠ analytics_events.campaign_id · ⚠ leads/orders.source_campaign_id · ⚠ ledger.unit (not INR-only).

The four ⚠ items are the only ones that are expensive later — three because they sit on append-only or high-volume tables, one because the outbox would otherwise be built without a scheduler.

pgTAP + integration must prove the negatives: an offer is invisible before starts_at and after ends_at · a campaign targeted at Thane never renders on an Andheri agent's card · an agent cannot edit an org-published campaign (oversight is read-only, ADR-0024 D1) · a cached card does show the offer within the purge window of starts_at (the one that actually proves D2 works, and it needs a live Cf-Cache-Status check, not a unit test).

Cost: ~+1.5d for the campaign engine, of which ~0.5d is the four baseline-critical columns. The admin UI is a separate Claude Design pass (CLAUDE.md § "A screen that does not exist yet").

Recommendation ​

Adopt D1–D7. Build the four ⚠ columns and outbox.scheduled_for in the baseline — they are cheap now and unfixable later. Build the campaign engine itself when Enterprise is genuinely being sold, because it is additive by construction once those columns exist.

The strongest signal from this assessment: five of six pieces were already there. Central publication is ADR-0022's sharing, targeting is ADR-0023's tree, the render slot is ADR-0019's manifest, measurement is ADR-0010's event stream, and time-bounding is a pattern used three times already. That is what a foundation absorbing a major new capability without a redesign looks like — and the one genuinely hard part (D2) is hard because of a deliberate prior decision, not an oversight.