@gradial/aci 0.1.52-rc.1 → 0.1.52-rc.10

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 (58) hide show
  1. package/README.md +4 -4
  2. package/dist/audience-data-models.d.ts +9 -0
  3. package/dist/audience-data-models.js +3 -0
  4. package/dist/cli/compile-registry.d.mts +5 -1
  5. package/dist/cli/compile-registry.mjs +25 -10
  6. package/dist/cli/validate-content.d.mts +31 -1
  7. package/dist/cli/validate-content.mjs +50 -12
  8. package/dist/content/provider.d.ts +1 -0
  9. package/dist/content/resolve-slots.d.ts +9 -2
  10. package/dist/content/resolve-slots.js +5 -3
  11. package/dist/content/types.d.ts +2 -0
  12. package/dist/edge/personalization-core.js +13 -1
  13. package/dist/internal/domain/edge/compose.d.ts +24 -0
  14. package/dist/internal/domain/edge/compose.js +89 -0
  15. package/dist/internal/domain/edge/index.d.ts +1 -0
  16. package/dist/internal/domain/edge/index.js +1 -0
  17. package/dist/internal/domain/edge/personalization-core.d.ts +4 -1
  18. package/dist/internal/domain/edge/personalization-core.js +25 -8
  19. package/dist/internal/domain/edge/resolve.js +6 -4
  20. package/dist/internal/domain/edge/types.d.ts +12 -0
  21. package/dist/next/error-seat-payload.js +1 -1
  22. package/dist/next/page.js +2 -2
  23. package/dist/next/vercel-edge-projection-v1.js +7 -1
  24. package/dist/providers/s3.js +11 -4
  25. package/dist/types/block-ref.d.ts +2 -0
  26. package/dist/types/block-ref.js +1 -0
  27. package/package.json +7 -7
  28. package/src/cli/compile-registry.mjs +24 -10
  29. package/src/cli/validate-content.mjs +52 -13
  30. package/src/types/block-ref.test.ts +45 -0
  31. package/src/types/block-ref.ts +2 -0
  32. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  33. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  34. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  35. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  36. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  37. package/templates/common/.content/pages/{home/_index.json → _index.json} +10 -0
  38. package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
  39. package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
  40. package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
  41. package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
  42. package/templates/common/starter-content/.content/pages/404/_index.json +1 -0
  43. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  44. package/templates/common/starter-content/.content/pages/error/_index.json +1 -0
  45. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  46. package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
  47. package/templates/common/starter-content/.content/patterns/components/container.json +62 -0
  48. package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
  49. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +57 -0
  50. package/templates/common/starter-content/.content/patterns/components/home_hero.json +142 -0
  51. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
  52. package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
  53. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
  54. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
  55. package/templates/common/starter-content/.content/patterns/layouts/default.json +14 -0
  56. package/templates/nextjs/template/.aci.yaml +10 -0
  57. /package/templates/common/.content/overlays/audience/preference-blue/pages/{home/_index.json → _index.json} +0 -0
  58. /package/templates/common/.content/overlays/audience/preference-red/pages/{home/_index.json → _index.json} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agentic Content Infrastructure SDK
2
2
 
3
- `@gradial/aci` `0.1.52-rc.1` is a pre-1.0 public-surface reset for the ACI
3
+ `@gradial/aci` `0.1.52-rc.10` is a pre-1.0 public-surface reset for the ACI
4
4
  client SDK. Breaking changes from the old `0.1.x` accidental surface are
5
5
  intentional: the root import is framework-neutral, framework runtime behavior is
6
6
  behind explicit subpaths, and the first required customer path is React/Next.
@@ -8,7 +8,7 @@ behind explicit subpaths, and the first required customer path is React/Next.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- npm install @gradial/aci@0.1.52-rc.1 zod@^4
11
+ npm install @gradial/aci@0.1.52-rc.10 zod@^4
12
12
  ```
13
13
 
14
14
  ## Root Import
@@ -78,13 +78,13 @@ host bundles select and serve immutable release artifacts.
78
78
  The first RC scaffold path is Next:
79
79
 
80
80
  ```bash
81
- npx @gradial/aci@0.1.52-rc.1 init my-site --template next-starter
81
+ npx @gradial/aci@0.1.52-rc.10 init my-site --template next-starter
82
82
  ```
83
83
 
84
84
  List available templates:
85
85
 
86
86
  ```bash
87
- npx @gradial/aci@0.1.52-rc.1 init --list
87
+ npx @gradial/aci@0.1.52-rc.10 init --list
88
88
  ```
89
89
 
90
90
  ## Personalization: The One-Tag Install
@@ -336,6 +336,9 @@ export declare const experimentDataModel: import("./index.js").DataModelContract
336
336
  routes: z.ZodOptional<z.ZodArray<z.ZodString>>;
337
337
  fragments: z.ZodOptional<z.ZodArray<z.ZodString>>;
338
338
  }, z.core.$strip>;
339
+ eligibility: z.ZodOptional<z.ZodObject<{
340
+ audienceRef: z.ZodString;
341
+ }, z.core.$strict>>;
339
342
  primaryGoalRef: z.ZodOptional<z.ZodString>;
340
343
  primaryGoalId: z.ZodOptional<z.ZodString>;
341
344
  goalRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -426,6 +429,9 @@ export declare const experimentationDataModels: (import("./index.js").DataModelC
426
429
  routes: z.ZodOptional<z.ZodArray<z.ZodString>>;
427
430
  fragments: z.ZodOptional<z.ZodArray<z.ZodString>>;
428
431
  }, z.core.$strip>;
432
+ eligibility: z.ZodOptional<z.ZodObject<{
433
+ audienceRef: z.ZodString;
434
+ }, z.core.$strict>>;
429
435
  primaryGoalRef: z.ZodOptional<z.ZodString>;
430
436
  primaryGoalId: z.ZodOptional<z.ZodString>;
431
437
  goalRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -794,6 +800,9 @@ export declare const audienceDataModels: (import("./index.js").DataModelContract
794
800
  routes: z.ZodOptional<z.ZodArray<z.ZodString>>;
795
801
  fragments: z.ZodOptional<z.ZodArray<z.ZodString>>;
796
802
  }, z.core.$strip>;
803
+ eligibility: z.ZodOptional<z.ZodObject<{
804
+ audienceRef: z.ZodString;
805
+ }, z.core.$strict>>;
797
806
  primaryGoalRef: z.ZodOptional<z.ZodString>;
798
807
  primaryGoalId: z.ZodOptional<z.ZodString>;
799
808
  goalRefs: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -127,6 +127,9 @@ export const experimentDataModel = defineDataModel({
127
127
  routes: z.array(z.string().min(1)).optional(),
128
128
  fragments: z.array(z.string().min(1)).optional(),
129
129
  }),
130
+ eligibility: z.object({
131
+ audienceRef: z.string().min(1),
132
+ }).strict().optional(),
130
133
  primaryGoalRef: z.string().optional(),
131
134
  primaryGoalId: z.string().optional(),
132
135
  goalRefs: z.array(z.string().min(1)).optional(),
@@ -1,2 +1,6 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export function compileRegistry(options: any): Promise<{
3
+ registry: string;
4
+ schemas: string[];
5
+ }>;
6
+ export function generatePageSchema(schemaDir: any, componentEntries: any, outDir: any): Promise<string>;
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
- import { pathToFileURL } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  import { z } from 'zod';
6
7
  import { ImageSchema } from '../types/image.ts';
7
8
  import { VideoSchema } from '../types/video.ts';
@@ -18,14 +19,16 @@ try {
18
19
  globalThis.React = React;
19
20
  }
20
21
  catch { /* react not available — component files without JSX will still work */ }
21
- const args = parseArgs(process.argv.slice(2));
22
- try {
23
- const result = await compileRegistry(args);
24
- console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
25
- }
26
- catch (error) {
27
- console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
28
- process.exitCode = 1;
22
+ if (isMainModule()) {
23
+ const args = parseArgs(process.argv.slice(2));
24
+ try {
25
+ const result = await compileRegistry(args);
26
+ console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
27
+ }
28
+ catch (error) {
29
+ console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
30
+ process.exitCode = 1;
31
+ }
29
32
  }
30
33
  async function compileRegistry(options) {
31
34
  const required = ['components', 'layouts', 'output'];
@@ -440,9 +443,10 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
440
443
  const defs = {
441
444
  baseBlock: {
442
445
  type: 'object',
443
- required: ['id', 'component', 'props'],
446
+ required: ['$type', 'id', 'component', 'props'],
444
447
  additionalProperties: true,
445
448
  properties: {
449
+ $type: { const: 'component' },
446
450
  id: { type: 'string', minLength: 1 },
447
451
  component: { type: 'string' },
448
452
  props: { type: 'object' },
@@ -757,3 +761,14 @@ function parseArgs(argv) {
757
761
  }
758
762
  return out;
759
763
  }
764
+ function isMainModule() {
765
+ if (!process.argv[1])
766
+ return false;
767
+ try {
768
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
769
+ }
770
+ catch {
771
+ return false;
772
+ }
773
+ }
774
+ export { compileRegistry, generatePageSchema };
@@ -1,2 +1,32 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export function validateContent(options: any): Promise<{
3
+ warnings?: any[] | undefined;
4
+ success: boolean;
5
+ pagesValidated: number;
6
+ blocksValidated: number;
7
+ errors: ({
8
+ page: string;
9
+ message: string;
10
+ fragment?: undefined;
11
+ component?: undefined;
12
+ path?: undefined;
13
+ } | {
14
+ fragment: string;
15
+ message: string;
16
+ page?: undefined;
17
+ component?: undefined;
18
+ path?: undefined;
19
+ } | {
20
+ fragment: string;
21
+ component: any;
22
+ message: string;
23
+ page?: undefined;
24
+ path?: undefined;
25
+ } | {
26
+ fragment: string;
27
+ component: any;
28
+ path: any;
29
+ message: any;
30
+ page?: undefined;
31
+ })[];
32
+ }>;
@@ -19,21 +19,25 @@
19
19
  * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
20
20
  * imports needed, same schemas usable by Go backend or other tools.
21
21
  */
22
+ import { realpathSync } from 'node:fs';
22
23
  import { readdir, readFile } from 'node:fs/promises';
23
24
  import path from 'node:path';
24
- const args = parseArgs(process.argv.slice(2));
25
- try {
26
- const result = await validateContent(args);
27
- console.log(JSON.stringify(result, null, 2));
28
- if (!result.success)
25
+ import { fileURLToPath } from 'node:url';
26
+ if (isMainModule()) {
27
+ const args = parseArgs(process.argv.slice(2));
28
+ try {
29
+ const result = await validateContent(args);
30
+ console.log(JSON.stringify(result, null, 2));
31
+ if (!result.success)
32
+ process.exitCode = 1;
33
+ }
34
+ catch (error) {
35
+ console.error(JSON.stringify({
36
+ success: false,
37
+ errors: [{ message: String(error?.message ?? error) }],
38
+ }, null, 2));
29
39
  process.exitCode = 1;
30
- }
31
- catch (error) {
32
- console.error(JSON.stringify({
33
- success: false,
34
- errors: [{ message: String(error?.message ?? error) }],
35
- }, null, 2));
36
- process.exitCode = 1;
40
+ }
37
41
  }
38
42
  // ---------------------------------------------------------------------------
39
43
  // Main
@@ -86,6 +90,22 @@ async function validateContent(options) {
86
90
  const blocks = collectBlocks(pageData);
87
91
  for (const block of blocks) {
88
92
  blocksValidated++;
93
+ if (!block || typeof block !== 'object') {
94
+ collector.push({
95
+ page: relativePath,
96
+ blockId: '<unknown>',
97
+ message: 'page region entry must be a component block',
98
+ });
99
+ continue;
100
+ }
101
+ if (block.$type !== 'component') {
102
+ collector.push({
103
+ page: relativePath,
104
+ blockId: block.id ?? '<unknown>',
105
+ path: '$type',
106
+ message: 'block "$type" must be "component"',
107
+ });
108
+ }
89
109
  if (!block.component) {
90
110
  collector.push({
91
111
  page: relativePath,
@@ -348,6 +368,13 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
348
368
  issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
349
369
  continue;
350
370
  }
371
+ if (nestedBlock.$type !== 'component') {
372
+ issues.push({
373
+ path: `${nestedPath}.$type`,
374
+ message: `${parentBlock.component}: block ref child "$type" must be "component"`,
375
+ });
376
+ continue;
377
+ }
351
378
  if (!componentMap.has(nestedBlock.component)) {
352
379
  issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
353
380
  continue;
@@ -424,3 +451,14 @@ function parseArgs(argv) {
424
451
  }
425
452
  return out;
426
453
  }
454
+ function isMainModule() {
455
+ if (!process.argv[1])
456
+ return false;
457
+ try {
458
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }
464
+ export { validateContent };
@@ -21,6 +21,7 @@ export interface FragmentEntry {
21
21
  }
22
22
  export interface LayoutEntry {
23
23
  name: string;
24
+ payloadRef: string;
24
25
  slots: LayoutSlot[];
25
26
  defaults?: Record<string, FragmentRef[]>;
26
27
  }
@@ -1,9 +1,16 @@
1
1
  import type { LayoutEntry } from './provider.js';
2
2
  import type { KernelPageBlock } from './types.js';
3
- export interface FragmentData {
4
- id: string;
3
+ export interface FragmentBlock {
4
+ $type: 'component';
5
5
  component: string;
6
6
  props?: Record<string, unknown>;
7
7
  }
8
+ export interface FragmentData {
9
+ fragmentId?: string;
10
+ id?: string;
11
+ block?: FragmentBlock;
12
+ component?: string;
13
+ props?: Record<string, unknown>;
14
+ }
8
15
  export type FragmentLoader = (id: string) => Promise<FragmentData>;
9
16
  export declare function resolveSlots(layout: LayoutEntry, pageRegions: Record<string, KernelPageBlock[]>, getFragment: FragmentLoader): Promise<Record<string, KernelPageBlock[]>>;
@@ -11,10 +11,12 @@ export async function resolveSlots(layout, pageRegions, getFragment) {
11
11
  const blocks = [];
12
12
  for (const ref of defaults) {
13
13
  const fragment = await getFragment(ref.fragmentId);
14
+ const block = fragment.block ?? { component: fragment.component ?? '', props: fragment.props };
14
15
  blocks.push({
15
- id: fragment.id,
16
- component: fragment.component,
17
- props: fragment.props || {},
16
+ $type: 'component',
17
+ id: fragment.id ?? fragment.fragmentId ?? ref.fragmentId,
18
+ component: block.component,
19
+ props: block.props || {},
18
20
  });
19
21
  }
20
22
  resolved[slot.name] = blocks;
@@ -42,11 +42,13 @@ export interface KernelPageSeo {
42
42
  canonical?: string;
43
43
  }
44
44
  export interface KernelPageBlock {
45
+ $type: 'component';
45
46
  id: string;
46
47
  component: string;
47
48
  props: Record<string, unknown>;
48
49
  }
49
50
  export interface KernelAtomicBlock {
51
+ $type: 'component';
50
52
  id: string;
51
53
  component: string;
52
54
  props: Record<string, unknown>;
@@ -13,6 +13,7 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
13
13
  const candidates = Object.values(experiments)
14
14
  .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
15
15
  .filter((item) => routeHasExperiment(route, item.id))
16
+ .filter((item) => experimentEligible(item, claims))
16
17
  .sort((left, right) => (right.priority || 0) - (left.priority || 0)
17
18
  || String(left.id || '').localeCompare(String(right.id || '')));
18
19
  for (let index = 0; index < candidates.length; index++) {
@@ -39,7 +40,8 @@ function selectRuleVariant(request, route, claims, experiments) {
39
40
  const values = variant.variantValues || variantValuesFromKey(key);
40
41
  const experimentID = String(values.experiment || '').trim();
41
42
  if (experimentID && (!experiments || !experiments[experimentID]
42
- || experiments[experimentID].status !== 'running'))
43
+ || experiments[experimentID].status !== 'running'
44
+ || !experimentEligible(experiments[experimentID], claims)))
43
45
  continue;
44
46
  if (!requestDimensionsMatch(request, values))
45
47
  continue;
@@ -66,6 +68,16 @@ function variantValuesFromKey(key) {
66
68
  }
67
69
  return values;
68
70
  }
71
+ // The eligibility gate (audience-scoped-experiments ASE-F4/F5), mirrored
72
+ // from internal/domain/edge/personalization-core.ts: an experiment declaring
73
+ // eligibilityAudienceId is skipped unless the verified claims carry the ID.
74
+ // Ungated experiments return true, preserving byte-identical behavior.
75
+ function experimentEligible(item, claims) {
76
+ const audienceID = String(item.eligibilityAudienceId || '').trim();
77
+ if (!audienceID)
78
+ return true;
79
+ return !!claims && (claims.aud || []).includes(audienceID);
80
+ }
69
81
  function routeHasExperiment(route, experimentID) {
70
82
  for (const key in route.variants || {}) {
71
83
  const values = route.variants[key].variantValues || variantValuesFromKey(key);
@@ -0,0 +1,24 @@
1
+ export interface ObjectReader {
2
+ readObject(path: string): Promise<Uint8Array>;
3
+ }
4
+ export interface ComposeObjectRef {
5
+ path: string;
6
+ digest: string;
7
+ }
8
+ export interface ComposeSlot {
9
+ name: string;
10
+ required: boolean;
11
+ placeholder: string;
12
+ parts: ComposeObjectRef[];
13
+ }
14
+ export interface ComposeEnvelope {
15
+ schema: 'gradial.compose-envelope.v1';
16
+ algorithm: 'aci-html-compose-v1';
17
+ shell: ComposeObjectRef;
18
+ slots: ComposeSlot[];
19
+ }
20
+ export declare function composeEnvelope(reader: ObjectReader, envelopePath: string, envelopeDigest: string): Promise<{
21
+ html: string;
22
+ contentType: string;
23
+ }>;
24
+ export declare function validateEnvelope(envelope: unknown): ComposeEnvelope;
@@ -0,0 +1,89 @@
1
+ const decoder = new TextDecoder('utf-8', { fatal: true });
2
+ export async function composeEnvelope(reader, envelopePath, envelopeDigest) {
3
+ const envelopeBytes = await reader.readObject(envelopePath);
4
+ await requireDigest(envelopeBytes, envelopeDigest, 'compose envelope');
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(decoder.decode(envelopeBytes));
8
+ }
9
+ catch {
10
+ throw new Error('compose envelope is malformed');
11
+ }
12
+ const envelope = validateEnvelope(parsed);
13
+ const shellBytes = await reader.readObject(envelope.shell.path);
14
+ await requireDigest(shellBytes, envelope.shell.digest, 'compose shell');
15
+ let html = decoder.decode(shellBytes);
16
+ for (const slot of envelope.slots) {
17
+ if (occurrences(html, slot.placeholder) !== 1) {
18
+ throw new Error(`compose slot ${JSON.stringify(slot.name)} placeholder must occur exactly once`);
19
+ }
20
+ if (slot.required && slot.parts.length === 0) {
21
+ throw new Error(`compose slot ${JSON.stringify(slot.name)} is required`);
22
+ }
23
+ const parts = [];
24
+ for (const part of slot.parts) {
25
+ const bytes = await reader.readObject(part.path);
26
+ await requireDigest(bytes, part.digest, `compose slot ${slot.name}`);
27
+ parts.push(decoder.decode(bytes));
28
+ }
29
+ html = html.replace(slot.placeholder, parts.join(''));
30
+ }
31
+ if (html.includes('<!--ACI_SLOT:')) {
32
+ throw new Error('compose shell has unresolved slots');
33
+ }
34
+ return { html, contentType: 'text/html; charset=utf-8' };
35
+ }
36
+ export function validateEnvelope(envelope) {
37
+ if (!envelope || typeof envelope !== 'object') {
38
+ throw new Error('compose envelope is malformed');
39
+ }
40
+ const obj = envelope;
41
+ if (obj.schema !== 'gradial.compose-envelope.v1'
42
+ || obj.algorithm !== 'aci-html-compose-v1'
43
+ || !Array.isArray(obj.slots)) {
44
+ throw new Error('compose envelope is malformed');
45
+ }
46
+ const env = envelope;
47
+ validateRef(env.shell);
48
+ const names = new Set();
49
+ for (const slot of env.slots) {
50
+ if (!slot || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(slot.name) || names.has(slot.name)) {
51
+ throw new Error('compose slot is malformed');
52
+ }
53
+ names.add(slot.name);
54
+ if (slot.placeholder !== `<!--ACI_SLOT:${slot.name}-->` || !Array.isArray(slot.parts)) {
55
+ throw new Error(`compose slot ${JSON.stringify(slot.name)} is malformed`);
56
+ }
57
+ for (const part of slot.parts)
58
+ validateRef(part);
59
+ }
60
+ return env;
61
+ }
62
+ function validateRef(ref) {
63
+ const path = String(ref?.path || '');
64
+ if (!path || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..')) {
65
+ throw new Error('compose object path is invalid');
66
+ }
67
+ if (!/^[0-9a-f]{64}$/.test(String(ref.digest || '').toLowerCase())) {
68
+ throw new Error('compose object digest is invalid');
69
+ }
70
+ }
71
+ async function requireDigest(bytes, want, label) {
72
+ const ab = new ArrayBuffer(bytes.byteLength);
73
+ new Uint8Array(ab).set(bytes);
74
+ const actual = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', ab)), (byte) => byte.toString(16).padStart(2, '0')).join('');
75
+ if (actual !== want.toLowerCase())
76
+ throw new Error(`${label} digest mismatch`);
77
+ }
78
+ function occurrences(value, needle) {
79
+ let count = 0;
80
+ let offset = 0;
81
+ while (true) {
82
+ offset = value.indexOf(needle, offset);
83
+ if (offset < 0)
84
+ break;
85
+ count += 1;
86
+ offset += needle.length;
87
+ }
88
+ return count;
89
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './types.js';
2
2
  export * from './personalization-core.js';
3
3
  export * from './identity.js';
4
+ export * from './compose.js';
4
5
  export { resolvePersonalizationRequest, } from './resolve.js';
@@ -3,4 +3,5 @@
3
3
  export * from './types.js';
4
4
  export * from './personalization-core.js';
5
5
  export * from './identity.js';
6
+ export * from './compose.js';
6
7
  export { resolvePersonalizationRequest, } from './resolve.js';
@@ -8,9 +8,12 @@ export declare function resolvePersonalizationDecision(request: PersonalizationR
8
8
  * @param data Release-level classification data (audiences, segments, experiments)
9
9
  * @param identity Visitor identity (for experiment bucketing). Pass undefined
10
10
  * if no identity or privacy denied.
11
+ * @param claims The verified claims the caller already holds (eligibility
12
+ * gate input, ASE-F4). Undefined/null means "no claims" —
13
+ * gated experiments are skipped.
11
14
  * @returns Classification — matched audiences, segments, and experiment arm assignments
12
15
  */
13
- export declare function classify(ctx: PersonalizationRequestContext, data: ClassificationData, identity?: PersonalizationIdentity): Classification;
16
+ export declare function classify(ctx: PersonalizationRequestContext, data: ClassificationData, identity?: PersonalizationIdentity, claims?: PersonalizationClaims | null): Classification;
14
17
  /**
15
18
  * Phase 2: Decide the winning variant for a specific route.
16
19
  *
@@ -20,6 +20,7 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
20
20
  const candidates = Object.values(experiments)
21
21
  .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
22
22
  .filter((item) => routeHasExperiment(route, item.id))
23
+ .filter((item) => experimentEligible(item, claims))
23
24
  .sort((left, right) => (right.priority || 0) - (left.priority || 0)
24
25
  || String(left.id || '').localeCompare(String(right.id || '')));
25
26
  for (let index = 0; index < candidates.length; index++) {
@@ -50,7 +51,8 @@ function selectRuleVariant(request, route, claims, experiments) {
50
51
  const values = variant.variantValues || variantValuesFromKey(key);
51
52
  const experimentID = String(values.experiment || '').trim();
52
53
  if (experimentID && (!experiments || !experiments[experimentID]
53
- || experiments[experimentID].status !== 'running'))
54
+ || experiments[experimentID].status !== 'running'
55
+ || !experimentEligible(experiments[experimentID], claims)))
54
56
  continue;
55
57
  if (!requestDimensionsMatch(request, values))
56
58
  continue;
@@ -64,6 +66,16 @@ function selectRuleVariant(request, route, claims, experiments) {
64
66
  || String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
65
67
  return matches[0] || null;
66
68
  }
69
+ // The eligibility gate (audience-scoped-experiments ASE-F4/F5): an
70
+ // experiment declaring eligibilityAudienceId is skipped entirely — before
71
+ // bucketing or arm candidacy — unless the verified claims carry the ID.
72
+ // Ungated experiments return true, preserving byte-identical behavior.
73
+ function experimentEligible(item, claims) {
74
+ const audienceID = String(item.eligibilityAudienceId || '').trim();
75
+ if (!audienceID)
76
+ return true;
77
+ return !!claims && (claims.aud || []).includes(audienceID);
78
+ }
67
79
  function routeHasExperiment(route, experimentID) {
68
80
  for (const key in route.variants || {}) {
69
81
  const values = route.variants[key].variantValues || variantValuesFromKey(key);
@@ -279,9 +291,12 @@ function utf8(value) {
279
291
  * @param data Release-level classification data (audiences, segments, experiments)
280
292
  * @param identity Visitor identity (for experiment bucketing). Pass undefined
281
293
  * if no identity or privacy denied.
294
+ * @param claims The verified claims the caller already holds (eligibility
295
+ * gate input, ASE-F4). Undefined/null means "no claims" —
296
+ * gated experiments are skipped.
282
297
  * @returns Classification — matched audiences, segments, and experiment arm assignments
283
298
  */
284
- export function classify(ctx, data, identity) {
299
+ export function classify(ctx, data, identity, claims) {
285
300
  // Evaluate audience rules
286
301
  const matchedAudiences = [];
287
302
  for (const key in data.audiences) {
@@ -307,6 +322,8 @@ export function classify(ctx, data, identity) {
307
322
  continue;
308
323
  if (exp.kind !== 'ab' && exp.kind !== 'abn')
309
324
  continue;
325
+ if (!experimentEligible(exp, claims))
326
+ continue;
310
327
  const assignment = assignExperiment(exp, identity);
311
328
  if (assignment) {
312
329
  experimentArms[expId] = assignment;
@@ -348,21 +365,21 @@ export function decide(record, classification, ctx) {
348
365
  if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
349
366
  matched = true;
350
367
  }
351
- // Experiment arm assignment
368
+ // Experiment arm assignment. The arm is a candidate only — committing it
369
+ // inside the win block prevents a losing arm variant from leaving a
370
+ // stale experiment decision behind when another variant wins.
371
+ let armMatch = null;
352
372
  if (!matched && experimentID) {
353
373
  const arm = classification.experimentArms[experimentID];
354
374
  if (arm && arm.variantKey === variant.variantKey) {
355
375
  matched = true;
356
- bestExperiment = arm;
376
+ armMatch = arm;
357
377
  }
358
378
  }
359
379
  if (matched) {
360
380
  if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
361
381
  bestVariant = variant;
362
- // Only set experiment if this variant's experiment arm won
363
- if (!experimentID || !classification.experimentArms[experimentID]) {
364
- bestExperiment = null;
365
- }
382
+ bestExperiment = armMatch;
366
383
  }
367
384
  }
368
385
  }
@@ -10,11 +10,13 @@ export async function resolvePersonalizationRequest(request, config) {
10
10
  const cookies = parseCookieHeader(request.headers.get('cookie'));
11
11
  const consent = consentSignals(request, cookies);
12
12
  const now = (config.now ?? (() => new Date()))();
13
- // Consent-gated identity model (R25): static artifact selection NEVER mints
13
+ // Consent-gated identity model (R25): artifact selection NEVER mints
14
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).
15
+ // Identity (aci_id) is minted only after explicit consent — by the
16
+ // collect/consent endpoint, and by the hosting edge router / framework
17
+ // host on consented 200 HTML page responses (which then bypass shared
18
+ // caches). Unconsented content responses stay cookie-free and cacheable
19
+ // (no non-essential cookie before consent).
18
20
  //
19
21
  // GPC/DNT deny fully: no read, no identity, no experiment bucketing.
20
22
  const rawIdentity = consent.privacyDenied
@@ -75,6 +75,7 @@ export interface PersonalizationExperiment {
75
75
  priority?: number;
76
76
  allocationVersion?: string;
77
77
  salt?: string;
78
+ eligibilityAudienceId?: string;
78
79
  arms: Array<{
79
80
  id: string;
80
81
  allocationBasisPoints?: number;
@@ -178,4 +179,15 @@ export interface PersonalizationResolution {
178
179
  allocationVersion: string;
179
180
  };
180
181
  }
182
+ export interface CompositionDescriptor {
183
+ layoutArtifact: string;
184
+ fragments?: Record<string, string>;
185
+ }
186
+ export type SelectedTargetKind = 'direct' | 'compose' | 'render';
187
+ export interface SelectedTarget {
188
+ kind: SelectedTargetKind;
189
+ path: string;
190
+ digest: string;
191
+ composition?: CompositionDescriptor;
192
+ }
181
193
  export { IdentityErasureFencedError } from '../../platform/collectionstore/errors.js';
@@ -104,7 +104,7 @@ export function buildErrorSeatPayload(contentRoot, posture) {
104
104
  if (!fragmentRef)
105
105
  continue;
106
106
  const fragment = readJSON(root, fragmentRef);
107
- blocks.push({ id: fragment.id, component: fragment.component, props: fragment.props || {} });
107
+ blocks.push({ $type: 'component', id: fragment.id, component: fragment.component, props: fragment.props || {} });
108
108
  }
109
109
  if (blocks.length)
110
110
  slots[slot.name] = blocks;
package/dist/next/page.js CHANGED
@@ -84,9 +84,9 @@ export function createPage(registry, options) {
84
84
  }
85
85
  return (_jsx(Fragment, { children: renderedBlocks }, slot.name));
86
86
  }));
87
- return (_jsxs(_Fragment, { children: [renderProofContent
87
+ return (_jsxs(_Fragment, { children: [renderedSlots.filter(Boolean), _jsx(AssignmentsScript, { page: input.page }), _jsx(CollectScript, { siteConfig: input.siteConfig }), renderProofContent
88
88
  ? _jsx("meta", { name: "aci-render-proof", content: renderProofContent })
89
- : null, renderedSlots.filter(Boolean), _jsx(AssignmentsScript, { page: input.page }), _jsx(CollectScript, { siteConfig: input.siteConfig })] }));
89
+ : null] }));
90
90
  }
91
91
  catch (error) {
92
92
  if (error instanceof PageNotFoundError)