@gradial/aci 0.1.52-rc.3 → 0.1.52-rc.34-e2e.1

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 (276) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +86 -5
  3. package/dist/astro/error-page.js +1 -0
  4. package/dist/astro/index.js +4 -0
  5. package/dist/astro/page.d.ts +5 -2
  6. package/dist/astro/page.js +35 -23
  7. package/dist/astro/preview.js +2 -17
  8. package/dist/audience-data-models.d.ts +308 -194
  9. package/dist/audience-data-models.js +314 -51
  10. package/dist/cli/compile-registry.d.mts +5 -1
  11. package/dist/cli/compile-registry.mjs +31 -46
  12. package/dist/cli/mintlify-render.mjs +8 -0
  13. package/dist/cli/validate-content.d.mts +31 -1
  14. package/dist/cli/validate-content.mjs +50 -12
  15. package/dist/collect/aci.js +1 -1
  16. package/dist/collect/aci.js.sha256 +1 -1
  17. package/dist/collect/api.d.ts +6 -7
  18. package/dist/collect/api.js +21 -8
  19. package/dist/collect/assignments.d.ts +7 -9
  20. package/dist/collect/assignments.js +23 -32
  21. package/dist/collect/batcher.d.ts +9 -7
  22. package/dist/collect/batcher.js +6 -5
  23. package/dist/collect/consent/envelope.d.ts +7 -1
  24. package/dist/collect/consent/envelope.js +3 -1
  25. package/dist/collect/consent/mapping.d.ts +2 -0
  26. package/dist/collect/consent/mapping.js +1 -0
  27. package/dist/collect/consenthold.d.ts +38 -0
  28. package/dist/collect/consenthold.js +82 -0
  29. package/dist/collect/declarative.js +7 -5
  30. package/dist/collect/gapbuffer.d.ts +13 -4
  31. package/dist/collect/gapbuffer.js +21 -10
  32. package/dist/collect/index.js +67 -20
  33. package/dist/collect/pageviews.d.ts +11 -11
  34. package/dist/collect/pageviews.js +27 -22
  35. package/dist/consent-banner/index.d.ts +17 -0
  36. package/dist/consent-banner/index.js +23 -0
  37. package/dist/content/index.d.ts +2 -1
  38. package/dist/content/index.js +4 -1
  39. package/dist/content/provider.d.ts +16 -2
  40. package/dist/content/provider.js +5 -1
  41. package/dist/content/resolve-slots.d.ts +33 -4
  42. package/dist/content/resolve-slots.js +127 -19
  43. package/dist/content/types.d.ts +7 -1
  44. package/dist/edge/expression-capabilities.v1.d.ts +106 -0
  45. package/dist/edge/expression-capabilities.v1.js +165 -0
  46. package/dist/index.d.ts +1 -2
  47. package/dist/index.js +0 -1
  48. package/dist/internal/domain/edge/compose.d.ts +24 -0
  49. package/dist/internal/domain/edge/compose.js +89 -0
  50. package/dist/internal/domain/edge/identity.d.ts +5 -0
  51. package/dist/internal/domain/edge/identity.js +50 -14
  52. package/dist/internal/domain/edge/index.d.ts +2 -0
  53. package/dist/internal/domain/edge/index.js +2 -0
  54. package/dist/internal/domain/edge/mimic_token.d.ts +14 -0
  55. package/dist/internal/domain/edge/mimic_token.js +125 -0
  56. package/dist/internal/domain/edge/personalization-core.d.ts +61 -22
  57. package/dist/internal/domain/edge/personalization-core.js +789 -198
  58. package/dist/internal/domain/edge/personalization_index.d.ts +17 -0
  59. package/dist/internal/domain/edge/personalization_index.js +56 -0
  60. package/dist/internal/domain/edge/record/codec.d.ts +6 -32
  61. package/dist/internal/domain/edge/record/codec.js +86 -168
  62. package/dist/internal/domain/edge/resolve.js +76 -39
  63. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
  64. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
  65. package/dist/internal/domain/edge/types.d.ts +93 -41
  66. package/dist/internal/domain/edge/types.js +3 -3
  67. package/dist/internal/domain/edge/variant_key.d.ts +3 -0
  68. package/dist/internal/domain/edge/variant_key.js +44 -0
  69. package/dist/internal/platform/collectionstore/errors.d.ts +24 -0
  70. package/dist/internal/platform/collectionstore/errors.js +48 -0
  71. package/dist/mimic/core.d.ts +2 -0
  72. package/dist/mimic/core.js +31 -0
  73. package/dist/mintlify/blocks.d.ts +6 -0
  74. package/dist/mintlify/blocks.js +201 -0
  75. package/dist/mintlify/config.d.ts +3 -0
  76. package/dist/mintlify/config.js +77 -0
  77. package/dist/mintlify/index.d.ts +3 -0
  78. package/dist/mintlify/index.js +2 -0
  79. package/dist/mintlify/json.d.ts +8 -0
  80. package/dist/mintlify/json.js +37 -0
  81. package/dist/mintlify/materialize.d.ts +14 -0
  82. package/dist/mintlify/materialize.js +254 -0
  83. package/dist/mintlify/types.d.ts +74 -0
  84. package/dist/mintlify/types.js +1 -0
  85. package/dist/mintlify/validate.d.ts +7 -0
  86. package/dist/mintlify/validate.js +256 -0
  87. package/dist/next/assignments-navigation.d.ts +4 -0
  88. package/dist/next/assignments-navigation.js +18 -0
  89. package/dist/next/assignments-script.d.ts +3 -2
  90. package/dist/next/assignments-script.js +5 -5
  91. package/dist/next/error-pages.d.ts +4 -0
  92. package/dist/next/error-pages.js +4 -0
  93. package/dist/next/error-seat-payload.d.ts +2 -1
  94. package/dist/next/error-seat-payload.js +14 -24
  95. package/dist/next/error-seat.js +7 -2
  96. package/dist/next/hosted-page-target.d.ts +7 -0
  97. package/dist/next/hosted-page-target.js +39 -0
  98. package/dist/next/index.d.ts +1 -1
  99. package/dist/next/index.js +3 -1
  100. package/dist/next/page.d.ts +29 -6
  101. package/dist/next/page.js +47 -25
  102. package/dist/next/release-context.d.ts +29 -0
  103. package/dist/next/release-context.js +55 -0
  104. package/dist/next/render.d.ts +1 -1
  105. package/dist/next/render.js +15 -5
  106. package/dist/next/server.d.ts +8 -0
  107. package/dist/next/server.js +44 -3
  108. package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
  109. package/dist/next/vercel-edge-projection-v1.js +9 -539
  110. package/dist/preview/core.d.ts +1 -0
  111. package/dist/preview/core.js +27 -0
  112. package/dist/react/VideoPlayer.d.ts +3 -3
  113. package/dist/react/VideoPlayer.js +2 -2
  114. package/dist/react/index.d.ts +4 -4
  115. package/dist/react/index.js +2 -3
  116. package/dist/registry.d.ts +4 -2
  117. package/dist/testing/conformance/checks.d.ts +2 -2
  118. package/dist/testing/conformance/checks.js +2 -0
  119. package/dist/testing/conformance/index.d.ts +1 -0
  120. package/dist/testing/conformance/index.js +1 -0
  121. package/dist/testing/conformance/next-stubs.d.ts +6 -0
  122. package/dist/testing/conformance/next-stubs.js +9 -0
  123. package/dist/testing/conformance/preset.d.ts +3 -2
  124. package/dist/testing/conformance/preset.js +15 -8
  125. package/dist/testing/conformance/root-identity.d.ts +31 -0
  126. package/dist/testing/conformance/root-identity.js +361 -0
  127. package/dist/testing/conformance/stubs/next-image.d.ts +7 -0
  128. package/dist/testing/conformance/stubs/next-image.js +5 -0
  129. package/dist/testing/conformance/stubs/next-link.d.ts +7 -0
  130. package/dist/testing/conformance/stubs/next-link.js +4 -0
  131. package/dist/testing/conformance/types.d.ts +5 -2
  132. package/dist/testing/index.d.ts +1 -1
  133. package/dist/testing/index.js +4 -3
  134. package/dist/types/block-ref.d.ts +2 -0
  135. package/dist/types/block-ref.js +3 -2
  136. package/dist/types/component.d.ts +9 -0
  137. package/dist/types/config.d.ts +0 -1
  138. package/dist/types/image.d.ts +54 -17
  139. package/dist/types/image.js +102 -18
  140. package/dist/types/index.d.ts +0 -1
  141. package/dist/types/index.js +0 -1
  142. package/dist/types/media.d.ts +14 -6
  143. package/dist/types/page.d.ts +0 -1
  144. package/dist/types/video.d.ts +7 -3
  145. package/migrations/conformance-registry.md +22 -0
  146. package/package.json +21 -7
  147. package/src/cli/compile-registry.mjs +30 -42
  148. package/src/cli/mintlify-render.mjs +9 -0
  149. package/src/cli/validate-content.mjs +52 -13
  150. package/src/types/block-ref.test.ts +60 -0
  151. package/src/types/block-ref.ts +4 -2
  152. package/src/types/component.ts +10 -0
  153. package/src/types/config.ts +0 -1
  154. package/src/types/image.ts +133 -29
  155. package/src/types/index.ts +0 -1
  156. package/src/types/page.ts +0 -1
  157. package/src/types/video.ts +1 -1
  158. package/templates/astro/starter-content/src/components/chrome/Footer.astro +5 -3
  159. package/templates/astro/starter-content/src/components/chrome/Header.astro +5 -3
  160. package/templates/astro/starter-content/src/components/elements/Button.astro +5 -2
  161. package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +5 -3
  162. package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -3
  163. package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -3
  164. package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -3
  165. package/templates/astro/starter-content/src/components/sections/HomeHero.astro +5 -3
  166. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -3
  167. package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -3
  168. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  169. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  170. package/templates/astro/starter-content/src/fixtures/sections.ts +3 -3
  171. package/templates/astro/starter-content/src/stories/CompositionStoryFrame.astro +7 -2
  172. package/templates/astro/template/.aci.yaml.tmpl +0 -1
  173. package/templates/astro/template/package.json.tmpl +5 -5
  174. package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
  175. package/templates/common/.agents/skills/aci/SKILL.md +58 -1
  176. package/templates/common/.agents/skills/authoring/SKILL.md +203 -43
  177. package/templates/common/.agents/skills/authoring/references/block-registry.md +19 -3
  178. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  179. package/templates/common/.agents/skills/authoring/references/contracts.md +9 -30
  180. package/templates/common/.agents/skills/authoring/references/repo-structure.md +5 -4
  181. package/templates/common/.agents/skills/authoring/references/sdk-server-functions.md +3 -3
  182. package/templates/common/.agents/skills/dam-delivery/SKILL.md +217 -0
  183. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +167 -0
  184. package/templates/common/.agents/skills/migration/references/canon-migration.md +18 -67
  185. package/templates/common/.agents/skills/migration/references/selector-map.md +1 -1
  186. package/templates/common/.content/fragments/navbar/_index.json +24 -0
  187. package/templates/common/.content/fragments/site-footer/_index.json +61 -0
  188. package/templates/common/.content/pages/404/_index.json +4 -2
  189. package/templates/common/.content/pages/{home/_index.json → _index.json} +10 -1
  190. package/templates/common/.content/pages/error/_index.json +4 -2
  191. package/templates/common/.content/schemas/site.schema.json +11 -1
  192. package/templates/common/.content/templates/error.json +24 -0
  193. package/templates/common/.content/templates/home.json +28 -0
  194. package/templates/common/.content/templates/site.json +8 -0
  195. package/templates/common/.content/templates/standard.json +24 -0
  196. package/templates/common/.nvmrc +1 -1
  197. package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
  198. package/templates/common/starter-content/.content/config/site.json +1 -1
  199. package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
  200. package/templates/common/starter-content/.content/dam/README.md +74 -0
  201. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/asset.json +1 -1
  202. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/governance.json +2 -1
  203. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/asset.json +1 -1
  204. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/governance.json +2 -1
  205. package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
  206. package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
  207. package/templates/common/starter-content/.content/pages/404/_index.json +2 -2
  208. package/templates/common/starter-content/.content/pages/{home/_index.json → _index.json} +23 -3
  209. package/templates/common/starter-content/.content/pages/about/_index.json +5 -2
  210. package/templates/common/starter-content/.content/pages/error/_index.json +2 -2
  211. package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
  212. package/templates/common/starter-content/.content/patterns/components/container.json +67 -0
  213. package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
  214. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +59 -0
  215. package/templates/common/starter-content/.content/patterns/components/home_hero.json +151 -0
  216. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
  217. package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
  218. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
  219. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
  220. package/templates/common/starter-content/.content/schemas/site.schema.json +11 -1
  221. package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +4 -3
  222. package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +4 -3
  223. package/templates/nextjs/starter-content/src/components/elements/Button.tsx +4 -3
  224. package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +4 -3
  225. package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +4 -2
  226. package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +4 -3
  227. package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +4 -2
  228. package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +4 -3
  229. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +4 -2
  230. package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +4 -3
  231. package/templates/nextjs/template/.aci.yaml +9 -0
  232. package/templates/nextjs/template/.aci.yaml.tmpl +0 -1
  233. package/templates/nextjs/template/package.json.tmpl +4 -3
  234. package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
  235. package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
  236. package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
  237. package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
  238. package/templates/nextjs/template/src/app/fonts/inter/OFL.txt +93 -0
  239. package/templates/nextjs/template/src/app/fonts/inter/inter-latin.woff2 +0 -0
  240. package/templates/nextjs/template/src/app/fonts/space-grotesk/OFL.txt +93 -0
  241. package/templates/nextjs/template/src/app/fonts/space-grotesk/space-grotesk-latin.woff2 +0 -0
  242. package/templates/nextjs/template/src/app/layout.tsx +12 -7
  243. package/templates/nextjs/template/tests/conformance.test.ts.tmpl +1 -0
  244. package/dist/define-layout.d.ts +0 -6
  245. package/dist/define-layout.js +0 -14
  246. package/dist/edge/personalization-core.d.ts +0 -11
  247. package/dist/edge/personalization-core.js +0 -269
  248. package/dist/next/collect-route.d.ts +0 -19
  249. package/dist/next/collect-route.js +0 -459
  250. package/dist/next/envelope-digest.d.ts +0 -21
  251. package/dist/next/envelope-digest.js +0 -58
  252. package/dist/next/middleware.d.ts +0 -40
  253. package/dist/next/middleware.js +0 -475
  254. package/dist/next/personalization-host.d.ts +0 -134
  255. package/dist/next/personalization-host.js +0 -270
  256. package/dist/next/root-layout.d.ts +0 -4
  257. package/dist/next/root-layout.js +0 -5
  258. package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
  259. package/dist/next/vercel-collect-sdk-route.js +0 -181
  260. package/dist/next/vercel-personalization.d.ts +0 -4
  261. package/dist/next/vercel-personalization.js +0 -4
  262. package/dist/next/vercel-s3-personalization.d.ts +0 -79
  263. package/dist/next/vercel-s3-personalization.js +0 -570
  264. package/dist/types/layout.d.ts +0 -24
  265. package/src/types/layout.ts +0 -29
  266. package/templates/astro/starter-content/src/render/RenderPage.astro +0 -67
  267. package/templates/astro/template/src/cms/contracts/layouts/index.ts +0 -18
  268. package/templates/nextjs/starter-content/src/components/elements/AtomicBlock.tsx +0 -13
  269. package/templates/nextjs/template/src/cms/contracts/layouts/index.ts +0 -16
  270. /package/dist/{types/layout.js → cli/mintlify-render.d.mts} +0 -0
  271. /package/templates/common/.content/overlays/audience/preference-blue/pages/{home/_index.json → _index.json} +0 -0
  272. /package/templates/common/.content/overlays/audience/preference-red/pages/{home/_index.json → _index.json} +0 -0
  273. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/editorial.json +0 -0
  274. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/versions/v0001.json +0 -0
  275. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/editorial.json +0 -0
  276. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/versions/v0001.json +0 -0
@@ -1,76 +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
- // Build-time constant. The CloudFront Functions bundle replaces this with
9
- // true and uses the runtime's built-in crypto module; other host bundles
10
- // replace it with false and retain the portable implementation.
11
- export function selectPersonalizationVariant(request, route, claims, experiments, identity) {
12
- return resolvePersonalizationDecision(request, route, claims, experiments, identity).variant;
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
+ }
13
19
  }
14
- export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
20
+ export function resolvePersonalizationDecision(request, route, claims, experiments, identity, forcedArms) {
15
21
  if (!route)
16
- return { variant: null, experiment: null };
17
- let variant = selectRuleVariant(request, route, claims, experiments);
18
- let experiment = null;
19
- if (identity && identity.usable && route.variants && experiments) {
20
- const candidates = Object.values(experiments)
21
- .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
22
- .filter((item) => routeHasExperiment(route, item.id))
23
- .sort((left, right) => (right.priority || 0) - (left.priority || 0)
24
- || String(left.id || '').localeCompare(String(right.id || '')));
25
- for (let index = 0; index < candidates.length; index++) {
26
- const item = candidates[index];
27
- const assignment = assignExperiment(item, identity);
28
- const candidate = assignment ? route.variants[assignment.variantKey] : undefined;
29
- 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))
48
+ continue;
49
+ const assignment = forcing
50
+ ? forceExperimentArm(item, forcedArms)
51
+ : assignExperiment(item, identity);
52
+ if (!assignment)
30
53
  continue;
31
- if (assignment && !candidate.variantKey)
32
- candidate.variantKey = assignment.variantKey;
33
- if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
34
- variant = candidate;
35
- 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
+ }
36
74
  }
37
- break;
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;
38
81
  }
39
82
  }
40
- return { variant: variant || null, experiment };
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];
103
+ }
104
+ }
105
+ }
106
+ return { variant, experiments: assignments, ...(legacyExperimentExclusions ? { legacyExperimentExclusions } : {}) };
41
107
  }
42
- function selectRuleVariant(request, route, claims, experiments) {
43
- 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)
116
+ return null;
117
+ const arm = (experiment.arms || []).find((candidate) => candidate.id === armId);
118
+ if (!arm)
44
119
  return null;
45
- const matches = [];
46
- for (const key in route.variants) {
47
- const variant = route.variants[key];
48
- if (!variant.variantKey)
49
- variant.variantKey = key;
50
- const values = variant.variantValues || variantValuesFromKey(key);
51
- const experimentID = String(values.experiment || '').trim();
52
- if (experimentID && (!experiments || !experiments[experimentID]
53
- || experiments[experimentID].status !== 'running'))
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 || ''))))
54
136
  continue;
55
137
  if (!requestDimensionsMatch(request, values))
56
138
  continue;
57
139
  const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
58
140
  || (values.segment && claims && (claims.seg || []).includes(values.segment));
59
- const ruleMatch = variant.rule ? evaluateRule(request, variant.rule) : false;
60
- if (membershipMatch || ruleMatch)
61
- 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 };
62
144
  }
63
- matches.sort((left, right) => (right.priority || 0) - (left.priority || 0)
64
- || String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
65
- return matches[0] || null;
145
+ return best;
66
146
  }
67
- function routeHasExperiment(route, experimentID) {
68
- for (const key in route.variants || {}) {
69
- const values = route.variants[key].variantValues || variantValuesFromKey(key);
70
- if (values.experiment === experimentID)
71
- return true;
72
- }
73
- return false;
147
+ // The eligibility gate (audience-scoped-experiments ASE-F4/F5): an
148
+ // experiment declaring eligibilityAudienceId is skipped entirely — before
149
+ // bucketing or arm candidacy — unless the verified claims carry the ID.
150
+ // Ungated experiments return true, preserving byte-identical behavior.
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);
163
+ const audienceID = String(item.eligibilityAudienceId || '').trim();
164
+ if (!audienceID)
165
+ return true;
166
+ return !!claims && (claims.aud || []).includes(audienceID);
167
+ }
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;
74
180
  }
75
181
  function assignExperiment(experiment, identity) {
76
182
  const version = String(experiment.allocationVersion || '').trim();
@@ -93,25 +199,20 @@ function assignExperiment(experiment, identity) {
93
199
  experimentId: experiment.id,
94
200
  armId: arm.id,
95
201
  allocationVersion: version,
96
- variantKey: arm.variantKey,
202
+ variantKey: `experiment.${experiment.id}=${arm.id}`,
97
203
  bucket,
98
204
  };
99
205
  }
100
206
  }
101
207
  return null;
102
208
  }
103
- function betterVariant(candidatePriority, candidateKey, bestPriority, bestKey) {
104
- if (candidatePriority !== bestPriority)
105
- return candidatePriority > bestPriority;
106
- return String(candidateKey || '').localeCompare(String(bestKey || '')) < 0;
107
- }
108
209
  function requestDimensionsMatch(request, values) {
109
210
  for (const rawKey in values) {
110
211
  const rawWant = values[rawKey];
111
212
  const key = String(rawKey).trim();
112
213
  const normalized = key.toLowerCase();
113
214
  const want = String(rawWant).trim();
114
- if (!key || !want || ['audience', 'segment', 'experiment', 'experimentarm'].includes(normalized))
215
+ if (!key || !want || ['audience', 'segment'].includes(normalized) || normalized.startsWith('experiment.'))
115
216
  continue;
116
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] || '');
117
218
  if (normalized === 'locale') {
@@ -127,52 +228,565 @@ function requestDimensionsMatch(request, values) {
127
228
  }
128
229
  return true;
129
230
  }
130
- function evaluateRule(request, rule) {
131
- const all = (rule.all || []);
132
- const any = (rule.any || []);
231
+ const MAX_EXPRESSION_CONDITIONS = 64;
232
+ // Authored nesting depth (ADR 0040). The top-level envelope is depth 1 and a
233
+ // group node is one deeper than the list containing it. Pinned to the
234
+ // capability artifact by the drift test, like the grammars above.
235
+ const MAX_EXPRESSION_DEPTH = 5;
236
+ // A member carrying `all` or `any` is an envelope, not a condition. Presence
237
+ // decides — never the value's type — so all three runtimes classify the same
238
+ // member the same way.
239
+ function isExpressionGroup(member) {
240
+ // Null-guarded because this runs on a viewer request path: a malformed
241
+ // member must fail closed to the base experience, never throw. A non-object
242
+ // member is classified as a leaf and rejected by ordinary validation.
243
+ return !!member && typeof member === 'object'
244
+ && (Object.prototype.hasOwnProperty.call(member, 'all')
245
+ || Object.prototype.hasOwnProperty.call(member, 'any'));
246
+ }
247
+ // expressionMembers returns a group node's member list: [] when the key is
248
+ // absent, null when it is present but not an array. Go rejects that document
249
+ // at decode; here it fails closed as a diagnostic rather than letting the
250
+ // member reclassify as a leaf.
251
+ function expressionMembers(member, group) {
252
+ if (!Object.prototype.hasOwnProperty.call(member, group))
253
+ return [];
254
+ const value = member[group];
255
+ if (!Array.isArray(value))
256
+ return null;
257
+ return value;
258
+ }
259
+ // countExpressionLeaves counts leaves across the tree, applying the same depth
260
+ // cutoff the validating walk does so the two bounds cannot disagree about one
261
+ // expression.
262
+ function countExpressionLeaves(members, depth) {
263
+ let total = 0;
264
+ for (const member of members) {
265
+ if (!isExpressionGroup(member)) {
266
+ total++;
267
+ continue;
268
+ }
269
+ if (depth + 1 > MAX_EXPRESSION_DEPTH)
270
+ continue;
271
+ total += countExpressionLeaves(expressionMembers(member, 'all') || [], depth + 1)
272
+ + countExpressionLeaves(expressionMembers(member, 'any') || [], depth + 1);
273
+ }
274
+ return total;
275
+ }
276
+ /** Evaluate one request expression and produce the same redacted trace used for qualification. */
277
+ export function evaluateRequestExpression(rule, resolve) {
278
+ const all = (Array.isArray(rule.all) ? rule.all : []);
279
+ const any = (Array.isArray(rule.any) ? rule.any : []);
280
+ const empty = all.length + any.length === 0;
281
+ const diagnostics = validateRequestExpression(rule, all, any);
282
+ const trace = { scope: 'request', empty, groups: [], leaves: [] };
283
+ if (diagnostics.length > 0)
284
+ return { qualified: false, diagnostics, trace };
285
+ const qualified = qualifyRequestExpression(all, any, resolve, trace);
286
+ return { qualified, diagnostics, trace };
287
+ }
288
+ function evaluateRule(request, rule, claims, variantKey) {
289
+ const all = (Array.isArray(rule.all) ? rule.all : []);
290
+ const any = (Array.isArray(rule.any) ? rule.any : []);
291
+ const diagnostics = validateRequestExpression(rule, all, any);
292
+ if (diagnostics.length > 0) {
293
+ // Records pass the write-time gate before they ship, so a rule rejected
294
+ // HERE — at serve time — is always version skew or a projection bug, never
295
+ // ordinary authoring error. That makes it a precise signal, and discarding
296
+ // it silently is what made the predicted incident (author previews a rule
297
+ // successfully while a stale lane serves base) undebuggable (ADR 0040).
298
+ // Codes and paths only; never a fact, operand, or request value.
299
+ if (request.onRuleRejected) {
300
+ request.onRuleRejected({
301
+ variantKey: variantKey || '',
302
+ diagnosticCode: diagnostics[0].code,
303
+ diagnosticPath: diagnostics[0].path,
304
+ diagnosticCount: diagnostics.length,
305
+ });
306
+ }
307
+ return false;
308
+ }
309
+ // audience.memberships resolves from the verified (or mimic) claims object
310
+ // the decision entry points already hold, never from the request bag — an
311
+ // unverified cookie can never impersonate membership (ADR 0039).
312
+ return qualifyRequestExpression(all, any, (field) => field === 'audience.memberships'
313
+ ? membershipsFact(claims)
314
+ : signalFact(request, field));
315
+ }
316
+ // No claims → unavailable: an unconsented visitor is not a known non-member,
317
+ // so every operator fails closed, exists:false included. Verified claims with
318
+ // no memberships → absent, so exists:false selects exactly the consented
319
+ // visitor who belongs to nothing (ADR 0039).
320
+ function membershipsFact(claims) {
321
+ if (!claims)
322
+ return { status: 'unavailable' };
323
+ // Array.isArray, not || []: a malformed truthy aud (a scalar from a buggy
324
+ // claims writer) must degrade to absent, never reach the matcher's
325
+ // .some() and throw on a viewer-request path.
326
+ const memberships = Array.isArray(claims.aud) ? claims.aud : [];
327
+ return memberships.length > 0 ? { status: 'present', values: memberships } : { status: 'absent' };
328
+ }
329
+ function validateRequestExpression(rule, all, any) {
330
+ const diagnostics = [];
133
331
  if (all.length + any.length === 0)
332
+ diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
333
+ if (countExpressionLeaves(all, 1) + countExpressionLeaves(any, 1) > MAX_EXPRESSION_CONDITIONS)
334
+ diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
335
+ Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
336
+ diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
337
+ });
338
+ for (const [group, members] of [['all', all], ['any', any]]) {
339
+ validateExpressionMembers('expression', group, members, 1, diagnostics);
340
+ }
341
+ return diagnostics;
342
+ }
343
+ // validateExpressionMembers validates one envelope's member list. depth is the
344
+ // depth of the envelope owning these members; the top-level envelope is 1.
345
+ function validateExpressionMembers(path, group, members, depth, diagnostics) {
346
+ members.forEach((member, index) => {
347
+ const memberPath = `${path}.${group}[${index}]`;
348
+ if (!member || typeof member !== 'object' || !isExpressionGroup(member)) {
349
+ validateRequestCondition(member || {}, memberPath, diagnostics);
350
+ return;
351
+ }
352
+ // One diagnostic per broken node, and no descent past it: a too-deep
353
+ // branch reports its depth once rather than once per buried condition.
354
+ if (depth + 1 > MAX_EXPRESSION_DEPTH) {
355
+ diagnostics.push({ code: 'expression.depth.exceeded', path: memberPath, scope: 'request', message: `${memberPath} must not nest more than ${MAX_EXPRESSION_DEPTH} levels deep` });
356
+ return;
357
+ }
358
+ const nestedAll = expressionMembers(member, 'all');
359
+ const nestedAny = expressionMembers(member, 'any');
360
+ if (nestedAll === null || nestedAny === null) {
361
+ const malformed = nestedAll === null ? 'all' : 'any';
362
+ diagnostics.push({ code: 'expression.group.unsupported', path: `${memberPath}.${malformed}`, scope: 'request', message: `${memberPath}.${malformed} must be an array of conditions or groups` });
363
+ return;
364
+ }
365
+ if (nestedAll.length + nestedAny.length === 0) {
366
+ diagnostics.push({ code: 'expression.empty', path: memberPath, scope: 'request', message: `${memberPath} must include at least one all or any condition` });
367
+ return;
368
+ }
369
+ Object.keys(member).filter((key) => key !== 'all' && key !== 'any').sort().forEach((key) => {
370
+ // Name the mistake, not its symptom: telling an author that `field` "is
371
+ // not a supported group" describes the classification, not the error.
372
+ const message = key === 'field' || key === 'operator' || key === 'value'
373
+ ? `${memberPath}.${key} is not supported: a member carrying all or any is a group, not a condition, so it cannot also carry ${key}`
374
+ : `${memberPath}.${key} is not a supported expression group`;
375
+ diagnostics.push({ code: 'expression.group.unsupported', path: `${memberPath}.${key}`, scope: 'request', message });
376
+ });
377
+ validateExpressionMembers(memberPath, 'all', nestedAll, depth + 1, diagnostics);
378
+ validateExpressionMembers(memberPath, 'any', nestedAny, depth + 1, diagnostics);
379
+ });
380
+ }
381
+ // qualifyRequestExpression applies the envelope truth table to one node's
382
+ // member lists, recursing through group nodes (ADR 0040). Evaluation stays
383
+ // exhaustive so the trace holds every leaf outcome.
384
+ //
385
+ // topLevel carries nesting's one asymmetry: the top envelope always records
386
+ // both group rows because they express the scope's empty-envelope policy,
387
+ // while a nested node records only for a list it carries members in — which
388
+ // also keeps the trace stable across a codec that drops empty lists.
389
+ function qualifyRequestExpression(all, any, resolve, trace, path = 'expression', topLevel = true) {
390
+ let qualified = true;
391
+ for (const [group, members] of [['all', all], ['any', any]]) {
392
+ if (!topLevel && members.length === 0)
393
+ continue;
394
+ let matched = 0;
395
+ members.forEach((member, index) => {
396
+ const memberPath = `${path}.${group}[${index}]`;
397
+ let passed = false;
398
+ if (isExpressionGroup(member)) {
399
+ passed = qualifyRequestExpression(expressionMembers(member, 'all') || [], expressionMembers(member, 'any') || [], resolve, trace, memberPath, false);
400
+ }
401
+ else {
402
+ const fact = normalizeFact(resolve(String(member.field || '')));
403
+ const outcome = matchRequestCondition(member, fact);
404
+ passed = outcome.matched;
405
+ if (trace)
406
+ trace.leaves.push({
407
+ path: memberPath, group, index,
408
+ field: String(member.field || ''), operator: String(member.operator || ''),
409
+ status: fact.status, matched: outcome.matched, reason: outcome.reason,
410
+ });
411
+ }
412
+ if (passed)
413
+ matched++;
414
+ });
415
+ const passed = group === 'all' ? matched === members.length : members.length === 0 || matched > 0;
416
+ if (trace)
417
+ trace.groups.push({ path: `${path}.${group}`, group, total: members.length, matched, passed });
418
+ qualified = qualified && passed;
419
+ }
420
+ return qualified;
421
+ }
422
+ function validateRequestCondition(condition, path, diagnostics) {
423
+ Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
424
+ diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
425
+ });
426
+ const field = String(condition.field || '');
427
+ if (field !== 'referrer' && field !== 'device.class' && field !== 'time.now' && field !== 'audience.memberships' && !['query.', 'header.', 'cookie.', 'geo.', 'utm.'].some((prefix) => field.startsWith(prefix) && field.length > prefix.length)) {
428
+ diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
429
+ }
430
+ const operator = String(condition.operator || '');
431
+ if (!EXPRESSION_OPERATORS.includes(operator)) {
432
+ diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
433
+ return;
434
+ }
435
+ // device.class compares against a closed four-value set, so substring
436
+ // operators express nothing the set cannot — and admitting them would
437
+ // re-create the user-agent-substring habit inside the safe field (ADR 0037).
438
+ if (field === 'device.class' && !['equals', 'in', 'exists'].includes(operator)) {
439
+ diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which compares against a closed value set` });
440
+ return;
441
+ }
442
+ // time.now is a sub-second-churning clock: string operators are silently
443
+ // inert on it and the numeric family can never match an RFC 3339 string
444
+ // (ADR 0038).
445
+ if (field === 'time.now' && !['before', 'after', 'between', 'exists'].includes(operator)) {
446
+ diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which supports only before, after, between, exists` });
447
+ return;
448
+ }
449
+ // audience.memberships is an opaque-ID list: equals is already
450
+ // any-member-equals under the multi-valued match loop, so the substring
451
+ // family would only add silent overmatching of prefixed IDs, and the
452
+ // numeric/instant families can never match an ID (ADR 0039).
453
+ if (field === 'audience.memberships' && !['equals', 'in', 'exists'].includes(operator)) {
454
+ diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which supports only equals, in, exists` });
455
+ return;
456
+ }
457
+ const value = condition.value;
458
+ if (operator === 'exists') {
459
+ if (value !== undefined && value !== null && typeof value !== 'boolean') {
460
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
461
+ }
462
+ return;
463
+ }
464
+ if (value === undefined || value === null) {
465
+ diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
466
+ return;
467
+ }
468
+ if (['equals', 'contains', 'startsWith'].includes(operator)) {
469
+ if (!['string', 'number', 'boolean'].includes(typeof value)
470
+ || (typeof value === 'number' && !expressionNumberInDomain(value))) {
471
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a string, boolean, or finite number using only safe integers` });
472
+ }
473
+ else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
474
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be one of ${DEVICE_CLASSES.join(', ')} for field ${JSON.stringify(field)}` });
475
+ }
476
+ return;
477
+ }
478
+ if (['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual'].includes(operator)) {
479
+ if (typeof value !== 'number' || !expressionNumberInDomain(value)) {
480
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a finite JSON number using only safe integers for operator ${operator}` });
481
+ }
482
+ return;
483
+ }
484
+ if (operator === 'before' || operator === 'after') {
485
+ if (typeof value !== 'string' || parseExpressionTimestamp(value) === null) {
486
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be an RFC 3339 timestamp with an explicit UTC offset for operator ${operator}` });
487
+ }
488
+ return;
489
+ }
490
+ if (operator === 'between') {
491
+ if (!validExpressionRange(value)) {
492
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a min/max object whose bounds are both numbers or both timestamps with min not above max` });
493
+ }
494
+ else if (field === 'time.now' && typeof value.min === 'number') {
495
+ // An instant field's observed values are instants: a numeric range on
496
+ // it would validate and then sit silently inert at execution.
497
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value bounds must be timestamps for field ${JSON.stringify(field)}` });
498
+ }
499
+ return;
500
+ }
501
+ // in: bounded, non-empty, every member one canonicalizable scalar, all
502
+ // members one JSON type — a mixed list is almost always an authoring bug.
503
+ const members = homogeneousExpressionMembers(value);
504
+ if (members === null) {
505
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be an array of 1 to ${MAX_IN_VALUES} scalars sharing one type` });
506
+ return;
507
+ }
508
+ if (field === 'device.class') {
509
+ for (const member of members) {
510
+ if (DEVICE_CLASSES.indexOf(member) < 0) {
511
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be one of ${DEVICE_CLASSES.join(', ')} for field ${JSON.stringify(field)}` });
512
+ return;
513
+ }
514
+ }
515
+ }
516
+ }
517
+ function matchRequestCondition(condition, fact) {
518
+ if (fact.status === 'unavailable')
519
+ return { matched: false, reason: 'unavailable' };
520
+ if (condition.operator === 'exists') {
521
+ const expected = typeof condition.value === 'boolean' ? condition.value : true;
522
+ const matched = (fact.status === 'present') === expected;
523
+ return { matched, reason: matched ? 'matched' : fact.status === 'absent' ? 'absent' : 'not_matched' };
524
+ }
525
+ if (fact.status === 'absent')
526
+ return { matched: false, reason: 'absent' };
527
+ const matcher = expressionConditionMatcher(condition);
528
+ if (!matcher)
529
+ return { matched: false, reason: 'not_matched' };
530
+ const matched = (fact.values || []).some(matcher);
531
+ return { matched, reason: matched ? 'matched' : 'not_matched' };
532
+ }
533
+ export const EXPRESSION_OPERATORS = [
534
+ 'equals', 'contains', 'startsWith', 'exists',
535
+ 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
536
+ 'before', 'after', 'between', 'in',
537
+ ];
538
+ const MAX_IN_VALUES = 32;
539
+ // Observed values are gated on the published grammars before any host parser
540
+ // runs (ADR 0038): Number() and Date.parse accept supersets (hex, empty
541
+ // string, vendor date formats) that would let the same fact match here and
542
+ // not in Go. Keep these literals identical to the capability artifact — the
543
+ // drift test compares them byte-for-byte.
544
+ export const EXPRESSION_NUMBER_GRAMMAR = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
545
+ export const EXPRESSION_TIMESTAMP_GRAMMAR = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
546
+ function expressionNumberInDomain(value) {
547
+ if (!Number.isFinite(value))
134
548
  return false;
135
- return all.every((condition) => evaluateCondition(request, condition))
136
- && (any.length === 0 || any.some((condition) => evaluateCondition(request, condition)));
137
- }
138
- function evaluateCondition(request, condition) {
139
- const values = signalValues(request, String(condition.field || ''));
140
- if (condition.operator === 'exists')
141
- return values.length > 0;
142
- if (values.length === 0 || typeof condition.value !== 'string' && typeof condition.value !== 'number' && typeof condition.value !== 'boolean')
549
+ if (Number.isInteger(value) && !Number.isSafeInteger(value))
143
550
  return false;
144
- const e = String(condition.value);
145
- if (condition.operator === 'equals')
146
- return values.some(v => v === e);
147
- if (condition.operator === 'contains')
148
- return values.some(v => v.includes(e));
149
- if (condition.operator === 'startsWith')
150
- return values.some(v => v.startsWith(e));
151
- return false;
152
- }
153
- function signalValues(request, field) {
551
+ return true;
552
+ }
553
+ function parseObservedExpressionNumber(value) {
554
+ if (!EXPRESSION_NUMBER_GRAMMAR.test(value))
555
+ return null;
556
+ const parsed = Number(value);
557
+ return expressionNumberInDomain(parsed) ? parsed : null;
558
+ }
559
+ // Explicit calendar validation runs before Date arithmetic because Date.UTC
560
+ // (like Go's time.Date) silently normalizes impossible dates. Instants
561
+ // compare as epoch milliseconds; fractional seconds truncate to milliseconds.
562
+ function parseExpressionTimestamp(value) {
563
+ const match = EXPRESSION_TIMESTAMP_GRAMMAR.exec(value);
564
+ if (!match)
565
+ return null;
566
+ const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
567
+ const hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6]);
568
+ if (month < 1 || month > 12 || day < 1 || day > expressionDaysInMonth(year, month))
569
+ return null;
570
+ if (hour > 23 || minute > 59 || second > 59)
571
+ return null;
572
+ let offsetMinutes = 0;
573
+ if (match[8] !== 'Z') {
574
+ const offsetHour = Number(match[8].slice(1, 3)), offsetMinute = Number(match[8].slice(4, 6));
575
+ if (offsetHour > 23 || offsetMinute > 59)
576
+ return null;
577
+ offsetMinutes = offsetHour * 60 + offsetMinute;
578
+ if (match[8][0] === '-')
579
+ offsetMinutes = -offsetMinutes;
580
+ }
581
+ const fractionMillis = match[7] ? Number((match[7].slice(1) + '00').slice(0, 3)) : 0;
582
+ // setUTCFullYear, not Date.UTC: Date.UTC maps two-digit years to 1900+.
583
+ const date = new Date(0);
584
+ date.setUTCFullYear(year, month - 1, day);
585
+ date.setUTCHours(hour, minute, second, 0);
586
+ return date.getTime() - offsetMinutes * 60000 + fractionMillis;
587
+ }
588
+ function expressionDaysInMonth(year, month) {
589
+ if (month === 4 || month === 6 || month === 9 || month === 11)
590
+ return 30;
591
+ if (month === 2)
592
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
593
+ return 31;
594
+ }
595
+ function scalarExpressionOperand(value) {
596
+ if (value === undefined || value === null)
597
+ return null;
598
+ const kind = typeof value;
599
+ if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
600
+ return null;
601
+ if (kind === 'number' && !expressionNumberInDomain(value))
602
+ return null;
603
+ return canonicalScalar(value);
604
+ }
605
+ function validExpressionRange(value) {
606
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
607
+ return false;
608
+ const record = value;
609
+ if (Object.keys(record).some((key) => key !== 'min' && key !== 'max'))
610
+ return false;
611
+ const min = record.min, max = record.max;
612
+ if (min === undefined || min === null || max === undefined || max === null)
613
+ return false;
614
+ if (typeof min === 'number' && expressionNumberInDomain(min)) {
615
+ return typeof max === 'number' && expressionNumberInDomain(max) && min <= max;
616
+ }
617
+ const minMillis = typeof min === 'string' ? parseExpressionTimestamp(min) : null;
618
+ if (minMillis === null)
619
+ return false;
620
+ const maxMillis = typeof max === 'string' ? parseExpressionTimestamp(max) : null;
621
+ return maxMillis !== null && minMillis <= maxMillis;
622
+ }
623
+ function homogeneousExpressionMembers(value) {
624
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IN_VALUES)
625
+ return null;
626
+ let memberKind = '';
627
+ const members = [];
628
+ for (const member of value) {
629
+ const kind = typeof member;
630
+ if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
631
+ return null;
632
+ if (kind === 'number' && !expressionNumberInDomain(member))
633
+ return null;
634
+ if (!memberKind)
635
+ memberKind = kind;
636
+ else if (memberKind !== kind)
637
+ return null;
638
+ members.push(canonicalScalar(member));
639
+ }
640
+ return members;
641
+ }
642
+ // expressionConditionMatcher builds the per-observed-value predicate for one
643
+ // condition. Null means the authored side cannot be interpreted — validation
644
+ // rejects those expressions first, so this only fails closed for direct use.
645
+ function expressionConditionMatcher(condition) {
646
+ const operator = String(condition.operator || '');
647
+ const operand = condition.value;
648
+ switch (operator) {
649
+ case 'equals':
650
+ case 'contains':
651
+ case 'startsWith': {
652
+ const expected = scalarExpressionOperand(operand);
653
+ if (expected === null)
654
+ return null;
655
+ return (value) => operator === 'equals' ? value === expected
656
+ : operator === 'contains' ? value.includes(expected) : value.startsWith(expected);
657
+ }
658
+ case 'greaterThan':
659
+ case 'greaterThanOrEqual':
660
+ case 'lessThan':
661
+ case 'lessThanOrEqual': {
662
+ if (typeof operand !== 'number' || !expressionNumberInDomain(operand))
663
+ return null;
664
+ return (value) => {
665
+ const observed = parseObservedExpressionNumber(value);
666
+ if (observed === null)
667
+ return false;
668
+ return operator === 'greaterThan' ? observed > operand
669
+ : operator === 'greaterThanOrEqual' ? observed >= operand
670
+ : operator === 'lessThan' ? observed < operand : observed <= operand;
671
+ };
672
+ }
673
+ case 'before':
674
+ case 'after': {
675
+ const operandMillis = typeof operand === 'string' ? parseExpressionTimestamp(operand) : null;
676
+ if (operandMillis === null)
677
+ return null;
678
+ return (value) => {
679
+ const observed = parseExpressionTimestamp(value);
680
+ if (observed === null)
681
+ return false;
682
+ return operator === 'after' ? observed > operandMillis : observed < operandMillis;
683
+ };
684
+ }
685
+ case 'between': {
686
+ if (!validExpressionRange(operand))
687
+ return null;
688
+ const record = operand;
689
+ if (typeof record.min === 'number') {
690
+ const min = record.min, max = record.max;
691
+ return (value) => {
692
+ const observed = parseObservedExpressionNumber(value);
693
+ return observed !== null && observed >= min && observed <= max;
694
+ };
695
+ }
696
+ const min = parseExpressionTimestamp(record.min);
697
+ const max = parseExpressionTimestamp(record.max);
698
+ return (value) => {
699
+ const observed = parseExpressionTimestamp(value);
700
+ return observed !== null && observed >= min && observed <= max;
701
+ };
702
+ }
703
+ case 'in': {
704
+ if (!Array.isArray(operand) || operand.length === 0 || operand.length > MAX_IN_VALUES)
705
+ return null;
706
+ const members = [];
707
+ for (const member of operand) {
708
+ const canonical = scalarExpressionOperand(member);
709
+ if (canonical === null)
710
+ return null;
711
+ members.push(canonical);
712
+ }
713
+ return (value) => members.includes(value);
714
+ }
715
+ default:
716
+ return null;
717
+ }
718
+ }
719
+ function canonicalScalar(value) {
720
+ if (typeof value === 'number')
721
+ return Object.is(value, -0) ? '0' : JSON.stringify(value);
722
+ return String(value);
723
+ }
724
+ function normalizeFact(fact) {
725
+ if (!fact || !['present', 'absent', 'unavailable'].includes(fact.status))
726
+ return { status: 'unavailable' };
727
+ return fact;
728
+ }
729
+ function signalFact(request, field) {
730
+ const own = (record, key) => !!record && Object.prototype.hasOwnProperty.call(record, key);
154
731
  const d = field.indexOf('.');
155
- if (d < 0)
156
- return field === 'referrer' ? [request.headers.referer || request.headers.referrer].filter(Boolean) : [];
732
+ if (d < 0) {
733
+ if (field !== 'referrer')
734
+ return { status: 'absent' };
735
+ const key = own(request.headers, 'referer') ? 'referer' : 'referrer';
736
+ return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
737
+ }
157
738
  const p = field.slice(0, d), k = field.slice(d + 1);
158
739
  if (p === 'query')
159
- return request.query[k] || [];
160
- if (p === 'utm')
161
- return request.query['utm_' + k] || [];
740
+ return own(request.query, k) ? { status: 'present', values: request.query[k] } : { status: 'absent' };
741
+ if (p === 'utm') {
742
+ const key = 'utm_' + k;
743
+ return own(request.query, key) ? { status: 'present', values: request.query[key] } : { status: 'absent' };
744
+ }
162
745
  if (p === 'header') {
163
- const v = request.headers[k.toLowerCase()];
164
- return v ? [v] : [];
746
+ const key = k.toLowerCase();
747
+ return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
165
748
  }
166
- if (p === 'cookie') {
167
- const v = request.cookies[k];
168
- return v ? [v] : [];
749
+ if (p === 'cookie')
750
+ return own(request.cookies, k) ? { status: 'present', values: [request.cookies[k]] } : { status: 'absent' };
751
+ if (p === 'device') {
752
+ // Derived, not injected: no signals plumbing, so preview, edge, and SDK
753
+ // agree by construction. A request with no user-agent header is absent.
754
+ if (k !== 'class')
755
+ return { status: 'absent' };
756
+ const deviceClass = cachedDeviceClass(request);
757
+ return deviceClass ? { status: 'present', values: [deviceClass] } : { status: 'absent' };
169
758
  }
170
759
  if (p === 'geo') {
171
- const g = request.signals && request.signals.geo;
172
- const v = g && g[k.toLowerCase()];
173
- return v ? [v] : [];
760
+ const record = request.signals?.geo;
761
+ if (!record)
762
+ return { status: 'unavailable' };
763
+ return own(record, k) ? { status: 'present', values: [record[k]] } : { status: 'absent' };
174
764
  }
175
- return [];
765
+ if (p === 'time') {
766
+ // The one clock fact (ADR 0038): resolved from the runtime's own clock,
767
+ // one RFC 3339 UTC value, so the kernel stays clock-free. Hosts set
768
+ // request.now (epoch ms) once per request for determinism.
769
+ if (k !== 'now')
770
+ return { status: 'absent' };
771
+ return { status: 'present', values: [cachedTimeNow(request)] };
772
+ }
773
+ return { status: 'absent' };
774
+ }
775
+ const TIME_NOW_CACHE = new WeakMap();
776
+ // cachedTimeNow resolves the clock ONCE per request context, like
777
+ // cachedDeviceClass: every time.now leaf in one evaluation reads the same
778
+ // instant. A host-supplied request.now outside Date's representable range
779
+ // falls back to the runtime clock rather than throwing from a viewer-request
780
+ // path — this module fails closed, never open.
781
+ function cachedTimeNow(request) {
782
+ const hit = TIME_NOW_CACHE.get(request);
783
+ if (hit !== undefined)
784
+ return hit;
785
+ const pinned = typeof request.now === 'number' && Number.isFinite(request.now)
786
+ && Math.abs(request.now) <= 8640000000000000 ? request.now : Date.now();
787
+ const value = new Date(pinned).toISOString();
788
+ TIME_NOW_CACHE.set(request, value);
789
+ return value;
176
790
  }
177
791
  // SHA-256 mirrors experiment/assignment.go: the first eight digest bytes are
178
792
  // interpreted big-endian and reduced modulo 10,000 basis points. Keep the
@@ -257,114 +871,91 @@ function utf8(value) {
257
871
  }
258
872
  return new Uint8Array(out);
259
873
  }
260
- // ── Normalized two-phase model: classify → decide ─────────────────────
874
+ // ── Derived request device class (ADR 0037) ──────────────────────────
261
875
  //
262
- // Phase 1 (classify): evaluate all audience/segment rules page-agnostically
263
- // and assign experiment arms. Returns a Classification that is used by
264
- // Phase 2 for cheap set-membership lookups per route.
876
+ // device.class is the one request fact that is neither transcribed from the
877
+ // request nor injected as a signal: it is classified here from headers the
878
+ // request already carries, so this module, the released SDK copy, and the Go
879
+ // preview evaluator agree without three separate plumbing points.
265
880
  //
266
- // Phase 2 (decide): for a given route record, check which variants match
267
- // the classification (set membership + priority). No rule evaluation —
268
- // just "is this audience in matchedAudiences?" and "is this experiment arm
269
- // the one we assigned?"
270
- //
271
- // The per-route record stays small: just variant keys (e.g.
272
- // "audience=trade") + priorities. Rules live in the classification data,
273
- // evaluated once per request.
881
+ // The ORDER below is the contract, not an implementation detail. It is fixed
882
+ // by internal/domain/audience/testdata/device-class.v1.json, which Go and both
883
+ // TypeScript copies execute. Change the order only by changing that fixture.
274
884
  //
885
+ // The result is approximate and derived from client-controlled input: a
886
+ // personalization convenience, never an authorization or entitlement input.
887
+ export const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
888
+ // Mirrors MaxDeviceHeaderBytes in internal/domain/audience/device.go.
889
+ const MAX_DEVICE_HEADER_BYTES = 512;
890
+ // Tablet runs first because Android tablet agents contain "android" (mobile)
891
+ // and every Android agent contains "linux" (desktop); iPad agents contain
892
+ // neither. Sec-CH-UA-Mobile is a tiebreaker rather than the primary source
893
+ // because UA-CH cannot express tablet at all.
894
+ const DEVICE_TABLET_SIGNATURES = ['ipad', 'tablet', 'playbook', 'kindle', 'silk'];
895
+ // 'iemobile' is deliberately absent: it contains 'mobi' and could never
896
+ // match. Every signature must be reachable given the ones before it.
897
+ const DEVICE_MOBILE_SIGNATURES = ['iphone', 'ipod', 'mobi', 'windows phone', 'blackberry', 'opera mini'];
898
+ const DEVICE_DESKTOP_SIGNATURES = ['windows nt', 'macintosh', 'mac os x', 'x11', 'cros', 'linux'];
275
899
  /**
276
- * Phase 1: Classify the visitor against all release-level rules.
277
- *
278
- * @param ctx The request context (cookies, headers, geo, query)
279
- * @param data Release-level classification data (audiences, segments, experiments)
280
- * @param identity Visitor identity (for experiment bucketing). Pass undefined
281
- * if no identity or privacy denied.
282
- * @returns Classification — matched audiences, segments, and experiment arm assignments
900
+ * Classifies the request's device class from its own headers. Returns '' when
901
+ * the request carried neither User-Agent nor Sec-CH-UA-Mobile, which the fact
902
+ * resolver reports as ABSENT — the same status the missing header itself
903
+ * resolves to, and deliberately distinct from the 'unknown' class, which means
904
+ * classified and unrecognized.
283
905
  */
284
- export function classify(ctx, data, identity) {
285
- // Evaluate audience rules
286
- const matchedAudiences = [];
287
- for (const key in data.audiences) {
288
- const def = data.audiences[key];
289
- if (def.rule && evaluateRule(ctx, def.rule)) {
290
- matchedAudiences.push(key);
291
- }
292
- }
293
- // Evaluate segment rules
294
- const matchedSegments = [];
295
- for (const key in data.segments) {
296
- const def = data.segments[key];
297
- if (def.rule && evaluateRule(ctx, def.rule)) {
298
- matchedSegments.push(key);
299
- }
300
- }
301
- // Assign experiment arms (only for identified visitors)
302
- const experimentArms = {};
303
- if (identity && identity.usable) {
304
- for (const expId in data.experiments) {
305
- const exp = data.experiments[expId];
306
- if (exp.status !== 'running')
307
- continue;
308
- if (exp.kind !== 'ab' && exp.kind !== 'abn')
309
- continue;
310
- const assignment = assignExperiment(exp, identity);
311
- if (assignment) {
312
- experimentArms[expId] = assignment;
313
- }
314
- }
315
- }
316
- return { matchedAudiences, matchedSegments, experimentArms };
906
+ export function deviceClassFromHeaders(headers) {
907
+ const userAgent = deviceHeader(headers, 'user-agent');
908
+ const uaMobile = deviceHeader(headers, 'sec-ch-ua-mobile');
909
+ if (!userAgent && !uaMobile)
910
+ return '';
911
+ const lower = userAgent.toLowerCase();
912
+ if (DEVICE_TABLET_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
913
+ return 'tablet';
914
+ // An Android agent without a "mobi" token is a tablet: Chrome on Android
915
+ // phones appends "Mobile", Android tablets do not. The explicit !"mobi"
916
+ // guard is required, not redundant — the mobile pass runs AFTER this one.
917
+ if (lower.indexOf('android') >= 0 && lower.indexOf('mobi') < 0)
918
+ return 'tablet';
919
+ if (DEVICE_MOBILE_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
920
+ return 'mobile';
921
+ if (uaMobile === '?1')
922
+ return 'mobile';
923
+ if (DEVICE_DESKTOP_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
924
+ return 'desktop';
925
+ if (uaMobile === '?0')
926
+ return 'desktop';
927
+ return 'unknown';
317
928
  }
318
- /**
319
- * Phase 2: Decide the winning variant for a specific route.
320
- *
321
- * Uses the classification from Phase 1 for cheap set-membership lookups.
322
- * No rule evaluation — just set membership and priority comparison.
323
- *
324
- * @param record The route entry (path, dispatch, variants)
325
- * @param classification The visitor's classification from Phase 1
326
- * @returns The decision — winning variant + experiment assignment, or nulls
327
- */
328
- export function decide(record, classification, ctx) {
329
- if (!record.variants)
330
- return { variant: null, experiment: null };
331
- let bestVariant = null;
332
- let bestExperiment = null;
333
- for (const key in record.variants) {
334
- const variant = record.variants[key];
335
- if (!variant.variantKey)
336
- variant.variantKey = key;
337
- const values = variant.variantValues || variantValuesFromKey(key);
338
- // Check if this variant references a paused experiment — skip if so
339
- const experimentID = String(values.experiment || '').trim();
340
- if (experimentID && !classification.experimentArms[experimentID])
341
- continue;
342
- let matched = false;
343
- // Audience membership
344
- if (values.audience && classification.matchedAudiences.includes(values.audience)) {
345
- matched = true;
346
- }
347
- // Segment membership
348
- if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
349
- matched = true;
350
- }
351
- // Experiment arm assignment
352
- if (!matched && experimentID) {
353
- const arm = classification.experimentArms[experimentID];
354
- if (arm && arm.variantKey === variant.variantKey) {
355
- matched = true;
356
- bestExperiment = arm;
357
- }
358
- }
359
- if (matched) {
360
- if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
361
- bestVariant = variant;
362
- // Only set experiment if this variant's experiment arm won
363
- if (!experimentID || !classification.experimentArms[experimentID]) {
364
- bestExperiment = null;
365
- }
366
- }
367
- }
368
- }
369
- return { variant: bestVariant, experiment: bestExperiment };
929
+ // deviceHeader reads one header so that Go's audience.ClassifyDeviceClass
930
+ // cannot disagree with this function about the same request.
931
+ //
932
+ // Keys are lowercase here: every context builder flattens through the Fetch
933
+ // API, which lowercases. There is deliberately NO case-insensitive fallback —
934
+ // the header.* branch above has none either, and adding one for this field
935
+ // only would mean a device rule resolves where an equivalent header rule
936
+ // silently does not.
937
+ //
938
+ // Trimming is ASCII-only on purpose. String.prototype.trim strips U+FEFF but
939
+ // not U+0085; Go's strings.TrimSpace does the reverse. Neither is "right";
940
+ // they only have to match, so both sides trim the same ASCII set. The length
941
+ // bound matches MaxDeviceHeaderBytes in device.go — no signature lives past
942
+ // it, and the header is attacker-sized on an unauthenticated path.
943
+ function deviceHeader(headers, key) {
944
+ const raw = (headers && headers[key]) || '';
945
+ if (typeof raw !== 'string')
946
+ return '';
947
+ return raw.slice(0, MAX_DEVICE_HEADER_BYTES).replace(/^[ \t\r\n\f\v]+|[ \t\r\n\f\v]+$/g, '');
948
+ }
949
+ // The class describes the REQUEST, so it is identical for every leaf. An
950
+ // envelope may carry up to 64 conditions and a route many candidate variants,
951
+ // each building its own resolver — without this, one page view could lowercase
952
+ // and scan the user agent hundreds of times. Keyed on the context object, so
953
+ const DEVICE_CLASS_CACHE = new WeakMap();
954
+ function cachedDeviceClass(request) {
955
+ const hit = DEVICE_CLASS_CACHE.get(request);
956
+ if (hit !== undefined)
957
+ return hit;
958
+ const deviceClass = deviceClassFromHeaders(request.headers);
959
+ DEVICE_CLASS_CACHE.set(request, deviceClass);
960
+ return deviceClass;
370
961
  }