Skip to content

feat: bring-your-own decision engine — first-class support (#66) - #73

Open
ramboz wants to merge 12 commits into
mainfrom
claude/aem-experimentation-issue-66-d07654
Open

feat: bring-your-own decision engine — first-class support (#66)#73
ramboz wants to merge 12 commits into
mainfrom
claude/aem-experimentation-issue-66-d07654

Conversation

@ramboz

@ramboz ramboz commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Implements the "bring your own decision engine" (BYO) epic from #66: a set of opt-in, no-op-by-default hooks that let the plugin apply an external engine's decisions while the engine keeps ownership of segmentation, bucketing and exposure — plus the reference material to wire it up.

Built as one branch per the plan agreed on the issue: independent hooks first (convergence to a single provider left as a later internal refactor), with the contract and worker synthesized from the RFC.

Enablers

Issue What
#69 rumTracking: 'off' | fn — disable or delegate the built-in RUM exposure tracking
#70 renderDecision(el, decision) — pluggable decision application (JSON / content ref / DOM patch)
#67 resolveAudiences(names, context) — one batched, context-aware resolution + bundled createRemoteAudienceResolver helper (memoized, timeout→control)
#68 getAssignment(experimentId, context) — external experiment assignment (skip randomization; ?experiment= still wins)
#71 Versioned client ⇄ engine decision contract + dependency-free validators + contract tests (src/contract.js)
#72 Reference auth-proxy worker (examples/auth-proxy-worker/) — Cloudflare entry + framework-neutral core + standalone stub
listAudiences() catalog seam (from the #66 comment) — advertises the audience universe to the simulation panel (enumeration, distinct from #67's membership resolution)

Docs: documentation/byo-decision-engine.md, linked from the README with the hooks listed in the config reference.

Guarantees

  • Every hook is opt-in and a no-op when unset — zero behavior change for existing projects. Verified by re-running the existing audiences / campaigns / experiments / simulation suites after each seam.
  • +31 tests; full suite green (134/134), npm run lint clean.
  • Each enabler is its own commit, so they can still be split into per-issue PRs if you prefer.

Also

  • A test: commit fixes a pre-existing flaky test (experiments.test.js — "RUM fired before redirect", a redirect navigation race). Independent of this work; it failed ~85% under --repeat-each before and passes 40/40 after.

Deferred (by design)

The RFC's "converge on one decision provider" is intentionally left as the later internal refactor — the four seams now exist for it to compose.

Closes #67, #68, #69, #70, #71, #72. Advances #66.

🤖 Generated with Claude Code

ramboz and others added 9 commits August 12, 2026 13:59
Opt-in `rumTracking` plugin option so a bring-your-own decision engine that
already fires exposure server-side can avoid double counting:

- `'off'` suppresses the built-in RUM for all decision types
- a function receives `{ type, source, target }` instead of the built-in call
- unset keeps today's behavior (no change)

Also wires a `window.PLUGIN_OPTIONS` passthrough into the test harness so
fixtures can exercise the BYO config hooks, and documents the hook set in
DEFAULT_OPTIONS.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opt-in `renderDecision(el, decision)` plugin option. When provided, the plugin
hands the target element and the normalized decision to the integrator instead
of the built-in fetch-URL → innerHTML, so an engine that returns JSON, a content
reference or an external-CMS id can apply the decision however it is shaped.

The decision is `{ type, scope, url, selector?, config }`. Routed through a new
`applyDecision` helper at both application sites (page/section and fragment); the
default fetch-and-replace behavior is unchanged when the hook is unset.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opt-in `resolveAudiences(names, context)` plugin option: a batched,
context-aware resolver that answers every configured audience in a single call
(with a shared `{ url, consent }` context) instead of N argument-less
per-audience calls. It short-circuits the per-audience `audiences[key]()` path,
works without a per-audience registry, and a rejection falls back to control.

Also exports `createRemoteAudienceResolver({ endpoint, timeout })` — a memoized
remote resolver (one request per page) with a timeout → control fallback so a
slow or failing engine never blocks render — and a shared `getDecisionContext`.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opt-in `getAssignment(experimentId, context)` plugin option. When it returns a
variant, the plugin serves that arm and skips its own client-side randomization
(`ued.evaluateDecisionPolicy`); sticky assignment and exposure stay the engine's
responsibility.

- a known variant is served as-is (no re-bucketing)
- a falsy answer or an error falls back to the plugin's self-bucketing
- an unknown variant serves control rather than re-randomizing against intent
- the `?experiment=` QA override still wins
- unset keeps today's self-bucketing behavior

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opt-in async `listAudiences()` that advertises an engine's audience universe to
the AEM Sidekick simulation panel at author time. In preview/dev the returned
names are merged into `body[data-audiences]` (which the panel enumerates its
switcher from), so a BYO project that registers only a generic remote resolver
can still surface the engine's segments — enumeration, distinct from the
membership resolution in #67. Author-time only; a no-op by default and never
fetched in production.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New `src/contract.js` pins down the normalized shapes exchanged between the
client hooks and an engine/worker — decision context, audience resolution,
experiment assignment, and rendered decision — under a single versioned
`DecisionResponse` envelope, with dependency-free validators usable on both
ends (browser + edge/Node). Shared JSON fixtures and contract tests validate
the shapes and exercise the client resolver against a contract-shaped response.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reference edge worker for a bring-your-own decision engine integration:
- src/core.js — framework-neutral core (plain data in/out; ports to any runtime)
- src/cloudflare.js — Cloudflare Worker entry adapting the core
- src/engine-stub.js — a deterministic fake engine so it runs standalone
- wrangler.toml / package.json — config + wrangler dev/deploy scripts
- README.md — setup, wiring to a real engine, and to the client resolver hook

It hides the engine API key server-side, reads/mints a first-party HttpOnly
visitor-id cookie, and emits decisions conforming to the versioned contract
(#71). A Node-side test asserts conformance, sticky membership, and that the
key never leaks to the client.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add documentation/byo-decision-engine.md covering all the BYO hooks
(resolveAudiences + helper, getAssignment, rumTracking, renderDecision,
listAudiences), the decision context, the versioned contract, and the
reference worker. Link it from the README and list the hooks in the config
reference.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The test raced the redirect two ways: it (a) asserted rumCalls[0] after only
waiting for sampleRUM to *exist* (set in addInitScript) rather than for the
exposure to actually fire, and (b) read body.innerText mid-navigation, which
destroyed the execution context.

Wait for the recorded RUM call (expect.poll), derive the expected landing state
from the RUM target, and wait for the redirect to settle before asserting
content. Pre-existing flake, independent of the BYO work.

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

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.24521% with 62 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/index.js 70.89% 55 Missing and 7 partials ⚠️

📢 Thoughts on this report? Let us know!

ramboz and others added 2 commits August 12, 2026 16:15
Add tests exercising the error and edge branches of the new BYO code that the
happy-path tests missed (flagged by codecov patch coverage):
- renderDecision throwing -> control
- getAssignment returning an unknown variant -> control, and throwing -> self-bucket
- listAudiences absent-in-preview / empty / throwing
- isDecisionResponse rejecting non-objects and invalid assignment/decision facets

contract.js is now 100% covered; every new src line in the PR is exercised.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The catalog only feeds the simulation panel's audience switcher, which loads
lazily, so awaiting listAudiences() in loadEager needlessly blocked the
eager/LCP path. Move it to loadLazy and make it fire-and-forget so a slow
catalog can't stall panel setup either. Rewrite the catalog tests to drive the
real loadLazy (the simulation.test.js pattern) and drop the now-unused
loadEager-based fixtures.

Part of #66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…A overrides

resolveDecisions(entries, context) => { [selector]: { url } } is a first-class 'engine returns content per slot' lane, consuming the decisions facet already defined in src/contract.js. A decisions-manifest page metadata declares the slots (selectors); the plugin reads it, calls resolveDecisions once with all entries, and applies each returned { url } via renderDecision. Opt-in and a no-op unless both resolveDecisions and a decisions-manifest are configured; getManifestEntriesForCurrentPage gains an opt-in { requireUrl } (default keeps existing behavior).

Separately, the ?audience= / ?experiment= QA overrides now still invoke the configured resolveAudiences / getAssignment for their resolution side-effect — the override still wins the plugin's own selection, but a content-returning BYO engine has run and has content to render under a forced selection.

Both additive and no-op by default; existing audience/experiment/campaign lanes are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

resolveAudiences: batched, context-aware audience resolution

1 participant