Skip to content

feat(personalization): wire Intuit pzn + ixp onto aem-experimentation BYO hooks (frozen-API content model) - #756

Draft
ramboz wants to merge 17 commits into
mainfrom
pzn-exp-byo
Draft

feat(personalization): wire Intuit pzn + ixp onto aem-experimentation BYO hooks (frozen-API content model)#756
ramboz wants to merge 17 commits into
mainfrom
pzn-exp-byo

Conversation

@ramboz

@ramboz ramboz commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

What

Wires Intuit's personalization + experimentation onto the vendored aem-experimentation plugin's bring-your-own decision-engine hooks, in the frozen-API content model — Intuit's APIs return content (pznblock / IXP variation), so the hooks apply the engine's content rather than mapping a cell/arm to authored content.

It also folds two generic additions into the plugin itself (opt-in, no-op by default — these go upstream to adobe/aem-experimentation#73):

  • resolveDecisions hook + serveDecisions lane — consumes the contract's already-defined decisions facet ("engine returns content per slot"), which nothing in the plugin used before.
  • QA overrides invoke the BYO hooks?audience=/?experiment= now still call resolveAudiences/getAssignment for their resolution side-effect (the override still wins the plugin's own selection).

How it works

  • PZN — the decisions-manifest lane (plugin serveDecisions). A sheet declares { selector, placement }. The plugin reads it and calls resolveDecisions(entries, context) once; byo.js's hook batches a single POST /api/pzn (with the marketing-profile / ZoomInfo enrichment) and returns { selector → { url: pznblock } }. The plugin applies each via renderDecision (→ applyFragment). No cache/indirection — the engine's fragment is returned directly.
  • IXP — getAssignment (frozen). Native Experiment/Experiment Variants authoring gates the experiment (the variant href is a placeholder). getAssignment calls GET /api/ixp, reports the arm, and stashes the engine's variation (assetLocation); renderDecision applies the stash. The QA override now invokes getAssignment too, so content resolves under a forced arm (no lazy workaround needed).
  • Hook bodies reuse the project's own decision.js/attributes.js/pzn-response.js/ixp-response.js + pzn.js's getMarketingProfile, unchanged. scripts/pzn.js/exp.js are untouched (for the perf A/B).

Contents

  • Plugin (plugins/experimentation/, → media-text cards: migration-path cards drift from original in color, type scale, and spacing #73 upstream): resolveDecisions / serveDecisions / watchMutationsAndApplyDecisions + decisions-manifest metadata (additive, no-op by default); getResolvedAudiences / getExperimentConfig invoke the configured BYO hook under a QA override; getManifestEntriesForCurrentPage gains { requireUrl } (default preserves existing behavior); byo doc + README updated.
  • Project: scripts/personalization/byo.js — the hooks (PZN collapsed onto resolveDecisions, net −62 lines); scripts/experiment-loader.js (spreads the hooks; isExperimentationEnabled recognizes decisions-manifest); blocks/pzn-hero/pzn-offer; aem-martech transport-only (personalization: false); demos + pzn-manifest.json (now { selector, placement }).

Test it (local)

npx @adobe/aem-cli up --no-open --html-folder drafts
  • PZN: /drafts/pzn-cell-demo → hero + offer render the engine's pznblock (sticky per placement via pzn-mock-* cookies).
  • IXP: /drafts/ixp-demo?experiment=ixp-hero-test/challenger-1 → the engine's variation; /control → control; no param → sticky mock.

Demos are local-only (drafts aren't served by the .aem.page preview; a shareable demo needs DA authoring).

Measure perf

PZN = one batched /api/pzn (via resolveDecisions) + renderDecision swaps; IXP = one /api/ixp + a variant swap. Drop the pzn-mock/ixp-mock metas to hit the real engine (the hooks call /api/pzn,/api/ixp via the existing Akamai transport) on the same content as scripts/pzn.js/exp.js for the head-to-head.

Verification

  • npm run lint clean; project npm test 15 failed / 450 passed = main baseline (pre-existing testimonial-video*/lead-form-config).
  • Plugin's own Playwright suite 141/141.
  • Browser (both lanes): PZN renders the engine's pznblock via the new serveDecisions lane; IXP challenger/control render (challenger under the override confirms getAssignment runs); no console errors beyond the Intuit martech CORS baseline.

Caveats / follow-ups

🤖 Generated with Claude Code

ramboz and others added 7 commits August 19, 2026 18:07
Decisioning stays in Intuit's engine (scripts/pzn.js + scripts/exp.js); the opt-in ?martech=adobe path no longer applies AJO/Target propositions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
51da897 perf: run listAudiences catalog in lazy phase, not eager (no LCP impact)
b710d58 test: cover BYO hook error/edge branches for codecov
69381f3 test: fix flaky "RUM fired before redirect" experiment test
5590131 docs: document the bring-your-own decision engine hooks
6685ea0 feat: add reference auth-proxy worker under examples/ (#72)
b3f2c58 feat: add versioned client ⇄ engine decision contract + tests (#71)
f7043aa feat: add listAudiences catalog seam for the simulation panel
6b15918 feat: add getAssignment hook for external experiment assignment (#68)
d542487 feat: add resolveAudiences hook + remote resolver helper (#67)
d085fa4 feat: add renderDecision hook for pluggable decision application (#70)
8e9a1a1 feat: add rumTracking hook to disable or delegate built-in RUM (#69)
7245090 chore(release): 1.2.2 [skip ci]
3fa2579 fix: resolve multi-word audience/campaign names at page level (#65)
6d50ecd chore(release): 1.2.1 [skip ci]
6f7b3e8 fix: support a single audience/campaign per selector in fragment manifests (#63)

git-subtree-dir: plugins/experimentation
git-subtree-split: 51da897116b5f3bf86b7eace7c3c52a143678ebf
…entation's BYO manifest hooks

Adds scripts/personalization/byo.js implementing the plugin's bring-your-own
decision-engine contract (resolveAudiences/renderDecision/rumTracking) for the
manifest-driven, cell-resolution model: resolveAudiences delegates to a remote
engine via the vendored plugin's createRemoteAudienceResolver when a
`pzn-cell-endpoint` page metadata is set, otherwise falls back to a sticky
demo mock; renderDecision reuses decision.js's applyFragment so fragment
swaps stay consistent with the existing pzn.js/exp.js paths; rumTracking
records exposure onto window.appVars.

experiment-loader.js now dynamically imports byo.js and spreads its hooks
onto the config passed to the plugin's loadEager/loadLazy, opt-in and fail
open (falls back to the plugin's default no-BYO behavior if the hooks module
fails to load). This does not touch the existing data-pzn/data-exp/
personalization-id path.

The disable comments on the import/no-cycle lines in scripts.js and
experiment-loader.js are pre-existing, unavoidable cycle edges (same as
pzn.js/exp.js/decision.js/fragment.js already carry) that newly surface
because byo.js reuses the fragment-loading machinery; no behavior changed on
either line.
Minimal blocks (unwrap the single authored cell, no other decoration) that
give the audience-manifest cell-resolution mechanism a stable, targetable
selector (.pzn-hero / .pzn-offer) with real default content. Without a
matching blocks/<name>/<name>.js, the boilerplate's block loader 404s trying
to fetch one for any div-shaped block, which is avoidable console noise on
the demo page.
…fest wiring

drafts/pzn-cell-demo.html + drafts/pzn-manifest.json + drafts/fragments/pzn/*
exercise scripts/personalization/byo.js end to end via the plugin's own
audience-manifest mechanism only (no data-pzn/data-exp/personalization-id),
so it never touches the existing Intuit path. Manifest rows cover .pzn-hero
and .pzn-offer across the retail/hospitality/default cells.

Force-added: drafts/ and *.html are gitignored by default (see .gitignore,
"gitignore drafts/ and untrack sample pzn/exp fixtures"), same as the
existing drafts/parity-probe.html test fixture.

Verified locally with `aem up --html-folder drafts`: content is served under
a /drafts/ prefix (not site root), so the meta/manifest/fragment URLs are
authored with that prefix baked in. The `audience-manifest` value is authored
directly as a <head> meta tag rather than a Metadata block, because
--html-folder serves static files verbatim (no pipeline step lifts a
Metadata block into <head>, and .plain.html is not auto-derived from .html
— confirmed empirically, not assumed).
@aem-code-sync

aem-code-sync Bot commented Aug 20, 2026

Copy link
Copy Markdown

Hello, I'm the AEM Code Sync Bot and I will run some actions to deploy your branch and validate page speed.
In case there are problems, just click a checkbox below to rerun the respective action.

  • Re-run all PSI checks
  • Re-run failed PSI checks
  • Re-sync branch
Commits

ramboz added 2 commits August 20, 2026 15:00
…xperimentation's getAssignment hook

Adds the IXP lane alongside the existing cell-resolution lane in byo.js: the
engine now owns an experiment's arm via getAssignment (remote endpoint or a
sticky demo mock), while the plugin's native Experiment/Experiment Variants
authoring owns arm->content. renderDecision now branches on decision.scope so
page/section-scope swaps (which land before the page's own decoration pass)
splice in raw markup instead of pre-decorating it, avoiding a double-wrapped
.section that decorateBlocks was misreading as a "section" block.
…e BYO getAssignment wiring

Section-level native experiment (Section Metadata Experiment/Experiment
Variants, not a manifest sheet) with one challenger fragment, mirroring the
cell-resolution demo's structure. Section-level was chosen over page-level
because a hand-authored page Metadata block isn't converted into <head> <meta>
tags when served locally via --html-folder, whereas .section-metadata is read
straight off the DOM.
@ramboz ramboz changed the title feat(personalization): cell-resolution wiring onto aem-experimentation BYO manifest hooks feat(personalization): cell-resolution + experiment-assignment wiring onto aem-experimentation BYO hooks Aug 20, 2026
… model

Intuit's DE/PZN and IXP endpoints return CONTENT (a fragment ref, or a
variation asset/redirect path), not a cell/arm name for authoring to map.
Flip byo.js accordingly: resolveAudiences/getAssignment now make the real
batched call and cache what the engine hands back; renderDecision applies
that cached content instead of decision.url verbatim (which is now only a
placement-id key or a placeholder). The manifest sheet and Experiment
Variants authoring keep owning discovery, not content.

Demo mocks (pzn-mock/ixp-mock) stand in for the real endpoint only when it's
absent, and are shaped exactly like the real payloads so the demos exercise
the actual pzn-response.js/ixp-response.js parse path. Also closes a gap
where the plugin's own ?audience=/?experiment=<id>/<arm> QA overrides bypass
resolveAudiences/getAssignment entirely: renderDecision now lazily resolves
in that case too, so a forced arm still shows real engine content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ramboz ramboz changed the title feat(personalization): cell-resolution + experiment-assignment wiring onto aem-experimentation BYO hooks feat(personalization): wire Intuit pzn + ixp onto aem-experimentation BYO hooks (frozen-API content model) Aug 20, 2026
… profile

resolvePznPlacementCache now awaits getMarketingProfile() and passes the ZoomInfo firmographics into buildBatchBody, matching scripts/pzn.js so the engine decides on the same attributes on both the plugin and bespoke paths. Memoized + fail-open upstream, so no extra network call and a miss sends the batch unenriched.

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

A BYO decision engine that returns content per slot had no first-class lane
in the vendored plugin, and the ?audience=/?experiment= QA overrides bypassed
the configured resolveAudiences/getAssignment hooks entirely, so a
content-returning engine had nothing to render under them. Adds an opt-in,
no-op-by-default resolveDecisions/decisions-manifest lane, fixes the
overrides to still invoke the configured hook for its resolution side-effect
while still winning the plugin's own selection, and collapses the project's
PZN audience-manifest scaffolding in byo.js onto the new hook (dropping the
now-unnecessary lazy IXP-content fallback).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ration

The vendored aem-experimentation plugin's BYO decision-engine hooks
(scripts/personalization/byo.js) now own section/block discovery + apply for
both PZN (decisions-manifest -> resolveDecisions) and IXP (native Experiment
blocks -> getAssignment), making the old bespoke path redundant:

- scripts/scripts.js: remove runExperienceLayer and its loadEager/loadLazy
  call sites.
- scripts/pzn.js: remove collectSlots, runPersonalization, sameTargetAsExp.
- scripts/exp.js: remove collectExperiments, runBlockExperiments.

Page-level pzn/exp (personalization-id / experiment-id metadata ->
runPersonalizationPage / runExperiment) is not covered by the plugin yet, so
it's unchanged.

Extract getMarketingProfile (+ its cookie/cache helpers) out of pzn.js into a
new scripts/personalization/marketing-profile.js so both the kept page-level
path and byo.js's resolveDecisions share it without pzn.js<->byo.js coupling.

Section/block click attribution (data-pzn/data-experiment stamping,
window.appVars records) is an intentional, documented gap on this path for
now - it will be covered by the upcoming Option B region-keyed tracking
registry rather than DOM data-attributes at rest. Page-level attribution is
unaffected.

Tests updated/moved to match: marketing-profile tests moved to their own
file; tests for the removed section/block functions are dropped (no
replacement, per the above); page-level test coverage is untouched.
…ntext registry

Two independent cleanups to the BYO decision-engine hooks:

- Move the PZN/IXP demo mocks (candidates, cookie helpers, forced-arm QA
  override reader) out of byo.js into a new byo-mock.js. byo.js now reaches
  it via a dynamic import() only after a real fetchDecision() call fails AND
  the page opts in via pzn-mock/ixp-mock metadata, so the mock module never
  even loads on a production page (verified via the network waterfall: the
  fetch for byo-mock.js only fires after the /api/pzn or /api/ixp miss).

- Replace the section/block click-attribution "KNOWN GAP" with a region-
  context registry (scripts/personalization/tracking-context.js):
  registerRegionContext/resolveRegionContext over a window-backed WeakMap
  (window.__pznTrackingContext), not DOM data-attributes. resolveDecisions
  and getAssignment now capture each region's PZN/IXP identity, and
  renderDecision publishes it onto the applied element for the click-
  tracking runtime (Option B, clicktrack-optionb branch) to resolve at
  interaction time.

Verified: npm run lint clean; vitest unchanged at 15 failed/414 passed
(pre-existing, unrelated testimonial-video failures); both drafts demos
render via the lazy-loaded mocks with zero data-pzn/data-experiment
attributes at rest, and window.__pznTrackingContext resolves the expected
context for both the PZN hero/offer slots and the IXP section.

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

Intuit's real authoring writes data-pzn / data-exp (+ -block) attributes
directly on a section (see experience-workspace/skills/
add-personalization-experimentation.md), not the demo's decisions-manifest
sheet — so the plugin's own serveDecisions never sees them and those pages
never personalize. Add scripts/personalization/discover.js, a thin
discovery+dispatch layer that finds these attributes and feeds them to
byo.js's existing resolveDecisions/getAssignment/renderDecision hooks
unchanged (they already register the served identity into
window.__pznTrackingContext for click attribution — no DOM stamping, no
analytics.js call). The decisions-manifest lane keeps working as-is; this is
a second, independent discovery source into the same hooks.

PZN and IXP need opposite timing relative to scripts.js's decorateMain,
because byo.js applies them differently: a PZN decision uses applyFragment
(self-decorating, so it's only safe to splice in AFTER decorateMain — the
plugin's own decisions-manifest lane relies on this same ordering), while an
IXP section/page decision uses applyRawFragment (a deliberately undecorated
swap, documented in byo.js as relying on the page's own decorateMain running
afterward — the same contract the plugin's native Experiment-block dispatch
and the page-level exp.js/pzn.js swaps already rely on). So:
- runAuthoredPersonalization (PZN) keeps the deleted runExperienceLayer's
  eager-first-section / lazy-rest phase split, called after decorateMain.
- runAuthoredExperiments (IXP) runs once, before decorateMain, for the whole
  page — decorateMain never runs a second time, so there's no later
  pre-decoration moment to defer a below-the-fold data-exp swap into.

Verified locally (aem up --html-folder drafts) against a new
drafts/pzn-authored-demo.html carrying real data-pzn/data-pzn-block/data-exp
sections: whole-section and block-scoped PZN swaps land correctly (sibling
prose outside the block scope is left untouched), the IXP section swap
renders the engine's challenger content, and a section carrying both tags
correctly drops PZN in favor of IXP. Confirmed no new per-visitor data-*
attributes appear anywhere on the page, and that
resolveRegionContext(cta)/window.__pznTrackingContext resolve the served
identity for both lanes. The existing decisions-manifest and native-IXP
demos (pzn-cell-demo.html, ixp-demo.html) still render unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n/data-exp

The aem.live pipeline serves Section Metadata as a raw `.section-metadata`
block, not as attributes; this project runs a trimmed decorateSections that
(unlike stock aem.js) never converted it. So section-level data-pzn/data-exp
were never produced and the authored pzn/exp discovery lane found nothing on
real content — only the literal-attribute demo appeared to work.

Add scripts/personalization/section-metadata.js (decorateSectionMetadata):
lifts the pzn/exp Section Metadata rows to data-* on the owning section and
removes the consumed block, scoped to the pzn/exp keys so section backgrounds
and the plugin's native Experiment blocks are left untouched. scripts.js runs
it in loadEager before the IXP lane and decorateMain, so discover.js and the
existing lane timing work unchanged.

Demo now authors real .section-metadata blocks (converted client-side, so it
works under `aem up` with no pipeline step). Corrects the authoring skill's
"pipeline emits data-*" claim. 10 unit tests; browser-verified all four cases
(whole-section pzn, block-scoped pzn, section exp, IXP-wins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tures, trim comments

- Inline decorateSectionMetadata into scripts.js (was its own module). A static import
  on the critical path is a needless LCP waterfall, and it's scripts.js-only, not a
  shared lib — so it doesn't earn a file.
- Untrack the pzn/exp demo fixtures (drafts/* pzn|ixp demos, blocks/pzn-hero,
  blocks/pzn-offer); kept on disk via .gitignore for local dev. They were never the PR's
  preview URL anyway (drafts aren't served by the .aem.page preview).
- Trim the verbose comments across byo.js / discover.js / tracking-context.js /
  marketing-profile.js / experiment-loader.js / scripts.js. These ship unminified, so
  comment bytes are wire bytes; load-bearing "why" notes kept, condensed.
- Drop the section-metadata unit test: inlined, it can't be imported without triggering
  scripts.js's top-level loadPage(); the 10-line fn stays browser-verified.

Lint clean; suite 15 fail/440 pass = baseline. Browser re-verified all four authored
cases render + the region-context registry populated.

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

amol-anand commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

@ramboz question about this ->

A sheet declares { selector, placement }

currently, the way we will know whether something needs to be replaced is based on page metadata or section metadata (or additional prop in section meta about block selector). Not from a sheet. So would want to make sure we can support this approach while rolling the changes into the OOTB plugin.

@amol-anand

Copy link
Copy Markdown
Contributor

Also, there's not worker doing anything right now. calls are made to /api/zoominfo, /api/ixp or /api/pzn (and in the future it will be consolidated into one) and wired via Akamai instead of the edge worker.

@ramboz

ramboz commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@amol-anand The plugin supports 3 layers of pzn/xp: page, section and "block" (but essentially any CSS selector).
The sheet is just used for the latter case so we can add all the metadata we need for a given "selector".

The worker you see if just cleanup I still need to do in the plugin itself. I had a dummy proxy worker as documentation to show how to route to an internal service, but it would not be used here.

Also since you have already a working version, I'd probably park the cleanup and changes to the experimentation pluging as post go-live.

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.

2 participants