@gradial/aci 0.1.52-rc.9 → 0.1.52

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 (254) hide show
  1. package/CHANGELOG.md +126 -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 -197
  9. package/dist/audience-data-models.js +313 -51
  10. package/dist/cli/compile-registry.mjs +6 -34
  11. package/dist/cli/mintlify-render.mjs +8 -0
  12. package/dist/collect/aci.js +1 -1
  13. package/dist/collect/aci.js.sha256 +1 -1
  14. package/dist/collect/api.d.ts +6 -7
  15. package/dist/collect/api.js +21 -8
  16. package/dist/collect/assignments.d.ts +7 -9
  17. package/dist/collect/assignments.js +23 -32
  18. package/dist/collect/batcher.d.ts +9 -7
  19. package/dist/collect/batcher.js +6 -5
  20. package/dist/collect/consent/envelope.d.ts +7 -1
  21. package/dist/collect/consent/envelope.js +3 -1
  22. package/dist/collect/consent/mapping.d.ts +2 -0
  23. package/dist/collect/consent/mapping.js +1 -0
  24. package/dist/collect/consenthold.d.ts +38 -0
  25. package/dist/collect/consenthold.js +82 -0
  26. package/dist/collect/declarative.js +7 -5
  27. package/dist/collect/gapbuffer.d.ts +13 -4
  28. package/dist/collect/gapbuffer.js +21 -10
  29. package/dist/collect/index.js +78 -21
  30. package/dist/collect/pageviews.d.ts +11 -11
  31. package/dist/collect/pageviews.js +27 -22
  32. package/dist/consent-banner/index.d.ts +17 -0
  33. package/dist/consent-banner/index.js +23 -0
  34. package/dist/content/index.d.ts +2 -1
  35. package/dist/content/index.js +4 -1
  36. package/dist/content/provider.d.ts +16 -2
  37. package/dist/content/provider.js +5 -1
  38. package/dist/content/resolve-slots.d.ts +24 -2
  39. package/dist/content/resolve-slots.js +127 -21
  40. package/dist/content/types.d.ts +5 -1
  41. package/dist/edge/expression-capabilities.v1.d.ts +106 -0
  42. package/dist/edge/expression-capabilities.v1.js +165 -0
  43. package/dist/index.d.ts +1 -2
  44. package/dist/index.js +0 -1
  45. package/dist/internal/domain/edge/identity.d.ts +47 -4
  46. package/dist/internal/domain/edge/identity.js +130 -26
  47. package/dist/internal/domain/edge/index.d.ts +1 -1
  48. package/dist/internal/domain/edge/index.js +1 -1
  49. package/dist/internal/domain/edge/mimic_token.d.ts +14 -0
  50. package/dist/internal/domain/edge/mimic_token.js +125 -0
  51. package/dist/internal/domain/edge/personalization-core.d.ts +61 -22
  52. package/dist/internal/domain/edge/personalization-core.js +790 -200
  53. package/dist/internal/domain/edge/personalization_index.d.ts +17 -0
  54. package/dist/internal/domain/edge/personalization_index.js +55 -0
  55. package/dist/internal/domain/edge/resolve.js +76 -39
  56. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
  57. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
  58. package/dist/internal/domain/edge/types.d.ts +82 -78
  59. package/dist/internal/domain/edge/types.js +3 -3
  60. package/dist/internal/domain/edge/variant_key.d.ts +4 -0
  61. package/dist/internal/domain/edge/variant_key.js +56 -0
  62. package/dist/internal/platform/collectionstore/errors.d.ts +24 -0
  63. package/dist/internal/platform/collectionstore/errors.js +48 -0
  64. package/dist/mintlify/blocks.d.ts +6 -0
  65. package/dist/mintlify/blocks.js +201 -0
  66. package/dist/mintlify/config.d.ts +3 -0
  67. package/dist/mintlify/config.js +77 -0
  68. package/dist/mintlify/index.d.ts +3 -0
  69. package/dist/mintlify/index.js +2 -0
  70. package/dist/mintlify/json.d.ts +8 -0
  71. package/dist/mintlify/json.js +37 -0
  72. package/dist/mintlify/materialize.d.ts +14 -0
  73. package/dist/mintlify/materialize.js +254 -0
  74. package/dist/mintlify/types.d.ts +74 -0
  75. package/dist/mintlify/types.js +1 -0
  76. package/dist/mintlify/validate.d.ts +7 -0
  77. package/dist/mintlify/validate.js +256 -0
  78. package/dist/next/assignments-navigation.d.ts +4 -0
  79. package/dist/next/assignments-navigation.js +18 -0
  80. package/dist/next/assignments-script.d.ts +3 -2
  81. package/dist/next/assignments-script.js +5 -5
  82. package/dist/next/error-pages.d.ts +4 -0
  83. package/dist/next/error-pages.js +4 -0
  84. package/dist/next/error-seat-payload.d.ts +2 -1
  85. package/dist/next/error-seat-payload.js +14 -24
  86. package/dist/next/error-seat.js +7 -2
  87. package/dist/next/index.d.ts +1 -1
  88. package/dist/next/index.js +3 -1
  89. package/dist/next/page.d.ts +2 -1
  90. package/dist/next/page.js +15 -4
  91. package/dist/next/release-context.d.ts +30 -0
  92. package/dist/next/release-context.js +56 -0
  93. package/dist/next/render.d.ts +1 -1
  94. package/dist/next/render.js +15 -5
  95. package/dist/next/server.d.ts +9 -3
  96. package/dist/next/server.js +17 -32
  97. package/dist/preview/core.d.ts +1 -0
  98. package/dist/preview/core.js +27 -0
  99. package/dist/react/VideoPlayer.d.ts +3 -3
  100. package/dist/react/index.d.ts +3 -3
  101. package/dist/registry.d.ts +4 -2
  102. package/dist/testing/conformance/checks.d.ts +2 -2
  103. package/dist/testing/conformance/checks.js +2 -0
  104. package/dist/testing/conformance/index.d.ts +1 -0
  105. package/dist/testing/conformance/index.js +1 -0
  106. package/dist/testing/conformance/next-stubs.d.ts +6 -0
  107. package/dist/testing/conformance/next-stubs.js +9 -0
  108. package/dist/testing/conformance/preset.d.ts +3 -2
  109. package/dist/testing/conformance/preset.js +15 -8
  110. package/dist/testing/conformance/root-identity.d.ts +31 -0
  111. package/dist/testing/conformance/root-identity.js +361 -0
  112. package/dist/testing/conformance/stubs/next-image.d.ts +7 -0
  113. package/dist/testing/conformance/stubs/next-image.js +5 -0
  114. package/dist/testing/conformance/stubs/next-link.d.ts +7 -0
  115. package/dist/testing/conformance/stubs/next-link.js +4 -0
  116. package/dist/testing/conformance/types.d.ts +5 -2
  117. package/dist/testing/index.d.ts +1 -1
  118. package/dist/testing/index.js +4 -3
  119. package/dist/types/block-ref.js +2 -2
  120. package/dist/types/component.d.ts +9 -0
  121. package/dist/types/config.d.ts +0 -1
  122. package/dist/types/index.d.ts +0 -1
  123. package/dist/types/index.js +0 -1
  124. package/dist/types/page.d.ts +0 -1
  125. package/migrations/conformance-registry.md +22 -0
  126. package/migrations/experiment-goal-ids.md +43 -0
  127. package/package.json +21 -7
  128. package/src/cli/compile-registry.mjs +6 -30
  129. package/src/cli/mintlify-render.mjs +9 -0
  130. package/src/types/block-ref.test.ts +16 -1
  131. package/src/types/block-ref.ts +2 -2
  132. package/src/types/component.ts +10 -0
  133. package/src/types/config.ts +0 -1
  134. package/src/types/index.ts +0 -1
  135. package/src/types/page.ts +0 -1
  136. package/templates/astro/starter-content/src/components/chrome/Footer.astro +5 -3
  137. package/templates/astro/starter-content/src/components/chrome/Header.astro +5 -3
  138. package/templates/astro/starter-content/src/components/elements/Button.astro +5 -2
  139. package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +5 -3
  140. package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -3
  141. package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -3
  142. package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -3
  143. package/templates/astro/starter-content/src/components/sections/HomeHero.astro +5 -3
  144. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -3
  145. package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -3
  146. package/templates/astro/starter-content/src/fixtures/sections.ts +1 -1
  147. package/templates/astro/starter-content/src/stories/CompositionStoryFrame.astro +7 -2
  148. package/templates/astro/template/.aci.yaml.tmpl +0 -1
  149. package/templates/astro/template/package.json.tmpl +5 -5
  150. package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
  151. package/templates/common/.agents/skills/aci/SKILL.md +58 -1
  152. package/templates/common/.agents/skills/authoring/SKILL.md +194 -42
  153. package/templates/common/.agents/skills/authoring/references/block-registry.md +19 -3
  154. package/templates/common/.agents/skills/authoring/references/contracts.md +9 -30
  155. package/templates/common/.agents/skills/authoring/references/repo-structure.md +5 -4
  156. package/templates/common/.agents/skills/authoring/references/sdk-server-functions.md +3 -3
  157. package/templates/common/.agents/skills/dam-delivery/SKILL.md +216 -0
  158. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +167 -0
  159. package/templates/common/.agents/skills/migration/references/canon-migration.md +18 -67
  160. package/templates/common/.agents/skills/migration/references/selector-map.md +1 -1
  161. package/templates/common/.content/fragments/navbar/_index.json +24 -0
  162. package/templates/common/.content/fragments/site-footer/_index.json +61 -0
  163. package/templates/common/.content/pages/404/_index.json +4 -2
  164. package/templates/common/.content/pages/_index.json +0 -1
  165. package/templates/common/.content/pages/error/_index.json +4 -2
  166. package/templates/common/.content/schemas/site.schema.json +11 -1
  167. package/templates/common/.content/templates/error.json +24 -0
  168. package/templates/common/.content/templates/home.json +28 -0
  169. package/templates/common/.content/templates/site.json +8 -0
  170. package/templates/common/.content/templates/standard.json +24 -0
  171. package/templates/common/.nvmrc +1 -1
  172. package/templates/common/starter-content/.content/config/site.json +1 -1
  173. package/templates/common/starter-content/.content/dam/README.md +74 -0
  174. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/asset.json +1 -1
  175. package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/asset.json +1 -1
  176. package/templates/common/starter-content/.content/pages/404/_index.json +1 -2
  177. package/templates/common/starter-content/.content/pages/{home/_index.json → _index.json} +14 -3
  178. package/templates/common/starter-content/.content/pages/about/_index.json +2 -2
  179. package/templates/common/starter-content/.content/pages/error/_index.json +1 -2
  180. package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
  181. package/templates/common/starter-content/.content/patterns/components/container.json +8 -3
  182. package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
  183. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +5 -3
  184. package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
  185. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +2 -2
  186. package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
  187. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
  188. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
  189. package/templates/common/starter-content/.content/schemas/site.schema.json +11 -1
  190. package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +4 -3
  191. package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +4 -3
  192. package/templates/nextjs/starter-content/src/components/elements/Button.tsx +4 -3
  193. package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +4 -3
  194. package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +4 -2
  195. package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +4 -3
  196. package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +4 -2
  197. package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +4 -3
  198. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +4 -2
  199. package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +4 -3
  200. package/templates/nextjs/template/.aci.yaml +0 -1
  201. package/templates/nextjs/template/.aci.yaml.tmpl +0 -1
  202. package/templates/nextjs/template/package.json.tmpl +4 -3
  203. package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
  204. package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
  205. package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
  206. package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
  207. package/templates/nextjs/template/src/app/fonts/inter/OFL.txt +93 -0
  208. package/templates/nextjs/template/src/app/fonts/inter/inter-latin.woff2 +0 -0
  209. package/templates/nextjs/template/src/app/fonts/space-grotesk/OFL.txt +93 -0
  210. package/templates/nextjs/template/src/app/fonts/space-grotesk/space-grotesk-latin.woff2 +0 -0
  211. package/templates/nextjs/template/src/app/layout.tsx +12 -7
  212. package/templates/nextjs/template/tests/conformance.test.ts.tmpl +1 -0
  213. package/dist/define-layout.d.ts +0 -6
  214. package/dist/define-layout.js +0 -14
  215. package/dist/edge/personalization-core.d.ts +0 -11
  216. package/dist/edge/personalization-core.js +0 -269
  217. package/dist/internal/domain/edge/compose.d.ts +0 -24
  218. package/dist/internal/domain/edge/compose.js +0 -89
  219. package/dist/internal/domain/edge/record/codec.d.ts +0 -71
  220. package/dist/internal/domain/edge/record/codec.js +0 -401
  221. package/dist/next/collect-route.d.ts +0 -19
  222. package/dist/next/collect-route.js +0 -459
  223. package/dist/next/edge-config.d.ts +0 -1
  224. package/dist/next/edge-config.js +0 -92
  225. package/dist/next/envelope-digest.d.ts +0 -21
  226. package/dist/next/envelope-digest.js +0 -58
  227. package/dist/next/middleware.d.ts +0 -40
  228. package/dist/next/middleware.js +0 -475
  229. package/dist/next/personalization-host.d.ts +0 -134
  230. package/dist/next/personalization-host.js +0 -270
  231. package/dist/next/root-layout.d.ts +0 -4
  232. package/dist/next/root-layout.js +0 -5
  233. package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
  234. package/dist/next/vercel-collect-sdk-route.js +0 -181
  235. package/dist/next/vercel-edge-projection-v1.d.ts +0 -24
  236. package/dist/next/vercel-edge-projection-v1.js +0 -880
  237. package/dist/next/vercel-personalization.d.ts +0 -4
  238. package/dist/next/vercel-personalization.js +0 -4
  239. package/dist/next/vercel-s3-personalization.d.ts +0 -79
  240. package/dist/next/vercel-s3-personalization.js +0 -570
  241. package/dist/types/layout.d.ts +0 -24
  242. package/src/types/layout.ts +0 -29
  243. package/templates/astro/starter-content/src/render/RenderPage.astro +0 -67
  244. package/templates/astro/template/src/cms/contracts/layouts/index.ts +0 -18
  245. package/templates/common/starter-content/.content/patterns/layouts/default.json +0 -14
  246. package/templates/nextjs/starter-content/src/components/elements/AtomicBlock.tsx +0 -13
  247. package/templates/nextjs/template/src/cms/contracts/layouts/index.ts +0 -16
  248. /package/dist/{types/layout.js → cli/mintlify-render.d.mts} +0 -0
  249. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/editorial.json +0 -0
  250. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000101}/governance.json +0 -0
  251. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000101}/versions/v0001.json +0 -0
  252. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/editorial.json +0 -0
  253. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_home_hero → 000000000000000000000000102}/governance.json +0 -0
  254. /package/templates/common/starter-content/.content/dam/folders/seed/{ast_site_about_hero → 000000000000000000000000102}/versions/v0001.json +0 -0
@@ -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,55 @@
1
+ import { compareUTF8, variantValuesFromKey } from './variant_key.js';
2
+ const preparedSelection = Symbol('prepared personalization selection');
3
+ // Call once when an immutable partition is decoded. The derived index is a
4
+ // non-enumerable symbol property, so it cannot alter any serialized record.
5
+ export function preparePersonalizationRoute(route, experiments) {
6
+ const existing = route[preparedSelection];
7
+ if (existing && existing.sourceExperiments === experiments)
8
+ return route;
9
+ return Object.freeze(Object.defineProperty({ ...route }, preparedSelection, { value: deriveSelection(route, experiments) }));
10
+ }
11
+ export function personalizationSelection(route, experiments) {
12
+ return preparePersonalizationRoute(route, experiments)[preparedSelection];
13
+ }
14
+ function deriveSelection(route, experiments) {
15
+ const authored = [];
16
+ const arms = Object.create(null);
17
+ const legacy = new Set();
18
+ const qualified = new Set();
19
+ for (const [key, variant] of Object.entries(route.variants || {})) {
20
+ const values = Object.freeze({ ...(variant.variantValues || variantValuesFromKey(key)) });
21
+ // DEBT(edge-functions): ADR-0045 bounded pair reader and counter. Remove
22
+ // only when every retained release in every tenant compiles under the
23
+ // post-removal compiler; pair entries then become malformed.
24
+ if (isPairVariant(key, values)) {
25
+ const id = values.experiment || key.split(';').find((part) => part.startsWith('experiment='))?.slice('experiment='.length);
26
+ if (id && experiments?.[id])
27
+ legacy.add(id);
28
+ continue;
29
+ }
30
+ const names = Object.keys(values);
31
+ const ids = names.filter((name) => name.startsWith('experiment.')).map((name) => name.slice('experiment.'.length));
32
+ for (const id of ids)
33
+ qualified.add(id);
34
+ if (ids.length === 1 && names.length === 1) {
35
+ const id = ids[0];
36
+ (arms[id] ||= Object.create(null))[values[`experiment.${id}`]] = variant;
37
+ }
38
+ const factors = ids.map((id) => experiments?.[id]);
39
+ if (factors.some((item) => !item || item.kind !== 'xt' || item.status !== 'running'))
40
+ continue;
41
+ authored.push(Object.freeze({ key, variant, values, experiments: Object.freeze(factors) }));
42
+ }
43
+ for (const id of qualified)
44
+ legacy.delete(id);
45
+ authored.sort((left, right) => (right.variant.priority || 0) - (left.variant.priority || 0) || compareUTF8(left.key, right.key));
46
+ const scoped = Object.keys(experiments || {}).sort(compareUTF8).map((id) => experiments[id])
47
+ .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running' && !legacy.has(item.id));
48
+ for (const entries of Object.values(arms))
49
+ Object.freeze(entries);
50
+ return Object.freeze({ sourceExperiments: experiments, authored: Object.freeze(authored), experiments: Object.freeze(scoped), arms: Object.freeze(arms), tuples: route.variants || {}, legacyExperimentExclusions: legacy.size });
51
+ }
52
+ function isPairVariant(key, values) {
53
+ const names = [...key.split(';').map((part) => part.split('=')[0]), ...Object.keys(values)];
54
+ return names.some((name) => name.toLowerCase() === 'experiment' || name.toLowerCase() === 'experimentarm');
55
+ }
@@ -1,58 +1,91 @@
1
1
  // resolvePersonalizationRequest — the edge decisioning entry point.
2
2
  //
3
- // Reads cookies for classification but never writes them (R25: consent-gated
4
- // identity model). Identity minting happens in the collect/consent endpoint,
5
- // not here. Public personalized responses may be private while providers cache
6
- // the selected immutable artifact by release and exact artifact path.
3
+ // Reads cookies for classification but never writes them. It may issue a
4
+ // first-party identity in memory when the host permits (ADR 0048); persisting
5
+ // that identity is the host's job. Public personalized responses may be
6
+ // private while providers cache the selected immutable artifact by release and
7
+ // exact artifact path.
7
8
  import { resolvePersonalizationDecision, } from './personalization-core.js';
8
- import { consentSignals, verifyIdentityCookie, identityHashHex, verifyClaims, parseCookieHeader, } from './identity.js';
9
+ import { consentSignals, verifyIdentityCookie, identityHashHex, randomIdentity, verifyClaims, parseCookieHeader, } from './identity.js';
9
10
  export async function resolvePersonalizationRequest(request, config) {
10
11
  const cookies = parseCookieHeader(request.headers.get('cookie'));
11
12
  const consent = consentSignals(request, cookies);
12
13
  const now = (config.now ?? (() => new Date()))();
13
- // Consent-gated identity model (R25): static artifact selection NEVER mints
14
- // identity. It reads cookies for classification but never writes them.
15
- // Identity (aci_id) is minted by the collect/consent endpoint after
16
- // explicit consent. This keeps all content responses cacheable and is
17
- // GDPR-compliant (no non-essential cookie before consent).
14
+ // Identity model (ADR 0016 as amended by ADR 0048): experiment bucketing is
15
+ // measurement, keyed by the first-party aci_id. A verified cookie (or the
16
+ // provider bootstrap's identity) is used as presented. With none usable, and
17
+ // only when the host's issuance rule permits it for this request, resolve
18
+ // issues a fresh identity so the request that issues it is also bucketed —
19
+ // an unverifiable cookie is replaced the same way. Persisting the issued
20
+ // identity is the host's decision (page responses only, never shared-cached).
21
+ // Claims and memberships stay gated on the personalization grant.
18
22
  //
19
- // GPC/DNT deny fully: no read, no identity, no experiment bucketing.
20
- const rawIdentity = consent.privacyDenied
23
+ // GPC/DNT deny fully: no read, no identity, no issuance, no bucketing.
24
+ //
25
+ // Mimic (specs/features/audience-mimic): a validated mimicClaims input
26
+ // bypasses identity resolution entirely — the session reads no aci_id and
27
+ // no aci_claims (D-2/D-7), so identityHash stays empty and the experiment
28
+ // tier is structurally skipped (D-5). An ARMS-bearing token is the D-17
29
+ // exception: the experiment tier runs in forced mode — the token's named
30
+ // arm replaces the dice for each experiment the request selects through
31
+ // the ordinary gate — and identity still never exists (I-17).
32
+ const mimic = config.mimicClaims;
33
+ const verifiedVisitor = mimic || consent.privacyDenied ? undefined : config.verifiedVisitor;
34
+ const presentedIdentity = mimic || consent.privacyDenied
21
35
  ? null
22
- : await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
36
+ : verifiedVisitor
37
+ ? verifiedVisitor.rawIdentity
38
+ : await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
39
+ const issuedIdentity = !presentedIdentity && !mimic && !consent.privacyDenied && config.issueIdentity
40
+ ? randomIdentity(config.randomBytes)
41
+ : undefined;
42
+ const rawIdentity = presentedIdentity || issuedIdentity || null;
23
43
  // The identity hash feeds experiment bucketing for identified visitors.
24
- // Claims are a signed cookie written by /collect; the EdgeRouter
25
- // never reads the customer object store to refresh or revoke them.
26
- let signedClaims = cookies.get('aci_claims') || '';
44
+ // Claims are a signed cookie written by /collect or the provider identity
45
+ // bootstrap; the EdgeRouter never reads the customer object store to
46
+ // refresh or revoke them.
27
47
  let identityHash = '';
28
48
  if (rawIdentity) {
29
49
  identityHash = await identityHashHex('aci_id', rawIdentity);
30
50
  }
31
- if (rawIdentity && consent.allowsIdentityTier && signedClaims) {
32
- if (!await verifyClaims(signedClaims, config.claimsKeys || {}, now)) {
33
- signedClaims = '';
34
- }
35
- }
36
51
  const matchedRoute = config.route;
37
52
  let variantKey = '';
38
53
  if (matchedRoute) {
39
- const claims = consent.allowsIdentityTier
40
- ? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
41
- : null;
42
- const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request), matchedRoute, claims, config.experiments, identityHash
54
+ const claims = mimic
55
+ ? { aud: mimic.aud, seg: mimic.seg, iat: 0, exp: 0 }
56
+ : consent.allowsIdentityTier
57
+ ? verifiedVisitor
58
+ ? verifiedVisitor.claims
59
+ : await verifyClaims(cookies.get('aci_claims') || '', config.claimsKeys || {}, now)
60
+ : null;
61
+ const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request, now, config.requestGeo, config.onRuleRejected), matchedRoute, claims, config.experiments, identityHash
43
62
  ? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
44
- : undefined);
63
+ : undefined, mimic?.arms);
45
64
  if (decision.variant) {
46
65
  variantKey = decision.variant.variantKey;
47
66
  }
48
- const experiment = decision.experiment
49
- ? { id: decision.experiment.experimentId, arm: decision.experiment.armId, allocationVersion: decision.experiment.allocationVersion }
50
- : undefined;
51
- return { variantKey, experiment };
67
+ const experiments = decision.experiments.map((item) => ({
68
+ id: item.experimentId,
69
+ arm: item.armId,
70
+ allocationVersion: item.allocationVersion,
71
+ ...(item.forced ? { forced: true } : {}),
72
+ }));
73
+ return {
74
+ variantKey,
75
+ experiments,
76
+ ...(decision.legacyExperimentExclusions ? { legacyExperimentExclusions: decision.legacyExperimentExclusions } : {}),
77
+ ...(issuedIdentity ? { issuedIdentity } : {}),
78
+ };
52
79
  }
53
- return { variantKey };
80
+ return { variantKey, experiments: [], ...(issuedIdentity ? { issuedIdentity } : {}) };
54
81
  }
55
- function toPersonalizationRequestContext(request) {
82
+ // The return type is annotated rather than inferred so the contract is stated
83
+ // at the construction site: a misspelled property would then silently never
84
+ // fire at the sink, which is the exact failure mode this context exists to
85
+ // make visible. No tsconfig covers internal/domain/** today (esbuild/tsx strip
86
+ // types without checking), so the annotation is documentation now and becomes
87
+ // real protection under any future typecheck of these trees.
88
+ function toPersonalizationRequestContext(request, now, requestGeo, onRuleRejected) {
56
89
  const url = new URL(request.url);
57
90
  const cookies = parseCookieHeader(request.headers.get('cookie'));
58
91
  const query = {};
@@ -60,17 +93,21 @@ function toPersonalizationRequestContext(request) {
60
93
  (query[key] ||= []).push(value);
61
94
  });
62
95
  const headers = Object.fromEntries(request.headers.entries());
96
+ // geo.* facts come only from the host's trusted geo. Reading provider geo
97
+ // headers here trusted whatever the viewer sent on every lane whose
98
+ // provider does not stamp that exact header before the decision runs.
99
+ const geo = Object.fromEntries(Object.entries(requestGeo ?? {})
100
+ .filter((entry) => typeof entry[1] === 'string' && entry[1] !== ''));
63
101
  return {
64
102
  path: url.pathname,
65
103
  query,
66
104
  headers,
67
105
  cookies: Object.fromEntries(cookies.entries()),
68
- signals: {
69
- geo: {
70
- country: headers['x-vercel-ip-country'] || headers['cloudfront-viewer-country'] || '',
71
- region: headers['x-vercel-ip-country-region'] || headers['cloudfront-viewer-country-region'] || '',
72
- city: headers['x-vercel-ip-city'] || headers['cloudfront-viewer-city'] || '',
73
- },
74
- },
106
+ // The host's pinned per-request clock resolves the time.now fact
107
+ // (ADR 0038), so claims verification and window evaluation read one
108
+ // instant — and config.now-injected tests are deterministic.
109
+ now: now.getTime(),
110
+ ...(Object.keys(geo).length > 0 ? { signals: { geo } } : {}),
111
+ ...(onRuleRejected ? { onRuleRejected } : {}),
75
112
  };
76
113
  }
@@ -0,0 +1,10 @@
1
+ export interface FrozenExpressionDiagnostic {
2
+ code: string;
3
+ path: string;
4
+ scope: 'request';
5
+ message: string;
6
+ }
7
+ export declare function frozenValidateRequestExpression(rule: {
8
+ all?: unknown[];
9
+ any?: unknown[];
10
+ }): FrozenExpressionDiagnostic[];
@@ -0,0 +1,253 @@
1
+ // FROZEN FIXTURE — the request-expression validation path as it stood before
2
+ // ADR 0040 added nested boolean expression trees, vendored from
3
+ // `internal/domain/edge/personalization-core.ts` at commit 1b785e4ac^ (the
4
+ // parent of the ADR 0040 landing commit). Do NOT update this file to track the
5
+ // live validator: its whole value is that it is stale.
6
+ //
7
+ // It exists so one test can reproduce the incident ADR 0040 predicts and says
8
+ // it could not previously debug — a lane running a flat-only evaluator meeting
9
+ // a record carrying a valid nested rule. That evaluator has no concept of a
10
+ // group node, so it classifies `{all:[...]}` as a leaf condition and reports
11
+ // the leaf vocabulary as unsupported. The resulting diagnostics are what the
12
+ // host recorder actually writes during real version skew, and they are NOT the
13
+ // same as those produced by a current, nesting-aware validator refusing a
14
+ // malformed rule.
15
+ //
16
+ // What is vendored: the COMPLETE validation path — `validateRequestExpression`
17
+ // and `validateRequestCondition` with every branch (per-field operator
18
+ // restrictions for device.class / time.now / audience.memberships, and every
19
+ // value check), plus each helper those functions reach
20
+ // (`expressionNumberInDomain`, `parseExpressionTimestamp`,
21
+ // `expressionDaysInMonth`, `canonicalScalar`, `validExpressionRange`,
22
+ // `homogeneousExpressionMembers`, and the DEVICE_CLASSES / MAX_IN_VALUES /
23
+ // grammar constants). Evaluation, matching, and the fact vocabulary are
24
+ // irrelevant to the record's shape and are deliberately absent.
25
+ //
26
+ // Adaptations from the source commit (everything not listed here is verbatim):
27
+ // 1. Entry-point signature. The original `validateRequestExpression` was
28
+ // 3-argument — its callers pre-extracted `all`/`any` with `rule.all || []`.
29
+ // The frozen export is 1-argument and derives them itself with
30
+ // `Array.isArray` guards (strictly stronger than the original `|| []`) so
31
+ // the fixture is safe to call directly on untrusted record data.
32
+ // 2. The diagnostic type is renamed `FrozenExpressionDiagnostic` (from
33
+ // `ExpressionDiagnostic`) so a frozen diagnostic can never be confused
34
+ // with a live one at a call site.
35
+ // 3. Export keywords were removed from the vendored internals; only the
36
+ // entry point and its diagnostic type are exported.
37
+ const MAX_EXPRESSION_CONDITIONS = 64;
38
+ const EXPRESSION_OPERATORS = [
39
+ 'equals', 'contains', 'startsWith', 'exists',
40
+ 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
41
+ 'before', 'after', 'between', 'in',
42
+ ];
43
+ const MAX_IN_VALUES = 32;
44
+ // Observed values are gated on the published grammars before any host parser
45
+ // runs (ADR 0038): Number() and Date.parse accept supersets (hex, empty
46
+ // string, vendor date formats) that would let the same fact match here and
47
+ // not in Go. Keep these literals identical to the capability artifact — the
48
+ // drift test compares them byte-for-byte.
49
+ const EXPRESSION_NUMBER_GRAMMAR = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
50
+ 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})$/;
51
+ const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
52
+ // Adaptation 1 (see header): the original was 3-argument with caller-extracted
53
+ // `all`/`any`; this 1-argument form derives them with Array.isArray guards.
54
+ // The body below this line is verbatim from the source commit.
55
+ export function frozenValidateRequestExpression(rule) {
56
+ const all = (Array.isArray(rule.all) ? rule.all : []);
57
+ const any = (Array.isArray(rule.any) ? rule.any : []);
58
+ const diagnostics = [];
59
+ if (all.length + any.length === 0)
60
+ diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
61
+ if (all.length + any.length > MAX_EXPRESSION_CONDITIONS)
62
+ diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
63
+ Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
64
+ diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
65
+ });
66
+ for (const [group, conditions] of [['all', all], ['any', any]]) {
67
+ conditions.forEach((condition, index) => frozenValidateRequestCondition(condition, `expression.${group}[${index}]`, diagnostics));
68
+ }
69
+ return diagnostics;
70
+ }
71
+ // Verbatim from the source commit: the full flat-era condition validator,
72
+ // including the per-field operator restrictions and every value check.
73
+ function frozenValidateRequestCondition(condition, path, diagnostics) {
74
+ Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
75
+ diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
76
+ });
77
+ const field = String(condition.field || '');
78
+ 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)) {
79
+ diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
80
+ }
81
+ const operator = String(condition.operator || '');
82
+ if (!EXPRESSION_OPERATORS.includes(operator)) {
83
+ diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
84
+ return;
85
+ }
86
+ // device.class compares against a closed four-value set, so substring
87
+ // operators express nothing the set cannot — and admitting them would
88
+ // re-create the user-agent-substring habit inside the safe field (ADR 0037).
89
+ if (field === 'device.class' && !['equals', 'in', 'exists'].includes(operator)) {
90
+ 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` });
91
+ return;
92
+ }
93
+ // time.now is a sub-second-churning clock: string operators are silently
94
+ // inert on it and the numeric family can never match an RFC 3339 string
95
+ // (ADR 0038).
96
+ if (field === 'time.now' && !['before', 'after', 'between', 'exists'].includes(operator)) {
97
+ 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` });
98
+ return;
99
+ }
100
+ // audience.memberships is an opaque-ID list: equals is already
101
+ // any-member-equals under the multi-valued match loop, so the substring
102
+ // family would only add silent overmatching of prefixed IDs, and the
103
+ // numeric/instant families can never match an ID (ADR 0039).
104
+ if (field === 'audience.memberships' && !['equals', 'in', 'exists'].includes(operator)) {
105
+ 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` });
106
+ return;
107
+ }
108
+ const value = condition.value;
109
+ if (operator === 'exists') {
110
+ if (value !== undefined && value !== null && typeof value !== 'boolean') {
111
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
112
+ }
113
+ return;
114
+ }
115
+ if (value === undefined || value === null) {
116
+ diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
117
+ return;
118
+ }
119
+ if (['equals', 'contains', 'startsWith'].includes(operator)) {
120
+ if (!['string', 'number', 'boolean'].includes(typeof value)
121
+ || (typeof value === 'number' && !expressionNumberInDomain(value))) {
122
+ 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` });
123
+ }
124
+ else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
125
+ 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)}` });
126
+ }
127
+ return;
128
+ }
129
+ if (['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual'].includes(operator)) {
130
+ if (typeof value !== 'number' || !expressionNumberInDomain(value)) {
131
+ 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}` });
132
+ }
133
+ return;
134
+ }
135
+ if (operator === 'before' || operator === 'after') {
136
+ if (typeof value !== 'string' || parseExpressionTimestamp(value) === null) {
137
+ 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}` });
138
+ }
139
+ return;
140
+ }
141
+ if (operator === 'between') {
142
+ if (!validExpressionRange(value)) {
143
+ 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` });
144
+ }
145
+ else if (field === 'time.now' && typeof value.min === 'number') {
146
+ // An instant field's observed values are instants: a numeric range on
147
+ // it would validate and then sit silently inert at execution.
148
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value bounds must be timestamps for field ${JSON.stringify(field)}` });
149
+ }
150
+ return;
151
+ }
152
+ // in: bounded, non-empty, every member one canonicalizable scalar, all
153
+ // members one JSON type — a mixed list is almost always an authoring bug.
154
+ const members = homogeneousExpressionMembers(value);
155
+ if (members === null) {
156
+ 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` });
157
+ return;
158
+ }
159
+ if (field === 'device.class') {
160
+ for (const member of members) {
161
+ if (DEVICE_CLASSES.indexOf(member) < 0) {
162
+ 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)}` });
163
+ return;
164
+ }
165
+ }
166
+ }
167
+ }
168
+ // ── Validation helpers, verbatim from the source commit ──────────────────
169
+ function expressionNumberInDomain(value) {
170
+ if (!Number.isFinite(value))
171
+ return false;
172
+ if (Number.isInteger(value) && !Number.isSafeInteger(value))
173
+ return false;
174
+ return true;
175
+ }
176
+ // Explicit calendar validation runs before Date arithmetic because Date.UTC
177
+ // (like Go's time.Date) silently normalizes impossible dates. Instants
178
+ // compare as epoch milliseconds; fractional seconds truncate to milliseconds.
179
+ function parseExpressionTimestamp(value) {
180
+ const match = EXPRESSION_TIMESTAMP_GRAMMAR.exec(value);
181
+ if (!match)
182
+ return null;
183
+ const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
184
+ const hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6]);
185
+ if (month < 1 || month > 12 || day < 1 || day > expressionDaysInMonth(year, month))
186
+ return null;
187
+ if (hour > 23 || minute > 59 || second > 59)
188
+ return null;
189
+ let offsetMinutes = 0;
190
+ if (match[8] !== 'Z') {
191
+ const offsetHour = Number(match[8].slice(1, 3)), offsetMinute = Number(match[8].slice(4, 6));
192
+ if (offsetHour > 23 || offsetMinute > 59)
193
+ return null;
194
+ offsetMinutes = offsetHour * 60 + offsetMinute;
195
+ if (match[8][0] === '-')
196
+ offsetMinutes = -offsetMinutes;
197
+ }
198
+ const fractionMillis = match[7] ? Number((match[7].slice(1) + '00').slice(0, 3)) : 0;
199
+ // setUTCFullYear, not Date.UTC: Date.UTC maps two-digit years to 1900+.
200
+ const date = new Date(0);
201
+ date.setUTCFullYear(year, month - 1, day);
202
+ date.setUTCHours(hour, minute, second, 0);
203
+ return date.getTime() - offsetMinutes * 60000 + fractionMillis;
204
+ }
205
+ function expressionDaysInMonth(year, month) {
206
+ if (month === 4 || month === 6 || month === 9 || month === 11)
207
+ return 30;
208
+ if (month === 2)
209
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
210
+ return 31;
211
+ }
212
+ function canonicalScalar(value) {
213
+ if (typeof value === 'number')
214
+ return Object.is(value, -0) ? '0' : JSON.stringify(value);
215
+ return String(value);
216
+ }
217
+ function validExpressionRange(value) {
218
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
219
+ return false;
220
+ const record = value;
221
+ if (Object.keys(record).some((key) => key !== 'min' && key !== 'max'))
222
+ return false;
223
+ const min = record.min, max = record.max;
224
+ if (min === undefined || min === null || max === undefined || max === null)
225
+ return false;
226
+ if (typeof min === 'number' && expressionNumberInDomain(min)) {
227
+ return typeof max === 'number' && expressionNumberInDomain(max) && min <= max;
228
+ }
229
+ const minMillis = typeof min === 'string' ? parseExpressionTimestamp(min) : null;
230
+ if (minMillis === null)
231
+ return false;
232
+ const maxMillis = typeof max === 'string' ? parseExpressionTimestamp(max) : null;
233
+ return maxMillis !== null && minMillis <= maxMillis;
234
+ }
235
+ function homogeneousExpressionMembers(value) {
236
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IN_VALUES)
237
+ return null;
238
+ let memberKind = '';
239
+ const members = [];
240
+ for (const member of value) {
241
+ const kind = typeof member;
242
+ if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
243
+ return null;
244
+ if (kind === 'number' && !expressionNumberInDomain(member))
245
+ return null;
246
+ if (!memberKind)
247
+ memberKind = kind;
248
+ else if (memberKind !== kind)
249
+ return null;
250
+ members.push(canonicalScalar(member));
251
+ }
252
+ return members;
253
+ }