Skip to content

docs: RFC — first-class bring-your-own decision engine support - #64

Closed
ramboz wants to merge 3 commits into
v2from
rfc/byo-decision-engine
Closed

docs: RFC — first-class bring-your-own decision engine support#64
ramboz wants to merge 3 commits into
v2from
rfc/byo-decision-engine

Conversation

@ramboz

@ramboz ramboz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Draft RFC (discussion only — not merge-ready) proposing first-class support for "bring your own decision engine" integrations, where a customer's own personalization/experimentation engine drives the plugin client-side (via an edge worker that hides the key), while the engine keeps ownership of segmentation, bucketing, and exposure tracking.

Adds docs/rfcs/byo-decision-engine.md, grounded in a real integration. It covers:

  • Bugs / footguns found while building it — one fixed in fix: support a single audience/campaign per selector in fragment manifests #63 (single audience/campaign per selector), and one documented with its fix direction (page-level multi-word audience/campaign names don't resolve, because getAllMetadata camelCases names lossily).
  • BYO enablers — a batched, context-aware audience resolver; external experiment assignment (so the engine owns the split, not the client ued bucketing); a tracking/exposure override; a pluggable decision renderer. All opt-in, no-ops by default.
  • A reference kit — a proxy-worker template + a client helper + contract tests, so integrators stop re-inventing the same glue.

Opening as a draft to gather direction on the API surface and on whether audiences / campaigns / experiments should converge behind a single "decision provider" before any implementation.

🤖 Generated with Claude Code

Captures what a real BYO decision-engine integration ran into (two bugs plus
four design gaps), proposes optional extension-point hooks (batched audience
resolver with context, external experiment assignment, tracking override,
pluggable decision renderer — all no-ops by default), and suggests a reference
proxy-worker + client helper + contract tests. Discussion only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread docs/rfcs/byo-decision-engine.md Outdated
Comment on lines +84 to +87
5. **No exposure/tracking override.** The plugin fires its own RUM per type with
no opt-out. A BYO engine already fires exposure server-side, so you get
double counting, and the inline reporting doesn't apply. Proposed:
`tracking: 'off' | (event) => void`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this explicitly rumTracking. also we have events that can be used to hook in custom tracking if needed.

Comment on lines +75 to +82
4. **No external experiment assignment — the biggest gap.** Experiments
self-bucket client-side (`ued.evaluateDecisionPolicy`); the only override is a
`?experiment=id/variant` query param. There is no clean way to say *"the arm
comes from my engine — don't randomize."* We could not use the experiment
primitive at all and had to model BYO experiments as audiences/campaigns.
Proposed: a pluggable assignment provider the engine owns, e.g.
`getAssignment(experimentId, context) → variant`, with the plugin still
rendering variants and reporting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

Comment on lines +64 to +73
3. **No batched decision resolver with a shared context.** Each audience is an
independent, argument-less `options.audiences[key]()`, run in `Promise.all`.
A remote engine wants **one** call per page with a shared context
(`{ visitorId, url, consent, geo, … }`), then answers all audiences from it.
We hand-rolled a memoized fetch to avoid N round trips. Proposed:
```js
loadEager(document, {
resolveAudiences: async (names, context) => { /* one call */ },
});
```

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

Comment on lines +89 to +92
6. **No pluggable decision renderer.** Application is fetch-URL-then-`innerHTML`
with a fixed `main > div` fallback. Engines return JSON, content refs, or
external-CMS ids. Proposed: `renderDecision(el, decision)` (e.g. json2html,
external fetch) so every integrator doesn't re-implement the seam.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see this being useful indeed

Comment thread docs/rfcs/byo-decision-engine.md Outdated
Comment on lines +125 to +126
- Should audiences / campaigns / experiments converge behind one **decision
provider** abstraction, rather than three parallel mechanisms?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 1 provider

Comment thread docs/rfcs/byo-decision-engine.md Outdated
- API surface + backward compatibility for the hooks.
- Should audiences / campaigns / experiments converge behind one **decision
provider** abstraction, rather than three parallel mechanisms?
- Where should the reference worker live (this repo `examples/`, or a companion)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with examples/ for now

Comment thread docs/rfcs/byo-decision-engine.md Outdated
Comment on lines +49 to +60
2. **Multi-word audience/campaign names don't resolve at page level.**
`getAllMetadata` camelCases metadata keys, so `audience-returning-visitor`
becomes `returningVisitor`. Downstream, names are matched/looked up in
class-name form, and `toClassName('returningVisitor')` → `returningvisitor`
(the word boundary is lost) — which matches neither the `returning-visitor`
project config key nor the camelCase one. Because the camelCasing is lossy,
this **cannot be fixed downstream** — it needs the metadata reader to
preserve the audience/campaign *name* (section- and fragment-level already
keep class-name keys and work). Flagged here rather than patched blindly
because the right fix touches shared `getAllMetadata` behavior and deserves a
design decision. *(This is why BYO integrations must currently use
hyphen-free tokens like `ixptreatment`.)*

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should fix this cleanly across all 3 experiment, campaigns and audiences

ramboz added a commit that referenced this pull request Aug 12, 2026
## Problem

A **page-level** audience or campaign with a **multi-word name** (e.g.
`returning-visitor`, `black-friday`) resolves its *membership* but never
serves the variant — the experience is silently dropped.

Root cause: page-level metadata is read with `getAllMetadata`, which
**camelCases** keys (`audience-returning-visitor` → `returningVisitor`).
Names are then matched + looked up in **class-name** form, and
`toClassName('returningVisitor')` → `returningvisitor` (the word
boundary is lost), so it matches neither the project config key
(`returning-visitor`) nor the camelCase one. Section- and fragment-level
already key names by class-name, so they work — only page-level is
affected.

## Fix

- `getAllMetadata(scope, keyFn = toCamelCase)` — an optional key
transform. The default is unchanged, so **experiments keep their
camelCased config props** (`startDate`, `requiresConsent`, …).
- `applyAllModifications` passes `toClassName` for **audience/campaign**
page metadata (experiments keep `toCamelCase`), matching how section-
and fragment-level already read names.
- Config readers (`getAudienceConfig` / `getCampaignConfig`) are
**unchanged** — they already handle class-name keys.

## Tests

Adds page-level fixtures + tests for a multi-word audience and a
multi-word campaign name. They fail red before this change (the variant
isn't served) and pass after. **Full suite green — 101 tests, including
experiments — no regression.**

---

Found while building a bring-your-own-engine integration (see #64) —
this is bug #2 from that RFC. It removes the need for hyphen-free tokens
at page level.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- rename tracking hook to rumTracking; note the existing aem:experimentation DOM events for custom tracking
- resolve two open questions into Decisions: one decision provider; reference worker in examples/
- reframe bug #2 as a clean cross-cutting fix (audiences/campaigns class-name at every level; experiments keep camelCase, with the why) and credit #65

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ramboz

ramboz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Converting this RFC into a tracking epic + sub-issues so each enabler ships as its own PR, per the review discussion:

The two bugs this RFC flagged already landed as #63 and #65. Closing in favor of #66.

@ramboz ramboz closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant