@gradial/aci 0.1.52-rc.21 → 0.1.52-rc.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +79 -5
  2. package/dist/astro/index.js +4 -0
  3. package/dist/audience-data-models.d.ts +12 -0
  4. package/dist/audience-data-models.js +3 -0
  5. package/dist/cli/mintlify-render.d.mts +1 -0
  6. package/dist/cli/mintlify-render.mjs +8 -0
  7. package/dist/collect/aci.js +1 -1
  8. package/dist/collect/aci.js.sha256 +1 -1
  9. package/dist/collect/api.d.ts +3 -0
  10. package/dist/collect/api.js +19 -0
  11. package/dist/collect/assignments.d.ts +7 -9
  12. package/dist/collect/assignments.js +23 -32
  13. package/dist/collect/batcher.d.ts +3 -2
  14. package/dist/collect/consent/envelope.d.ts +7 -1
  15. package/dist/collect/consent/envelope.js +3 -1
  16. package/dist/collect/consent/mapping.d.ts +2 -0
  17. package/dist/collect/consent/mapping.js +1 -0
  18. package/dist/collect/consenthold.d.ts +38 -0
  19. package/dist/collect/consenthold.js +82 -0
  20. package/dist/collect/declarative.js +7 -5
  21. package/dist/collect/gapbuffer.d.ts +13 -4
  22. package/dist/collect/gapbuffer.js +10 -4
  23. package/dist/collect/index.js +66 -19
  24. package/dist/collect/pageviews.d.ts +11 -11
  25. package/dist/collect/pageviews.js +27 -22
  26. package/dist/consent-banner/index.d.ts +17 -0
  27. package/dist/consent-banner/index.js +23 -0
  28. package/dist/content/provider.d.ts +8 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/internal/domain/edge/identity.d.ts +5 -0
  31. package/dist/internal/domain/edge/identity.js +49 -13
  32. package/dist/internal/domain/edge/mimic_token.d.ts +1 -0
  33. package/dist/internal/domain/edge/mimic_token.js +74 -7
  34. package/dist/internal/domain/edge/personalization-core.d.ts +8 -33
  35. package/dist/internal/domain/edge/personalization-core.js +151 -195
  36. package/dist/internal/domain/edge/personalization_index.d.ts +17 -0
  37. package/dist/internal/domain/edge/personalization_index.js +56 -0
  38. package/dist/internal/domain/edge/record/codec.d.ts +1 -30
  39. package/dist/internal/domain/edge/record/codec.js +1 -156
  40. package/dist/internal/domain/edge/resolve.js +55 -38
  41. package/dist/internal/domain/edge/types.d.ts +41 -32
  42. package/dist/internal/domain/edge/variant_key.d.ts +3 -0
  43. package/dist/internal/domain/edge/variant_key.js +44 -0
  44. package/dist/internal/platform/collectionstore/errors.d.ts +13 -0
  45. package/dist/internal/platform/collectionstore/errors.js +28 -0
  46. package/dist/mintlify/blocks.d.ts +6 -0
  47. package/dist/mintlify/blocks.js +198 -0
  48. package/dist/mintlify/config.d.ts +3 -0
  49. package/dist/mintlify/config.js +77 -0
  50. package/dist/mintlify/index.d.ts +3 -0
  51. package/dist/mintlify/index.js +2 -0
  52. package/dist/mintlify/json.d.ts +8 -0
  53. package/dist/mintlify/json.js +37 -0
  54. package/dist/mintlify/materialize.d.ts +12 -0
  55. package/dist/mintlify/materialize.js +159 -0
  56. package/dist/mintlify/types.d.ts +69 -0
  57. package/dist/mintlify/types.js +1 -0
  58. package/dist/mintlify/validate.d.ts +7 -0
  59. package/dist/mintlify/validate.js +252 -0
  60. package/dist/next/assignments-navigation.d.ts +4 -0
  61. package/dist/next/assignments-navigation.js +18 -0
  62. package/dist/next/assignments-script.d.ts +3 -2
  63. package/dist/next/assignments-script.js +5 -5
  64. package/dist/next/error-seat.js +3 -0
  65. package/dist/next/index.d.ts +1 -0
  66. package/dist/next/index.js +3 -0
  67. package/dist/next/render.js +8 -1
  68. package/dist/react/VideoPlayer.d.ts +1 -1
  69. package/dist/react/index.d.ts +1 -1
  70. package/dist/registry.d.ts +4 -2
  71. package/dist/testing/conformance/checks.d.ts +2 -2
  72. package/dist/testing/conformance/checks.js +2 -0
  73. package/dist/testing/conformance/index.d.ts +1 -0
  74. package/dist/testing/conformance/index.js +1 -0
  75. package/dist/testing/conformance/next-stubs.d.ts +6 -0
  76. package/dist/testing/conformance/next-stubs.js +9 -0
  77. package/dist/testing/conformance/preset.d.ts +3 -2
  78. package/dist/testing/conformance/preset.js +12 -8
  79. package/dist/testing/conformance/root-identity.d.ts +31 -0
  80. package/dist/testing/conformance/root-identity.js +380 -0
  81. package/dist/testing/conformance/stubs/next-image.d.ts +7 -0
  82. package/dist/testing/conformance/stubs/next-image.js +5 -0
  83. package/dist/testing/conformance/stubs/next-link.d.ts +7 -0
  84. package/dist/testing/conformance/stubs/next-link.js +4 -0
  85. package/dist/testing/conformance/types.d.ts +2 -2
  86. package/dist/testing/index.js +2 -2
  87. package/dist/types/component.d.ts +9 -0
  88. package/package.json +11 -7
  89. package/src/cli/mintlify-render.mjs +9 -0
  90. package/src/types/component.ts +10 -0
  91. package/templates/astro/starter-content/src/components/chrome/Footer.astro +5 -3
  92. package/templates/astro/starter-content/src/components/chrome/Header.astro +5 -3
  93. package/templates/astro/starter-content/src/components/elements/Button.astro +5 -2
  94. package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +5 -3
  95. package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -3
  96. package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -3
  97. package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -3
  98. package/templates/astro/starter-content/src/components/sections/HomeHero.astro +5 -3
  99. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -3
  100. package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -3
  101. package/templates/astro/starter-content/src/fixtures/sections.ts +1 -1
  102. package/templates/astro/starter-content/src/stories/CompositionStoryFrame.astro +7 -2
  103. package/templates/astro/template/package.json.tmpl +4 -4
  104. package/templates/common/.agents/skills/aci/SKILL.md +50 -0
  105. package/templates/common/.agents/skills/authoring/SKILL.md +4 -3
  106. package/templates/common/.agents/skills/authoring/references/block-registry.md +15 -0
  107. package/templates/common/.agents/skills/migration/references/selector-map.md +1 -1
  108. package/templates/common/.content/schemas/site.schema.json +11 -1
  109. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/asset.json +1 -1
  110. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/asset.json +1 -1
  111. package/templates/common/starter-content/.content/pages/404/_index.json +1 -1
  112. package/templates/common/starter-content/.content/pages/{home/_index.json → _index.json} +2 -2
  113. package/templates/common/starter-content/.content/pages/about/_index.json +1 -1
  114. package/templates/common/starter-content/.content/pages/error/_index.json +1 -1
  115. package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
  116. package/templates/common/starter-content/.content/patterns/components/container.json +2 -2
  117. package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
  118. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +2 -2
  119. package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
  120. package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
  121. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
  122. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
  123. package/templates/common/starter-content/.content/patterns/layouts/default.json +2 -2
  124. package/templates/common/starter-content/.content/schemas/site.schema.json +11 -1
  125. package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +4 -3
  126. package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +4 -3
  127. package/templates/nextjs/starter-content/src/components/elements/Button.tsx +4 -3
  128. package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +4 -3
  129. package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +4 -2
  130. package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +4 -3
  131. package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +4 -2
  132. package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +4 -3
  133. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +4 -2
  134. package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +4 -3
  135. package/templates/astro/starter-content/src/render/RenderPage.astro +0 -67
  136. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +0 -42
  137. package/templates/nextjs/starter-content/src/components/elements/AtomicBlock.tsx +0 -13
  138. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/editorial.json +0 -0
  139. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/governance.json +0 -0
  140. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/versions/v0001.json +0 -0
  141. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/editorial.json +0 -0
  142. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/governance.json +0 -0
  143. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/versions/v0001.json +0 -0
@@ -1,89 +1,182 @@
1
1
  // Runtime-neutral personalization selection. The backend owns this logic
2
- // and pushes compiled artifacts to each host (CloudFront Function, Vercel
2
+ // and pushes compiled artifacts to each host (Lambda@Edge, Vercel
3
3
  // middleware, Cloudflare Worker). Keep this file free of platform APIs.
4
4
  //
5
5
  // This is the pure decision algorithm — no I/O, no cookies, no identity
6
6
  // minting. The caller resolves identity and claims, then calls this.
7
7
  import { variantValuesFromKey } from './record/codec.js';
8
- export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
8
+ import { canonicalVariantKey } from './variant_key.js';
9
+ import { personalizationSelection } from './personalization_index.js';
10
+ export { preparePersonalizationRoute } from './personalization_index.js';
11
+ export class PersonalizationResolutionError extends Error {
12
+ variantKey;
13
+ code = 'personalization.tuple_missing';
14
+ constructor(variantKey) {
15
+ super(`materialized personalization tuple ${variantKey} is absent from the route projection`);
16
+ this.variantKey = variantKey;
17
+ this.name = 'PersonalizationResolutionError';
18
+ }
19
+ }
20
+ export function resolvePersonalizationDecision(request, route, claims, experiments, identity, forcedArms) {
9
21
  if (!route)
10
- return { variant: null, experiment: null };
11
- let variant = selectRuleVariant(request, route, claims, experiments);
12
- let experiment = null;
13
- if (identity && identity.usable && route.variants && experiments) {
14
- const candidates = Object.values(experiments)
15
- .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
16
- .filter((item) => routeHasExperiment(route, item.id))
17
- .filter((item) => experimentEligible(item, claims))
18
- .sort((left, right) => (right.priority || 0) - (left.priority || 0)
19
- || String(left.id || '').localeCompare(String(right.id || '')));
20
- for (let index = 0; index < candidates.length; index++) {
21
- const item = candidates[index];
22
- const assignment = assignExperiment(item, identity);
23
- const candidate = assignment ? route.variants[assignment.variantKey] : undefined;
24
- if (!candidate)
22
+ return { variant: null, experiments: [] };
23
+ const selection = personalizationSelection(route, experiments);
24
+ const legacyExperimentExclusions = selection.legacyExperimentExclusions;
25
+ const authored = selectRuleVariant(request, selection, claims);
26
+ const values = { ...(authored ? authored.variantValues || variantValuesFromKey(authored.variantKey) : {}) };
27
+ // The leg selectRuleVariant chose is the selected-mode predicate's only
28
+ // input (ADR 0050, edge-functions I-16): "" when no leg won — an organic
29
+ // visit admits no selected-mode experiment.
30
+ const selectedLegAudienceId = String(values.audience || '');
31
+ const assignments = [];
32
+ const rejected = [];
33
+ const attempted = { ...values };
34
+ // Experiment-arm forcing (audience-mimic design/09, edge-functions I-17):
35
+ // an arms-bearing mimic token runs the experiment tier with the token's
36
+ // assertion replacing the dice for each experiment the request selects
37
+ // through the ordinary gate. Selection, eligibility, and arm-rule
38
+ // evaluation are the ordinary path — only allocation is replaced. No
39
+ // identity exists under mimic, so experiments the token does not name are
40
+ // skipped, and unselected experiments' forces are inert.
41
+ const forcing = !!forcedArms && Object.keys(forcedArms).length > 0;
42
+ if ((identity?.usable || forcing) && experiments) {
43
+ // The immutable route projection is the authority for scope. Missing
44
+ // qualified entries are corruption, never evidence to omit an experiment.
45
+ for (const item of selection.experiments) {
46
+ const id = item.id;
47
+ if (!experimentEligible(item, claims, selectedLegAudienceId))
25
48
  continue;
26
- if (assignment && !candidate.variantKey)
27
- candidate.variantKey = assignment.variantKey;
28
- // A bucket assignment is candidacy, not entitlement: an arm variant
29
- // carrying a rule must pass the same serve-time evaluation the rule
30
- // leg applies (ADR 0040). A rejection fires the onRuleRejected sink
31
- // inside evaluateRule; the candidate is skipped — never served, and
32
- // never left behind as a stale experiment decision.
33
- if (candidate.rule && !evaluateRule(request, candidate.rule, claims, candidate.variantKey))
49
+ const assignment = forcing
50
+ ? forceExperimentArm(item, forcedArms)
51
+ : assignExperiment(item, identity);
52
+ if (!assignment)
34
53
  continue;
35
- if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
36
- variant = candidate;
37
- experiment = assignment;
54
+ attempted[`experiment.${id}`] = assignment.armId;
55
+ if (item.eligibilityMode !== 'selected') {
56
+ const candidate = selection.arms[id]?.[assignment.armId];
57
+ // Under forcing an absent single-factor entry is publish/mint skew,
58
+ // not corruption (D-12 forbids the error page on the live origin):
59
+ // the factor degrades to absent and the plain mimic view renders.
60
+ if (!candidate) {
61
+ if (!forcing)
62
+ throw new PersonalizationResolutionError(assignment.variantKey);
63
+ continue;
64
+ }
65
+ const factorEvents = [];
66
+ const factorRequest = { ...request, onRuleRejected: (event) => factorEvents.push(event) };
67
+ if (candidate.rule && !evaluateRule(factorRequest, candidate.rule, claims, assignment.variantKey)) {
68
+ if (!factorEvents.length)
69
+ factorEvents.push({ variantKey: assignment.variantKey, diagnosticCode: 'expression.not_matched', diagnosticPath: 'expression', diagnosticCount: 1 });
70
+ for (const event of factorEvents)
71
+ rejected.push({ ...event, experimentId: id, armId: assignment.armId });
72
+ continue;
73
+ }
74
+ }
75
+ // A selected-mode experiment carries no arm rules (experiment I-18) and
76
+ // its arms materialize only composed with the selected leg, so there is
77
+ // no single-factor arm entry to look up; the composed-tuple resolution
78
+ // below stays the fail-closed corruption guard.
79
+ assignments.push(assignment);
80
+ values[`experiment.${id}`] = assignment.armId;
81
+ }
82
+ }
83
+ const attemptedKey = canonicalVariantKey(attempted);
84
+ for (const event of rejected)
85
+ request.onRuleRejected?.({ ...event, variantKey: attemptedKey });
86
+ let variant = authored;
87
+ if (assignments.length) {
88
+ const key = canonicalVariantKey(values);
89
+ const selected = selection.tuples[key];
90
+ // Under forcing a forced tuple absent from the projection (publish/mint
91
+ // skew) degrades every forced factor to absent — the plain mimic view.
92
+ // The fail-closed named error applies only to non-mimic traffic (I-17).
93
+ if (!selected && !forcing)
94
+ throw new PersonalizationResolutionError(key);
95
+ if (selected) {
96
+ variant = { ...selected, variantKey: key };
97
+ }
98
+ else {
99
+ assignments.length = 0;
100
+ for (const name of Object.keys(values)) {
101
+ if (name.startsWith('experiment.'))
102
+ delete values[name];
38
103
  }
39
- break;
40
104
  }
41
105
  }
42
- return { variant: variant || null, experiment };
106
+ return { variant, experiments: assignments, ...(legacyExperimentExclusions ? { legacyExperimentExclusions } : {}) };
43
107
  }
44
- function selectRuleVariant(request, route, claims, experiments) {
45
- if (!route.variants)
108
+ // forceExperimentArm resolves the token's named arm for one selected
109
+ // experiment instead of bucketing (design/09). Null when the token does not
110
+ // name this experiment (unforced experiments never run under mimic — no
111
+ // identity exists) or names an arm the active projection does not carry
112
+ // (skew degrades to the plain mimic view, never an error).
113
+ function forceExperimentArm(experiment, forcedArms) {
114
+ const armId = forcedArms[experiment.id];
115
+ if (armId === undefined)
46
116
  return null;
47
- const matches = [];
48
- for (const key in route.variants) {
49
- const variant = route.variants[key];
50
- if (!variant.variantKey)
51
- variant.variantKey = key;
52
- const values = variant.variantValues || variantValuesFromKey(key);
53
- const experimentID = String(values.experiment || '').trim();
54
- if (experimentID && (!experiments || !experiments[experimentID]
55
- || experiments[experimentID].status !== 'running'
56
- || !experimentEligible(experiments[experimentID], claims)))
117
+ const arm = (experiment.arms || []).find((candidate) => candidate.id === armId);
118
+ if (!arm)
119
+ return null;
120
+ return {
121
+ experimentId: experiment.id,
122
+ armId,
123
+ allocationVersion: String(experiment.allocationVersion || ''),
124
+ variantKey: `experiment.${experiment.id}=${armId}`,
125
+ // The dice never rolled; -1 marks the forced origin in the decision
126
+ // record where a bucket would otherwise sit.
127
+ bucket: -1,
128
+ forced: true,
129
+ };
130
+ }
131
+ function selectRuleVariant(request, selection, claims) {
132
+ let best = null;
133
+ for (const candidate of selection.authored) {
134
+ const { variant, values, key } = candidate;
135
+ if (candidate.experiments.some((item) => !experimentEligible(item, claims, String(values.audience || ''))))
57
136
  continue;
58
137
  if (!requestDimensionsMatch(request, values))
59
138
  continue;
60
139
  const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
61
140
  || (values.segment && claims && (claims.seg || []).includes(values.segment));
62
- const ruleMatch = variant.rule ? evaluateRule(request, variant.rule, claims, variant.variantKey) : false;
63
- if (membershipMatch || ruleMatch)
64
- matches.push(variant);
141
+ const ruleMatch = variant.rule ? evaluateRule(request, variant.rule, claims, key) : false;
142
+ if (!best && (membershipMatch || ruleMatch))
143
+ best = { ...variant, variantKey: key, variantValues: values };
65
144
  }
66
- matches.sort((left, right) => (right.priority || 0) - (left.priority || 0)
67
- || String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
68
- return matches[0] || null;
145
+ return best;
69
146
  }
70
147
  // The eligibility gate (audience-scoped-experiments ASE-F4/F5): an
71
148
  // experiment declaring eligibilityAudienceId is skipped entirely — before
72
149
  // bucketing or arm candidacy — unless the verified claims carry the ID.
73
150
  // Ungated experiments return true, preserving byte-identical behavior.
74
- function experimentEligible(item, claims) {
151
+ //
152
+ // ADR 0050 (edge-functions I-16): an experiment whose projection carries
153
+ // eligibilityMode "selected" is gated by the selected-leg predicate INSTEAD
154
+ // of the claims gate — it is a factor only when the leg the request selected
155
+ // carries audience=<eligibilityAudienceId>. Claims are never consulted for
156
+ // it: a rule-backed audience cannot enter claims, so the claims gate would
157
+ // hold the experiment dormant, and the selected leg is the fact the
158
+ // planner's sum built this route's tuples for. Out of selection there is no
159
+ // bucket, assignment, exposure, or rule-rejection record.
160
+ function experimentEligible(item, claims, selectedLegAudienceId) {
161
+ if (item.eligibilityMode === 'selected')
162
+ return selectedModeFactorOf(item, selectedLegAudienceId);
75
163
  const audienceID = String(item.eligibilityAudienceId || '').trim();
76
164
  if (!audienceID)
77
165
  return true;
78
166
  return !!claims && (claims.aud || []).includes(audienceID);
79
167
  }
80
- function routeHasExperiment(route, experimentID) {
81
- for (const key in route.variants || {}) {
82
- const values = route.variants[key].variantValues || variantValuesFromKey(key);
83
- if (values.experiment === experimentID)
84
- return true;
85
- }
86
- return false;
168
+ // selectedModeFactorOf is the decision core's spelling of the planner's
169
+ // leg-membership predicate (ADR 0050) — one name, one rule, two runtimes:
170
+ // experiment.SelectedModeFactorOf in Go, applied by Assign and by the
171
+ // contentbuild planner's leg factors, candidates, and Lookup. A
172
+ // selected-mode experiment with an unresolved eligibility audience is a
173
+ // factor of no leg, base included. Both inputs are canonical by variant-key
174
+ // construction (the record codec and the strict document decode pre-trim
175
+ // them), so this comparison needs no normalization — the Go twin's TrimSpace
176
+ // is defensive only and never changes the outcome.
177
+ function selectedModeFactorOf(item, legAudienceId) {
178
+ const audienceID = String(item.eligibilityAudienceId || '').trim();
179
+ return audienceID !== '' && audienceID === legAudienceId;
87
180
  }
88
181
  function assignExperiment(experiment, identity) {
89
182
  const version = String(experiment.allocationVersion || '').trim();
@@ -106,25 +199,20 @@ function assignExperiment(experiment, identity) {
106
199
  experimentId: experiment.id,
107
200
  armId: arm.id,
108
201
  allocationVersion: version,
109
- variantKey: arm.variantKey,
202
+ variantKey: `experiment.${experiment.id}=${arm.id}`,
110
203
  bucket,
111
204
  };
112
205
  }
113
206
  }
114
207
  return null;
115
208
  }
116
- function betterVariant(candidatePriority, candidateKey, bestPriority, bestKey) {
117
- if (candidatePriority !== bestPriority)
118
- return candidatePriority > bestPriority;
119
- return String(candidateKey || '').localeCompare(String(bestKey || '')) < 0;
120
- }
121
209
  function requestDimensionsMatch(request, values) {
122
210
  for (const rawKey in values) {
123
211
  const rawWant = values[rawKey];
124
212
  const key = String(rawKey).trim();
125
213
  const normalized = key.toLowerCase();
126
214
  const want = String(rawWant).trim();
127
- if (!key || !want || ['audience', 'segment', 'experiment', 'experimentarm'].includes(normalized))
215
+ if (!key || !want || ['audience', 'segment'].includes(normalized) || normalized.startsWith('experiment.'))
128
216
  continue;
129
217
  const candidates = (request.query[key] || []).concat(request.query['aci_' + key] || [], request.headers['x-aci-' + normalized] || '', request.headers['x-gradial-' + normalized] || '', request.cookies[key] || '', request.cookies['aci_' + key] || '');
130
218
  if (normalized === 'locale') {
@@ -783,137 +871,6 @@ function utf8(value) {
783
871
  }
784
872
  return new Uint8Array(out);
785
873
  }
786
- // ── Normalized two-phase model: classify → decide ─────────────────────
787
- //
788
- // Phase 1 (classify): evaluate all audience/segment rules page-agnostically
789
- // and assign experiment arms. Returns a Classification that is used by
790
- // Phase 2 for cheap set-membership lookups per route.
791
- //
792
- // Phase 2 (decide): for a given route record, check which variants match
793
- // the classification (set membership + priority). Audience/segment rules
794
- // were evaluated in Phase 1; an experiment arm variant's own rule is
795
- // evaluated here at commit time, because a bucket assignment is candidacy,
796
- // not entitlement (ADR 0040).
797
- //
798
- // The per-route record stays small: just variant keys (e.g.
799
- // "audience=trade") + priorities. Rules live in the classification data,
800
- // evaluated once per request.
801
- //
802
- /**
803
- * Phase 1: Classify the visitor against all release-level rules.
804
- *
805
- * @param ctx The request context (cookies, headers, geo, query)
806
- * @param data Release-level classification data (audiences, segments, experiments)
807
- * @param identity Visitor identity (for experiment bucketing). Pass undefined
808
- * if no identity or privacy denied.
809
- * @param claims The verified claims the caller already holds (eligibility
810
- * gate input, ASE-F4). Undefined/null means "no claims" —
811
- * gated experiments are skipped.
812
- * @returns Classification — matched audiences, segments, and experiment arm assignments
813
- */
814
- export function classify(ctx, data, identity, claims) {
815
- // Evaluate audience rules. Claims feed the audience.memberships fact
816
- // (ADR 0039) exactly as they do in the request-scoped resolver path.
817
- const matchedAudiences = [];
818
- for (const key in data.audiences) {
819
- const def = data.audiences[key];
820
- if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
821
- matchedAudiences.push(key);
822
- }
823
- }
824
- // Evaluate segment rules
825
- const matchedSegments = [];
826
- for (const key in data.segments) {
827
- const def = data.segments[key];
828
- if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
829
- matchedSegments.push(key);
830
- }
831
- }
832
- // Assign experiment arms (only for identified visitors)
833
- const experimentArms = {};
834
- if (identity && identity.usable) {
835
- for (const expId in data.experiments) {
836
- const exp = data.experiments[expId];
837
- if (exp.status !== 'running')
838
- continue;
839
- if (exp.kind !== 'ab' && exp.kind !== 'abn')
840
- continue;
841
- if (!experimentEligible(exp, claims))
842
- continue;
843
- const assignment = assignExperiment(exp, identity);
844
- if (assignment) {
845
- experimentArms[expId] = assignment;
846
- }
847
- }
848
- }
849
- return { matchedAudiences, matchedSegments, experimentArms };
850
- }
851
- /**
852
- * Phase 2: Decide the winning variant for a specific route.
853
- *
854
- * Uses the classification from Phase 1 for cheap set-membership lookups.
855
- * Audience/segment rules were already evaluated in Phase 1; the one rule
856
- * evaluated here is an experiment arm variant's own rule, because the arm
857
- * assignment is candidacy, not entitlement (ADR 0040).
858
- *
859
- * @param record The route entry (path, dispatch, variants)
860
- * @param classification The visitor's classification from Phase 1
861
- * @param ctx The request context (signal facts + rejection sink)
862
- * @param claims The verified claims the caller already holds — the
863
- * same object passed to classify() — so an arm variant's
864
- * audience.memberships rule resolves identically to the
865
- * resolver path and fails closed without it (ADR 0039).
866
- * @returns The decision — winning variant + experiment assignment, or nulls
867
- */
868
- export function decide(record, classification, ctx, claims) {
869
- if (!record.variants)
870
- return { variant: null, experiment: null };
871
- let bestVariant = null;
872
- let bestExperiment = null;
873
- for (const key in record.variants) {
874
- const variant = record.variants[key];
875
- if (!variant.variantKey)
876
- variant.variantKey = key;
877
- const values = variant.variantValues || variantValuesFromKey(key);
878
- // Check if this variant references a paused experiment — skip if so
879
- const experimentID = String(values.experiment || '').trim();
880
- if (experimentID && !classification.experimentArms[experimentID])
881
- continue;
882
- let matched = false;
883
- // Audience membership
884
- if (values.audience && classification.matchedAudiences.includes(values.audience)) {
885
- matched = true;
886
- }
887
- // Segment membership
888
- if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
889
- matched = true;
890
- }
891
- // Experiment arm assignment. The arm is a candidate only — committing it
892
- // inside the win block prevents a losing arm variant from leaving a
893
- // stale experiment decision behind when another variant wins.
894
- let armMatch = null;
895
- if (!matched && experimentID) {
896
- const arm = classification.experimentArms[experimentID];
897
- if (arm && arm.variantKey === variant.variantKey) {
898
- // Same ADR 0040 posture as the resolver's experiment leg: the arm
899
- // assignment is candidacy only. A rule rejected at serve time (or
900
- // unmatched) fires the onRuleRejected sink inside evaluateRule and
901
- // skips the variant — never served, no stale experiment decision.
902
- if (!variant.rule || evaluateRule(ctx, variant.rule, claims, variant.variantKey)) {
903
- matched = true;
904
- armMatch = arm;
905
- }
906
- }
907
- }
908
- if (matched) {
909
- if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
910
- bestVariant = variant;
911
- bestExperiment = armMatch;
912
- }
913
- }
914
- }
915
- return { variant: bestVariant, experiment: bestExperiment };
916
- }
917
874
  // ── Derived request device class (ADR 0037) ──────────────────────────
918
875
  //
919
876
  // device.class is the one request fact that is neither transcribed from the
@@ -993,7 +950,6 @@ function deviceHeader(headers, key) {
993
950
  // envelope may carry up to 64 conditions and a route many candidate variants,
994
951
  // each building its own resolver — without this, one page view could lowercase
995
952
  // and scan the user agent hundreds of times. Keyed on the context object, so
996
- // classify()/decide() reuse it too.
997
953
  const DEVICE_CLASS_CACHE = new WeakMap();
998
954
  function cachedDeviceClass(request) {
999
955
  const hit = DEVICE_CLASS_CACHE.get(request);
@@ -0,0 +1,17 @@
1
+ import type { PersonalizationExperiment, PersonalizationRoute, PersonalizationVariant } from './types.js';
2
+ export interface AuthoredCandidate {
3
+ readonly key: string;
4
+ readonly variant: PersonalizationVariant;
5
+ readonly values: Record<string, string>;
6
+ readonly experiments: readonly PersonalizationExperiment[];
7
+ }
8
+ export interface PersonalizationSelection {
9
+ readonly sourceExperiments: Record<string, PersonalizationExperiment> | undefined;
10
+ readonly authored: readonly AuthoredCandidate[];
11
+ readonly experiments: readonly PersonalizationExperiment[];
12
+ readonly arms: Readonly<Record<string, Readonly<Record<string, PersonalizationVariant>>>>;
13
+ readonly tuples: Readonly<Record<string, PersonalizationVariant>>;
14
+ readonly legacyExperimentExclusions: number;
15
+ }
16
+ export declare function preparePersonalizationRoute(route: PersonalizationRoute, experiments?: Record<string, PersonalizationExperiment>): PersonalizationRoute;
17
+ export declare function personalizationSelection(route: PersonalizationRoute, experiments?: Record<string, PersonalizationExperiment>): PersonalizationSelection;
@@ -0,0 +1,56 @@
1
+ import { variantValuesFromKey } from './record/codec.js';
2
+ import { compareUTF8 } from './variant_key.js';
3
+ const preparedSelection = Symbol('prepared personalization selection');
4
+ // Call once when an immutable partition is decoded. The derived index is a
5
+ // non-enumerable symbol property, so it cannot alter any serialized record.
6
+ export function preparePersonalizationRoute(route, experiments) {
7
+ const existing = route[preparedSelection];
8
+ if (existing && existing.sourceExperiments === experiments)
9
+ return route;
10
+ return Object.freeze(Object.defineProperty({ ...route }, preparedSelection, { value: deriveSelection(route, experiments) }));
11
+ }
12
+ export function personalizationSelection(route, experiments) {
13
+ return preparePersonalizationRoute(route, experiments)[preparedSelection];
14
+ }
15
+ function deriveSelection(route, experiments) {
16
+ const authored = [];
17
+ const arms = Object.create(null);
18
+ const legacy = new Set();
19
+ const qualified = new Set();
20
+ for (const [key, variant] of Object.entries(route.variants || {})) {
21
+ const values = Object.freeze({ ...(variant.variantValues || variantValuesFromKey(key)) });
22
+ // DEBT(edge-functions): ADR-0045 bounded pair reader and counter. Remove
23
+ // only when every retained release in every tenant compiles under the
24
+ // post-removal compiler; pair entries then become malformed.
25
+ if (isPairVariant(key, values)) {
26
+ const id = values.experiment || key.split(';').find((part) => part.startsWith('experiment='))?.slice('experiment='.length);
27
+ if (id && experiments?.[id])
28
+ legacy.add(id);
29
+ continue;
30
+ }
31
+ const names = Object.keys(values);
32
+ const ids = names.filter((name) => name.startsWith('experiment.')).map((name) => name.slice('experiment.'.length));
33
+ for (const id of ids)
34
+ qualified.add(id);
35
+ if (ids.length === 1 && names.length === 1) {
36
+ const id = ids[0];
37
+ (arms[id] ||= Object.create(null))[values[`experiment.${id}`]] = variant;
38
+ }
39
+ const factors = ids.map((id) => experiments?.[id]);
40
+ if (factors.some((item) => !item || item.kind !== 'xt' || item.status !== 'running'))
41
+ continue;
42
+ authored.push(Object.freeze({ key, variant, values, experiments: Object.freeze(factors) }));
43
+ }
44
+ for (const id of qualified)
45
+ legacy.delete(id);
46
+ authored.sort((left, right) => (right.variant.priority || 0) - (left.variant.priority || 0) || compareUTF8(left.key, right.key));
47
+ const scoped = Object.keys(experiments || {}).sort(compareUTF8).map((id) => experiments[id])
48
+ .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running' && !legacy.has(item.id));
49
+ for (const entries of Object.values(arms))
50
+ Object.freeze(entries);
51
+ return Object.freeze({ sourceExperiments: experiments, authored: Object.freeze(authored), experiments: Object.freeze(scoped), arms: Object.freeze(arms), tuples: route.variants || {}, legacyExperimentExclusions: legacy.size });
52
+ }
53
+ function isPairVariant(key, values) {
54
+ const names = [...key.split(';').map((part) => part.split('=')[0]), ...Object.keys(values)];
55
+ return names.some((name) => name.toLowerCase() === 'experiment' || name.toLowerCase() === 'experimentarm');
56
+ }
@@ -1,4 +1,4 @@
1
- import type { EdgeRouteEntry, ClassificationData } from '../types.js';
1
+ import type { EdgeRouteEntry } from '../types.js';
2
2
  /**
3
3
  * Unique sentinel object returned by expand functions on failure.
4
4
  * Callers check `result === INVALID`. Never throws.
@@ -37,35 +37,6 @@ export interface CompactRouteRecord {
37
37
  }
38
38
  export declare function projectRouteRecord(entry: EdgeRouteEntry): CompactRouteRecord;
39
39
  export declare function expandRouteRecord(raw: unknown): EdgeRouteEntry | Invalid;
40
- export interface CompactClassificationData {
41
- au?: Record<string, {
42
- r?: CompactRule;
43
- }>;
44
- sg?: Record<string, {
45
- r?: CompactRule;
46
- }>;
47
- x?: Record<string, CompactExperiment>;
48
- }
49
- interface CompactExperimentArm {
50
- id: string;
51
- ab?: number;
52
- vk?: string;
53
- ro?: string;
54
- }
55
- interface CompactExperiment {
56
- id: string;
57
- st?: string;
58
- k?: string;
59
- av?: string;
60
- sl?: string;
61
- pr?: number;
62
- ar?: CompactExperimentArm[];
63
- dn?: string;
64
- pg?: string;
65
- gl?: string[];
66
- }
67
- export declare function projectClassificationData(data: ClassificationData): CompactClassificationData;
68
- export declare function expandClassificationData(raw: unknown): ClassificationData | Invalid;
69
40
  export declare const MARKER = "{\"s\":1,\"e\":1}";
70
41
  export declare function isMarker(raw: string): boolean;
71
42
  export declare function canonicalSerialize(value: unknown): string;