@gradial/aci 0.1.52-rc.29 → 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 (55) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +201 -372
  4. package/dist/audience-data-models.js +283 -120
  5. package/dist/cli/compile-registry.mjs +0 -2
  6. package/dist/collect/aci.js +1 -1
  7. package/dist/collect/aci.js.sha256 +1 -1
  8. package/dist/collect/gapbuffer.js +11 -6
  9. package/dist/internal/platform/collectionstore/errors.d.ts +11 -0
  10. package/dist/internal/platform/collectionstore/errors.js +20 -0
  11. package/dist/next/error-pages.d.ts +4 -0
  12. package/dist/next/error-pages.js +4 -0
  13. package/dist/next/release-context.d.ts +29 -0
  14. package/dist/next/release-context.js +55 -0
  15. package/dist/next/server.d.ts +8 -0
  16. package/dist/next/server.js +14 -1
  17. package/dist/react/VideoPlayer.d.ts +1 -1
  18. package/dist/react/VideoPlayer.js +2 -2
  19. package/dist/react/index.d.ts +2 -2
  20. package/dist/react/index.js +2 -3
  21. package/dist/testing/conformance/preset.js +3 -0
  22. package/dist/testing/conformance/root-identity.js +7 -26
  23. package/dist/testing/conformance/types.d.ts +3 -0
  24. package/dist/types/image.d.ts +54 -17
  25. package/dist/types/image.js +102 -18
  26. package/dist/types/media.d.ts +14 -6
  27. package/dist/types/video.d.ts +7 -3
  28. package/migrations/conformance-registry.md +22 -0
  29. package/package.json +8 -7
  30. package/src/cli/compile-registry.mjs +0 -2
  31. package/src/types/image.ts +133 -29
  32. package/src/types/video.ts +1 -1
  33. package/templates/astro/starter-content/src/fixtures/sections.ts +0 -1
  34. package/templates/common/.agents/skills/aci/SKILL.md +4 -3
  35. package/templates/common/.agents/skills/dam-delivery/SKILL.md +182 -193
  36. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +137 -101
  37. package/templates/common/starter-content/.content/dam/README.md +74 -0
  38. package/templates/common/starter-content/.content/dam/folders/seed/000000000000000000000000101/governance.json +2 -1
  39. package/templates/common/starter-content/.content/dam/folders/seed/000000000000000000000000102/governance.json +2 -1
  40. package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
  41. package/templates/common/starter-content/.content/patterns/components/container.json +2 -2
  42. package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
  43. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +2 -2
  44. package/templates/common/starter-content/.content/patterns/components/home_hero.json +16 -7
  45. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +2 -2
  46. package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
  47. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
  48. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
  49. package/templates/nextjs/template/src/app/fonts/inter/OFL.txt +93 -0
  50. package/templates/nextjs/template/src/app/fonts/inter/inter-latin.woff2 +0 -0
  51. package/templates/nextjs/template/src/app/fonts/space-grotesk/OFL.txt +93 -0
  52. package/templates/nextjs/template/src/app/fonts/space-grotesk/space-grotesk-latin.woff2 +0 -0
  53. package/templates/nextjs/template/src/app/layout.tsx +10 -7
  54. package/templates/nextjs/template/tests/conformance.test.ts.tmpl +1 -0
  55. package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +0 -107
@@ -1,9 +1,190 @@
1
+ // GENERATED by scripts/generate-data-models.go — DO NOT EDIT.
2
+ // Regenerate: make data-models
3
+ //
4
+ // Authoring data models for the audience-family and experimentation content
5
+ // documents, generated from the Go document readers' contracts (the readers
6
+ // stay the authority on what the API accepts; these give authors client-side
7
+ // validation that cannot drift from them). The canonical descriptor artifact
8
+ // is packages/sdk/schema/audience-data-models.v1.json, drift-gated in CI by
9
+ // make data-models-verify.
1
10
  import { z } from 'zod';
2
11
  import { defineDataModel } from './define-data-model.js';
3
- const resourceStatus = z.enum(['draft', 'enabled', 'disabled']);
4
- const experimentStatus = z.enum(['draft', 'running', 'paused', 'stopped', 'promoted', 'archived']);
5
- const experimentKind = z.enum(['ab', 'abn', 'xt']);
12
+ // Mirrors the ADR 0038 operator vocabulary for author-time DX; the expression
13
+ // kernel stays the authority, and the capability drift test pins this enum
14
+ // and the operand pairing to the generated artifact.
15
+ export const expressionOperators = ['equals', 'contains', 'startsWith', 'exists', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', 'before', 'after', 'between', 'in'];
6
16
  const scalarValue = z.union([z.string(), z.number(), z.boolean()]);
17
+ const maxInValues = 32;
18
+ const maxConditions = 64;
19
+ const maxExpressionDepth = 5;
20
+ const rangeBoundValue = z.union([z.number(), z.string()]);
21
+ const conditionValueSchema = z.union([
22
+ scalarValue,
23
+ z.array(scalarValue).min(1).max(maxInValues),
24
+ z.object({ min: rangeBoundValue, max: rangeBoundValue }).strict(),
25
+ ]);
26
+ const scalarOperandOperators = new Set(['equals', 'contains', 'startsWith']);
27
+ const numberOperandOperators = new Set(['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']);
28
+ const timestampOperandOperators = new Set(['before', 'after']);
29
+ // A condition is strict — the kernel's decoder retains unknown leaf keys so
30
+ // validation can reject them rather than silently dropping authored meaning.
31
+ const expressionConditionSchema = z.object({
32
+ field: z.string().min(1),
33
+ operator: z.enum(expressionOperators),
34
+ value: conditionValueSchema.optional(),
35
+ }).strict().superRefine((condition, context) => {
36
+ const { operator, value } = condition;
37
+ const fail = (message) => context.addIssue({ code: 'custom', path: ['value'], message });
38
+ if (operator === 'exists') {
39
+ if (value !== undefined && typeof value !== 'boolean')
40
+ fail('exists takes an optional boolean value');
41
+ return;
42
+ }
43
+ if (value === undefined) {
44
+ fail(`value is required for operator ${operator}`);
45
+ return;
46
+ }
47
+ if (scalarOperandOperators.has(operator) && (typeof value === 'object')) {
48
+ fail(`${operator} takes one scalar value`);
49
+ }
50
+ else if (numberOperandOperators.has(operator) && typeof value !== 'number') {
51
+ fail(`${operator} takes one finite JSON number`);
52
+ }
53
+ else if (timestampOperandOperators.has(operator) && typeof value !== 'string') {
54
+ fail(`${operator} takes one RFC 3339 timestamp string`);
55
+ }
56
+ else if (operator === 'between' && (typeof value !== 'object' || Array.isArray(value))) {
57
+ fail('between takes a {min, max} object');
58
+ }
59
+ else if (operator === 'in' && !Array.isArray(value)) {
60
+ fail(`in takes an array of 1 to ${maxInValues} scalars`);
61
+ }
62
+ });
63
+ // Groups are strict too: a member carrying all/any is a group, and any other
64
+ // key on it is an unsupported expression group the kernel refuses. An empty
65
+ // group is refused by validateAuthoredExpression below, mirroring the
66
+ // kernel's members-not-leaves emptiness rule.
67
+ const expressionGroupSchema = z.lazy(() => z.object({
68
+ all: z.array(expressionMemberSchema).optional(),
69
+ any: z.array(expressionMemberSchema).optional(),
70
+ }).strict());
71
+ const expressionMemberSchema = z.lazy(() => z.union([expressionConditionSchema, expressionGroupSchema]));
72
+ const requestScopeFields = [
73
+ { field: 'referrer' },
74
+ { field: 'query.', prefix: true },
75
+ { field: 'header.', prefix: true },
76
+ { field: 'cookie.', prefix: true },
77
+ { field: 'geo.', prefix: true },
78
+ { field: 'utm.', prefix: true },
79
+ { field: 'device.class', operators: ['equals', 'in', 'exists'], values: ['desktop', 'mobile', 'tablet', 'unknown'] },
80
+ { field: 'time.now', operators: ['before', 'after', 'between', 'exists'], instant: true },
81
+ { field: 'audience.memberships', operators: ['equals', 'in', 'exists'] },
82
+ ];
83
+ const eventScopeFields = [
84
+ { field: 'referrer' },
85
+ { field: 'page.path' },
86
+ { field: 'properties.', prefix: true },
87
+ { field: 'utm.', prefix: true },
88
+ ];
89
+ function expressionFieldRule(fields, field) {
90
+ for (const rule of fields) {
91
+ if (rule.prefix) {
92
+ // A prefix rule needs at least one character past the prefix, exactly
93
+ // like the kernel's fieldRuleFor.
94
+ if (field.length > rule.field.length && field.startsWith(rule.field))
95
+ return rule;
96
+ continue;
97
+ }
98
+ if (rule.field === field)
99
+ return rule;
100
+ }
101
+ return undefined;
102
+ }
103
+ // validateAuthoredExpression mirrors the kernel's validation walk
104
+ // (personalization/expression Validate): one diagnostic per broken node, the
105
+ // leaf budget counted with the same depth cutoff, emptiness judged on
106
+ // members not leaves.
107
+ function validateAuthoredExpression(expressionValue, fields, allowsEmpty, context) {
108
+ const fail = (path, message) => context.addIssue({ code: 'custom', path, message });
109
+ const walk = (members, path, depth) => {
110
+ (members ?? []).forEach((member, index) => {
111
+ const memberPath = [...path, index];
112
+ if (!('all' in member || 'any' in member)) {
113
+ validateScopedCondition(member, fields, memberPath, fail);
114
+ return;
115
+ }
116
+ const group = member;
117
+ // One diagnostic per broken node, and no descent past it: a too-deep
118
+ // branch reports its depth once rather than repeating for every
119
+ // condition buried inside it.
120
+ if (depth + 1 > maxExpressionDepth) {
121
+ fail(memberPath, `must not nest more than ${maxExpressionDepth} levels deep`);
122
+ return;
123
+ }
124
+ if ((group.all?.length ?? 0) + (group.any?.length ?? 0) === 0) {
125
+ fail(memberPath, 'must include at least one all or any condition');
126
+ return;
127
+ }
128
+ walk(group.all, [...memberPath, 'all'], depth + 1);
129
+ walk(group.any, [...memberPath, 'any'], depth + 1);
130
+ });
131
+ };
132
+ if ((expressionValue.all?.length ?? 0) + (expressionValue.any?.length ?? 0) === 0 && !allowsEmpty) {
133
+ fail([], 'must include at least one all or any condition');
134
+ }
135
+ if (countExpressionLeaves(expressionValue.all, 1) + countExpressionLeaves(expressionValue.any, 1) > maxConditions) {
136
+ fail([], `must contain at most ${maxConditions} conditions`);
137
+ }
138
+ walk(expressionValue.all, ['all'], 1);
139
+ walk(expressionValue.any, ['any'], 1);
140
+ }
141
+ // countExpressionLeaves counts leaf conditions across the tree with the same
142
+ // depth cutoff the validating walk applies, so the two limits never disagree
143
+ // about a tree.
144
+ function countExpressionLeaves(members, depth) {
145
+ let total = 0;
146
+ for (const member of members ?? []) {
147
+ if (!('all' in member || 'any' in member)) {
148
+ total++;
149
+ continue;
150
+ }
151
+ if (depth + 1 > maxExpressionDepth)
152
+ continue;
153
+ const group = member;
154
+ total += countExpressionLeaves(group.all, depth + 1) + countExpressionLeaves(group.any, depth + 1);
155
+ }
156
+ return total;
157
+ }
158
+ function validateScopedCondition(condition, fields, path, fail) {
159
+ const rule = expressionFieldRule(fields, condition.field);
160
+ if (!rule) {
161
+ fail([...path, 'field'], `field ${condition.field} is not supported in this scope`);
162
+ return;
163
+ }
164
+ if (rule.operators && !rule.operators.includes(condition.operator)) {
165
+ fail([...path, 'operator'], `operator ${condition.operator} is not supported for ${condition.field}`);
166
+ return;
167
+ }
168
+ if (rule.values && (condition.operator === 'equals' || condition.operator === 'in')) {
169
+ const members = condition.operator === 'in' && Array.isArray(condition.value) ? condition.value : [condition.value];
170
+ for (const memberValue of members) {
171
+ if (typeof memberValue === 'string' && !rule.values.includes(memberValue)) {
172
+ fail([...path, 'value'], `value ${JSON.stringify(memberValue)} is not supported for ${condition.field}`);
173
+ }
174
+ }
175
+ }
176
+ // An instant field narrows a dual-typed operand: between on time.now takes
177
+ // timestamp bounds, so a numeric range cannot validate and then sit
178
+ // silently inert at execution.
179
+ if (rule.instant && condition.operator === 'between') {
180
+ const range = condition.value;
181
+ if (typeof range?.min !== 'string' || typeof range?.max !== 'string') {
182
+ fail([...path, 'value'], `between takes timestamp bounds on ${condition.field}`);
183
+ }
184
+ }
185
+ }
186
+ const requestRuleExpressionSchema = expressionGroupSchema.superRefine((expressionValue, context) => validateAuthoredExpression(expressionValue, requestScopeFields, false, context));
187
+ const eventRuleExpressionSchema = expressionGroupSchema.superRefine((expressionValue, context) => validateAuthoredExpression(expressionValue, eventScopeFields, true, context));
7
188
  // Largest whole-day window whose nanoseconds fit in Go's time.Duration:
8
189
  // floor(MaxInt64 / (24 * time.Hour)) = 106751.
9
190
  const maxCriteriaWithinDays = 106751;
@@ -16,12 +197,20 @@ const criteriaExperimentSchema = z.object({
16
197
  experimentId: z.string().regex(/\S/, 'experimentId is required'),
17
198
  armId: z.string().optional(),
18
199
  }).strict();
200
+ // The server-stamped request geo filter (CollectGeo): normalized codes,
201
+ // exact match. geo requires country or region — the write gate owns that
202
+ // rule (a refinement would make this model uncompilable to JSON Schema).
203
+ const criteriaGeoSchema = z.object({
204
+ country: z.string().regex(/^[A-Z]{2}$/, 'geo.country must be an uppercase ISO 3166-1 alpha-2 code').optional(),
205
+ region: z.string().regex(/^[A-Z0-9]{1,3}$/, 'geo.region must be 1-3 uppercase alphanumerics').optional(),
206
+ }).strict();
19
207
  const criteriaFilterShape = {
20
208
  event: z.string().regex(/\S/, 'event is required'),
21
209
  path: z.string().regex(/^(?!\*$)/, 'path must not be a lone "*"').optional(),
22
210
  referrer: z.string().regex(/^(?!\*$)/, 'referrer must not be a lone "*"').optional(),
23
211
  withinDays: z.number().int().positive().max(maxCriteriaWithinDays).optional(),
24
212
  experiment: criteriaExperimentSchema.optional(),
213
+ geo: criteriaGeoSchema.optional(),
25
214
  properties: z.record(z.string(), criteriaFilterValue).optional(),
26
215
  };
27
216
  const segmentCriteriaConditionSchema = z.discriminatedUnion('type', [
@@ -51,192 +240,167 @@ const segmentCriteriaSchema = z.union([
51
240
  none: z.array(segmentCriteriaConditionSchema).optional(),
52
241
  }).strict(),
53
242
  ]);
54
- // Mirrors the ADR 0038 operator vocabulary for author-time DX; the expression
55
- // kernel stays the authority, and the capability drift test pins this enum
56
- // and the operand pairing to the generated artifact.
57
- export const expressionOperators = [
58
- 'equals', 'contains', 'startsWith', 'exists',
59
- 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
60
- 'before', 'after', 'between', 'in',
61
- ];
62
- const maxInValues = 32;
63
- const rangeBoundValue = z.union([z.number(), z.string()]);
64
- const conditionValueSchema = z.union([
65
- scalarValue,
66
- z.array(scalarValue).min(1).max(maxInValues),
67
- z.object({ min: rangeBoundValue, max: rangeBoundValue }).strict(),
68
- ]);
69
- const scalarOperandOperators = new Set(['equals', 'contains', 'startsWith']);
70
- const numberOperandOperators = new Set(['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']);
71
- const timestampOperandOperators = new Set(['before', 'after']);
72
- const requestRuleConditionSchema = z.object({
73
- field: z.string().min(1),
74
- operator: z.enum(expressionOperators),
75
- value: conditionValueSchema.optional(),
76
- }).superRefine((condition, context) => {
77
- const { operator, value } = condition;
78
- const fail = (message) => context.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message });
79
- if (operator === 'exists') {
80
- if (value !== undefined && typeof value !== 'boolean')
81
- fail('exists takes an optional boolean value');
243
+ // The mapped-json extraction matrix (validateProviderMapping): mapping is
244
+ // forbidden for aep-batch-export and required for mapped-json, whose
245
+ // membership declares exactly one source. Pointer grammar and pattern
246
+ // compilation stay with the write gate.
247
+ function providerFormatMappingMatrix(props, context) {
248
+ const fail = (path, message) => context.addIssue({ code: 'custom', path, message });
249
+ if (props.format === "aep-batch-export") {
250
+ if (props.mapping !== undefined) {
251
+ fail(['mapping'], 'mapping is only valid for format "mapped-json"');
252
+ }
82
253
  return;
83
254
  }
84
- if (value === undefined) {
85
- fail(`value is required for operator ${operator}`);
255
+ if (props.mapping === undefined) {
256
+ fail(['mapping'], 'mapping is required for format "mapped-json"');
86
257
  return;
87
258
  }
88
- if (scalarOperandOperators.has(operator) && (typeof value === 'object')) {
89
- fail(`${operator} takes one scalar value`);
90
- }
91
- else if (numberOperandOperators.has(operator) && typeof value !== 'number') {
92
- fail(`${operator} takes one finite JSON number`);
93
- }
94
- else if (timestampOperandOperators.has(operator) && typeof value !== 'string') {
95
- fail(`${operator} takes one RFC 3339 timestamp string`);
96
- }
97
- else if (operator === 'between' && (typeof value !== 'object' || Array.isArray(value))) {
98
- fail('between takes a {min, max} object');
259
+ const membership = props.mapping.membership;
260
+ if (membership === undefined) {
261
+ fail(['mapping', 'membership'], 'mapping.membership is required');
262
+ return;
99
263
  }
100
- else if (operator === 'in' && !Array.isArray(value)) {
101
- fail(`in takes an array of 1 to ${maxInValues} scalars`);
264
+ const sources = [membership.segmentIdsPath, membership.objectNamePattern]
265
+ .filter((value) => value !== undefined && value !== '');
266
+ if (sources.length !== 1) {
267
+ fail(['mapping', 'membership'], 'mapping.membership must declare exactly one of segmentIdsPath or objectNamePattern');
102
268
  }
103
- });
104
- const requestRuleExpressionSchema = z.object({
105
- all: z.array(requestRuleConditionSchema).optional(),
106
- any: z.array(requestRuleConditionSchema).optional(),
107
- });
269
+ }
270
+ // scopedID is the experiment scope's path-safe grammar (validateScope): ""
271
+ // is the homepage route; segments are identifiers; ".." is rejected.
272
+ const scopedID = z.string().refine((value) => {
273
+ const trimmed = value.replace(/^\/+|\/+$/g, '');
274
+ if (trimmed === '')
275
+ return true;
276
+ if (trimmed.includes('..'))
277
+ return false;
278
+ return trimmed.split('/').every((part) => /^[A-Za-z0-9_-]+$/.test(part));
279
+ }, { message: 'must be a path-safe scoped id' });
108
280
  export const segmentDataModel = defineDataModel({
109
281
  type: 'segment',
110
282
  schema: z.object({
111
- id: z.string().min(1),
112
- displayName: z.string().min(1),
113
- status: resourceStatus,
114
- priority: z.number().int().nonnegative().optional(),
283
+ id: z.string().regex(/^(?!base$)[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens; "base" is reserved (it names the base variant key and the no-leg-won selectedLeg sentinel, ADR 0050)'),
284
+ displayName: z.string().regex(/\S/, 'displayName is required'),
285
+ status: z.enum(['draft', 'enabled', 'disabled']),
286
+ priority: z.number().int().min(0).optional(),
115
287
  description: z.string().optional(),
288
+ // Absent = a static label the deriver never evaluates. The conditions budget (64) and geo's country-or-region requirement stay with the write gate: this model compiles to JSON Schema (compileSchema), which cannot represent refinements.
116
289
  criteria: segmentCriteriaSchema.optional(),
117
290
  }),
118
291
  });
119
292
  export const audienceDataModel = defineDataModel({
120
293
  type: 'audience',
121
294
  schema: z.object({
122
- id: z.string().min(1),
123
- displayName: z.string().min(1),
124
- status: resourceStatus,
125
- priority: z.number().int().nonnegative(),
295
+ id: z.string().regex(/^(?!base$)[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens; "base" is reserved (it names the base variant key and the no-leg-won selectedLeg sentinel, ADR 0050)'),
296
+ displayName: z.string().regex(/\S/, 'displayName is required'),
297
+ description: z.string().optional(),
298
+ status: z.enum(['draft', 'enabled', 'disabled']),
299
+ priority: z.number().int().min(0),
126
300
  membershipSource: z.enum(['rule', 'segment', 'manual', 'external', 'none']),
127
301
  ruleRef: z.string().optional(),
128
302
  segmentRef: z.string().optional(),
129
303
  providerRef: z.string().optional(),
130
- externalSegmentId: z
131
- .string()
132
- .min(1)
133
- .max(256)
134
- .refine((value) => value.trim() === value, {
135
- message: 'externalSegmentId must not have leading or trailing whitespace',
136
- })
137
- .optional(),
304
+ externalSegmentId: z.string().min(1).max(256).refine((value) => value.trim() === value, { message: 'must not have leading or trailing whitespace' }).optional(),
138
305
  }),
139
306
  });
140
- const providerIdentityResolutionSchema = z.object({
141
- field: z.string().regex(/^(cookie|query|header)\..+$/, 'field must be cookie.<name>, query.<name>, or header.<name>'),
142
- }).strict();
143
307
  export const audienceProviderDataModel = defineDataModel({
144
308
  type: 'audience_provider',
145
309
  schema: z.object({
146
- id: z.string().regex(/^[A-Za-z0-9_-]+$/, 'id must contain only letters, numbers, underscores, or hyphens'),
147
- displayName: z.string().min(1),
148
- status: resourceStatus,
310
+ id: z.string().regex(/^(?!base$)[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens; "base" is reserved (it names the base variant key and the no-leg-won selectedLeg sentinel, ADR 0050)'),
311
+ displayName: z.string().regex(/\S/, 'displayName is required'),
312
+ status: z.enum(['draft', 'enabled', 'disabled']),
149
313
  format: z.enum(['aep-batch-export', 'mapped-json']),
150
314
  identity: z.object({
151
- sourceNamespace: z.string().min(1),
152
- resolution: z.array(providerIdentityResolutionSchema).optional(),
315
+ sourceNamespace: z.string().regex(/\S/, 'sourceNamespace is required').refine((value) => value.trim().toLowerCase() !== 'aci_id', { message: 'must be an external namespace, not the first-party aci_id namespace' }),
316
+ absencePolicy: z.enum(['unlink', 'retain']),
317
+ resolution: z.array(z.object({
318
+ field: z.string().regex(/^(cookie|query|header)\.[A-Za-z0-9_-]+$/),
319
+ }).strict()).optional(),
153
320
  }).strict(),
154
- membershipTtlDays: z.number().int().positive(),
321
+ membershipTtlDays: z.number().int().min(1),
322
+ // The write gate compiles it (CompileProviderDumpSchema); the schema only pins the object envelope.
155
323
  dumpSchema: z.record(z.string(), z.unknown()).optional(),
156
- // mapped-json extraction declarations (required for that format; the Go
157
- // write gate owns the full matrix — exactly one membership source, valid
158
- // RFC 6901 pointers, a compiling single-capture pattern).
324
+ // mapped-json extraction declarations (required for that format; the Go write gate owns the full matrix — valid RFC 6901 pointers, a compiling single-capture pattern).
159
325
  mapping: z.object({
160
326
  identity: z.object({
161
- path: z.string().regex(/^\//, 'identity.path must be an RFC 6901 JSON pointer'),
327
+ path: z.string().regex(/^\//),
162
328
  }).strict(),
163
329
  membership: z.object({
164
330
  segmentIdsPath: z.string().regex(/^\//).optional(),
165
- objectNamePattern: z.string().min(1).optional(),
331
+ objectNamePattern: z.string().regex(/\S/, 'objectNamePattern must not be blank').optional(),
166
332
  }).strict(),
167
333
  qualifiedAtPath: z.string().regex(/^\//).optional(),
168
334
  }).strict().optional(),
169
335
  discovery: z.object({
170
336
  mode: z.enum(['draft', 'report-only']),
171
- maxPerImport: z.number().int().positive().max(1000).optional(),
337
+ maxPerImport: z.number().int().min(1).max(1000).optional(),
172
338
  }).strict(),
173
339
  limits: z.object({
174
- maxDumpBytes: z.number().int().positive().optional(),
175
- maxRecords: z.number().int().positive().optional(),
176
- maxIdentitiesPerRecord: z.number().int().positive().optional(),
340
+ maxDumpBytes: z.number().int().min(1).optional(),
341
+ maxRecords: z.number().int().min(1).optional(),
342
+ maxIdentitiesPerRecord: z.number().int().min(1).optional(),
177
343
  }).strict().optional(),
178
- }),
344
+ }).strict().superRefine(providerFormatMappingMatrix),
179
345
  });
180
346
  export const audienceRuleDataModel = defineDataModel({
181
347
  type: 'audience_rule',
182
348
  schema: z.object({
183
- id: z.string().min(1),
184
- status: resourceStatus,
349
+ id: z.string().regex(/^(?!base$)[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens; "base" is reserved (it names the base variant key and the no-leg-won selectedLeg sentinel, ADR 0050)'),
350
+ status: z.enum(['draft', 'enabled', 'disabled']),
185
351
  description: z.string().optional(),
186
352
  expression: requestRuleExpressionSchema,
187
353
  }),
188
354
  });
189
- export const visitorProfileDataModel = defineDataModel({
190
- type: 'visitor_profile',
191
- schema: z.object({
192
- visitorId: z.string().min(1),
193
- audienceIds: z.array(z.string().min(1)).optional(),
194
- segmentIds: z.array(z.string().min(1)).optional(),
195
- traits: z.record(z.string(), scalarValue).optional(),
196
- updatedAt: z.string().optional(),
197
- }),
198
- });
199
355
  export const goalDataModel = defineDataModel({
200
356
  type: 'goal',
201
357
  schema: z.object({
202
- id: z.string().min(1),
203
- displayName: z.string().min(1),
204
- status: resourceStatus,
205
- eventName: z.string().min(1),
206
- eventRule: requestRuleExpressionSchema.optional(),
358
+ id: z.string().regex(/^[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens'),
359
+ displayName: z.string().regex(/\S/, 'displayName is required'),
360
+ status: z.enum(['draft', 'enabled', 'disabled']),
361
+ eventName: z.string().regex(/^(?:rum\.page_view|rum\.link_click|rum\.variation_view|rum\.web_vital|experiment\.exposure|experiment\.conversion|experiment\.engagement|custom\.[^\s/\\]+)$/, 'must be one of the rum.* or experiment.* events, or use the custom.* namespace').max(128),
362
+ // Event scope (expression.ScopeEvent): referrer, page.path, properties.*, utm.* — request-only fields are rejected.
363
+ eventRule: eventRuleExpressionSchema.optional(),
207
364
  }),
208
365
  });
209
366
  export const experimentDataModel = defineDataModel({
210
367
  type: 'experiment',
211
368
  schema: z.object({
212
- id: z.string().min(1),
213
- displayName: z.string().min(1),
214
- status: experimentStatus,
215
- kind: experimentKind,
216
- priority: z.number().int().nonnegative(),
369
+ id: z.string().regex(/^[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens'),
370
+ displayName: z.string().regex(/\S/, 'displayName is required'),
371
+ status: z.enum(['draft', 'running', 'paused', 'stopped', 'promoted', 'archived']),
372
+ kind: z.enum(['ab', 'abn', 'xt']),
373
+ priority: z.number().int().min(0),
217
374
  allocationVersion: z.string().optional(),
218
375
  salt: z.string().optional(),
376
+ // At least one route or fragment (validateScope); "" is the homepage.
219
377
  scope: z.object({
220
- routes: z.array(z.string().min(1)).optional(),
221
- fragments: z.array(z.string().min(1)).optional(),
222
- }),
223
- // ADR 0050: mode is claims or selected; absent means claims. The object
224
- // stays strict so an unknown mode value — or an unknown key — is refused.
378
+ routes: z.array(scopedID).optional(),
379
+ fragments: z.array(scopedID).optional(),
380
+ }).refine((value) => Object.values(value).some((list) => Array.isArray(list) && list.length > 0), { message: 'must include at least one route or fragment' }),
225
381
  eligibility: z.object({
226
- audienceRef: z.string().min(1),
382
+ // Top-level ref grammar: $ref:audiences/{id}, one path-safe segment (parseAudienceRef).
383
+ audienceRef: z.string().regex(/^\$ref:audiences\/[A-Za-z0-9_-]+$/),
227
384
  mode: z.enum(['claims', 'selected']).optional(),
228
385
  }).strict().optional(),
229
386
  primaryGoalRef: z.string().optional(),
230
387
  primaryGoalId: z.string().optional(),
231
- goalRefs: z.array(z.string().min(1)).optional(),
232
- goalIds: z.array(z.string().min(1)).optional(),
388
+ goalRefs: z.array(z.string().regex(/\S/, 'entries must be non-blank')).optional(),
389
+ goalIds: z.array(z.string().regex(/\S/, 'entries must be non-blank')).optional(),
390
+ promotedArmId: z.string().optional(),
391
+ promotedAt: z.string().optional(),
392
+ horizon: z.object({
393
+ uniqueExposedPerArm: z.number().int().min(1),
394
+ conversionWindowDays: z.number().int().min(1).max(90).optional(),
395
+ declaredAfterRun: z.literal(true).optional(),
396
+ }).strict().optional(),
233
397
  arms: z.array(z.object({
234
- id: z.string().min(1),
235
- displayName: z.string().min(1),
398
+ id: z.string().regex(/^[A-Za-z0-9_-]+$/, 'must contain only letters, numbers, underscores, or hyphens'),
399
+ displayName: z.string().regex(/\S/, 'displayName is required'),
236
400
  role: z.enum(['control', 'variant']),
237
401
  allocationBasisPoints: z.number().int().min(0).max(10000).optional(),
238
402
  rule: requestRuleExpressionSchema.optional(),
239
- })).min(1),
403
+ }).strict()).min(1),
240
404
  }),
241
405
  });
242
406
  export const experimentationDataModels = [
@@ -248,6 +412,5 @@ export const audienceDataModels = [
248
412
  audienceDataModel,
249
413
  audienceRuleDataModel,
250
414
  audienceProviderDataModel,
251
- visitorProfileDataModel,
252
415
  ...experimentationDataModels,
253
416
  ];
@@ -352,12 +352,10 @@ function isImageSchema(schema) {
352
352
  return compiled?.type === 'object'
353
353
  && properties.$type?.const === 'gradial.image'
354
354
  && properties.assetId?.type === 'string'
355
- && properties.versionId?.type === 'string'
356
355
  && properties.alt?.type === 'string'
357
356
  && properties.sources?.type === 'array'
358
357
  && required.has('$type')
359
358
  && required.has('assetId')
360
- && required.has('versionId')
361
359
  && required.has('alt')
362
360
  && required.has('sources');
363
361
  }