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

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 (48) hide show
  1. package/README.md +4 -4
  2. package/dist/astro/preview.js +2 -17
  3. package/dist/audience-data-models.d.ts +193 -16
  4. package/dist/audience-data-models.js +47 -2
  5. package/dist/collect/batcher.d.ts +6 -5
  6. package/dist/collect/batcher.js +6 -5
  7. package/dist/edge/expression-capabilities.v1.d.ts +106 -0
  8. package/dist/edge/expression-capabilities.v1.js +165 -0
  9. package/dist/internal/domain/edge/identity.js +1 -1
  10. package/dist/internal/domain/edge/mimic_token.js +1 -2
  11. package/dist/internal/domain/edge/personalization-core.d.ts +14 -4
  12. package/dist/internal/domain/edge/personalization-core.js +472 -54
  13. package/dist/internal/domain/edge/record/codec.d.ts +5 -2
  14. package/dist/internal/domain/edge/record/codec.js +83 -10
  15. package/dist/internal/domain/edge/resolve.js +13 -2
  16. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
  17. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
  18. package/dist/internal/domain/edge/types.d.ts +36 -9
  19. package/dist/internal/domain/edge/types.js +3 -3
  20. package/dist/mimic/core.d.ts +0 -32
  21. package/dist/mimic/core.js +12 -108
  22. package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
  23. package/dist/next/vercel-edge-projection-v1.js +9 -545
  24. package/dist/preview/core.d.ts +1 -0
  25. package/dist/preview/core.js +27 -0
  26. package/dist/react/VideoPlayer.d.ts +1 -1
  27. package/dist/react/index.d.ts +1 -1
  28. package/package.json +16 -7
  29. package/templates/common/.agents/skills/aci/SKILL.md +6 -0
  30. package/templates/common/.agents/skills/dam-delivery/SKILL.md +228 -0
  31. package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +107 -0
  32. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +131 -0
  33. package/dist/edge/personalization-core.d.ts +0 -35
  34. package/dist/edge/personalization-core.js +0 -483
  35. package/dist/next/collect-route.d.ts +0 -25
  36. package/dist/next/collect-route.js +0 -479
  37. package/dist/next/envelope-digest.d.ts +0 -21
  38. package/dist/next/envelope-digest.js +0 -58
  39. package/dist/next/personalization-host.d.ts +0 -138
  40. package/dist/next/personalization-host.js +0 -280
  41. package/dist/next/root-layout.d.ts +0 -4
  42. package/dist/next/root-layout.js +0 -5
  43. package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
  44. package/dist/next/vercel-collect-sdk-route.js +0 -181
  45. package/dist/next/vercel-personalization.d.ts +0 -4
  46. package/dist/next/vercel-personalization.js +0 -4
  47. package/dist/next/vercel-s3-personalization.d.ts +0 -80
  48. package/dist/next/vercel-s3-personalization.js +0 -571
@@ -10,14 +10,17 @@ interface CompactDispatch {
10
10
  ap: string;
11
11
  }
12
12
  interface CompactCondition {
13
- f: string;
14
- o: string;
13
+ f?: string;
14
+ o?: string;
15
15
  w?: unknown;
16
+ a?: CompactCondition[];
17
+ n?: CompactCondition[];
16
18
  }
17
19
  interface CompactRule {
18
20
  a?: CompactCondition[];
19
21
  n?: CompactCondition[];
20
22
  }
23
+ export declare const COMPACT_MAX_DEPTH = 32;
21
24
  interface CompactVariant {
22
25
  pr?: number;
23
26
  r?: CompactRule;
@@ -41,9 +41,13 @@ const DISPATCH_KIND_FROM_COMPACT = {
41
41
  };
42
42
  const OPERATOR_TO_COMPACT = {
43
43
  equals: 'eq', contains: 'ct', startsWith: 'sw', exists: 'ex',
44
+ greaterThan: 'gt', greaterThanOrEqual: 'ge', lessThan: 'lt', lessThanOrEqual: 'le',
45
+ before: 'bf', after: 'af', between: 'bt', in: 'in',
44
46
  };
45
47
  const OPERATOR_FROM_COMPACT = {
46
48
  eq: 'equals', ct: 'contains', sw: 'startsWith', ex: 'exists',
49
+ gt: 'greaterThan', ge: 'greaterThanOrEqual', lt: 'lessThan', le: 'lessThanOrEqual',
50
+ bf: 'before', af: 'after', bt: 'between', in: 'in',
47
51
  };
48
52
  const EXPERIMENT_STATUS_TO_COMPACT = {
49
53
  running: 'r', paused: 'p', completed: 'd',
@@ -78,20 +82,89 @@ function expandDispatch(raw) {
78
82
  return INVALID;
79
83
  return { kind, artifactPath };
80
84
  }
81
- function projectCondition(cond) {
82
- if (!cond || !cond.field || !cond.operator)
83
- return null;
85
+ // Structural recursion guard, deliberately looser than the authored MaxDepth
86
+ // (5) the validators enforce. The codec's job is to bound adversarial
87
+ // recursion in a byte-constrained runtime; the authored contract is
88
+ // validation's. Keeping it looser is what lets an over-depth expression
89
+ // round-trip and be rejected by validation identically in every runtime,
90
+ // rather than being erased by the codec first (ADR 0040).
91
+ export const COMPACT_MAX_DEPTH = 32;
92
+ // A member carrying all/any is an envelope, not a condition — the same
93
+ // presence rule the evaluators apply, so the codec cannot disagree with them
94
+ // about what a member is.
95
+ function isGroupMember(cond) {
96
+ return !!cond && (cond.all !== undefined || cond.any !== undefined);
97
+ }
98
+ function projectCondition(cond, depth) {
99
+ // Projection runs at publish time in a trusted context behind write-time
100
+ // validation: a malformed condition or an operator with no compact token is
101
+ // vocabulary/codec drift, and silently dropping the condition would ship a
102
+ // rule missing a leaf — MORE permissive than authored (ADR 0038). The same
103
+ // reasoning covers a group node: dropping one publishes a rule the author
104
+ // did not write. Expansion stays fail-closed for already-deployed bundles.
105
+ if (!cond) {
106
+ throw new Error('edge rule condition must name a field and operator');
107
+ }
108
+ if (isGroupMember(cond)) {
109
+ if (depth > COMPACT_MAX_DEPTH) {
110
+ throw new Error('edge rule nests deeper than the codec can project');
111
+ }
112
+ if (cond.field !== undefined || cond.operator !== undefined || cond.value !== undefined) {
113
+ throw new Error('edge rule member mixes group and leaf keys');
114
+ }
115
+ const group = {};
116
+ if (cond.all && cond.all.length > 0)
117
+ group.a = cond.all.map((member) => projectCondition(member, depth + 1));
118
+ if (cond.any && cond.any.length > 0)
119
+ group.n = cond.any.map((member) => projectCondition(member, depth + 1));
120
+ if (!group.a && !group.n) {
121
+ throw new Error('edge rule group must include at least one member');
122
+ }
123
+ return group;
124
+ }
125
+ if (!cond.field || !cond.operator) {
126
+ throw new Error('edge rule condition must name a field and operator');
127
+ }
84
128
  const compactOp = OPERATOR_TO_COMPACT[cond.operator];
85
- if (!compactOp)
86
- return null;
129
+ if (!compactOp) {
130
+ throw new Error(`edge rule operator has no compact token: ${cond.operator}`);
131
+ }
87
132
  const result = { f: cond.field, o: compactOp };
88
133
  if (cond.value !== undefined)
89
134
  result.w = cond.value;
90
135
  return result;
91
136
  }
92
- function expandCondition(raw) {
137
+ function expandCondition(raw, depth) {
93
138
  if (!isObj(raw))
94
139
  return INVALID;
140
+ if (raw.a !== undefined || raw.n !== undefined) {
141
+ if (depth > COMPACT_MAX_DEPTH)
142
+ return INVALID;
143
+ // A member cannot be both: expanding a mixed record would hand the
144
+ // evaluator something neither a group nor a condition.
145
+ if (raw.f !== undefined || raw.o !== undefined || raw.w !== undefined)
146
+ return INVALID;
147
+ const group = {};
148
+ for (const [compact, key] of [['a', 'all'], ['n', 'any']]) {
149
+ if (raw[compact] === undefined)
150
+ continue;
151
+ if (!Array.isArray(raw[compact]))
152
+ return INVALID;
153
+ const members = [];
154
+ for (const member of raw[compact]) {
155
+ const expanded = expandCondition(member, depth + 1);
156
+ if (expanded === INVALID)
157
+ return INVALID;
158
+ members.push(expanded);
159
+ }
160
+ group[key] = members;
161
+ }
162
+ // Length, not truthiness: [] is truthy, so a group whose only list is
163
+ // empty would otherwise expand into a member with no predicate at all.
164
+ if ((group.all?.length || 0) + (group.any?.length || 0) === 0)
165
+ return INVALID;
166
+ return group;
167
+ }
95
168
  const f = raw.f;
96
169
  const co = raw.o;
97
170
  if (typeof f !== 'string')
@@ -109,9 +182,9 @@ function projectRule(rule) {
109
182
  return undefined;
110
183
  const result = {};
111
184
  if (rule.all && rule.all.length > 0)
112
- result.a = rule.all.map(projectCondition).filter((c) => c !== null);
185
+ result.a = rule.all.map((cond) => projectCondition(cond, 1));
113
186
  if (rule.any && rule.any.length > 0)
114
- result.n = rule.any.map(projectCondition).filter((c) => c !== null);
187
+ result.n = rule.any.map((cond) => projectCondition(cond, 1));
115
188
  if (!result.a && !result.n)
116
189
  return undefined;
117
190
  return result;
@@ -124,7 +197,7 @@ function expandRule(raw) {
124
197
  const rule = {};
125
198
  if (Array.isArray(raw.a))
126
199
  for (const c of raw.a) {
127
- const r = expandCondition(c);
200
+ const r = expandCondition(c, 1);
128
201
  if (r === INVALID)
129
202
  return INVALID;
130
203
  if (r)
@@ -132,7 +205,7 @@ function expandRule(raw) {
132
205
  }
133
206
  if (Array.isArray(raw.n))
134
207
  for (const c of raw.n) {
135
- const r = expandCondition(c);
208
+ const r = expandCondition(c, 1);
136
209
  if (r === INVALID)
137
210
  return INVALID;
138
211
  if (r)
@@ -49,7 +49,7 @@ export async function resolvePersonalizationRequest(request, config) {
49
49
  : consent.allowsIdentityTier
50
50
  ? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
51
51
  : null;
52
- const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request), matchedRoute, claims, config.experiments, identityHash
52
+ const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request, now, config.onRuleRejected), matchedRoute, claims, config.experiments, identityHash
53
53
  ? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
54
54
  : undefined);
55
55
  if (decision.variant) {
@@ -62,7 +62,13 @@ export async function resolvePersonalizationRequest(request, config) {
62
62
  }
63
63
  return { variantKey };
64
64
  }
65
- function toPersonalizationRequestContext(request) {
65
+ // The return type is annotated rather than inferred so the contract is stated
66
+ // at the construction site: a misspelled property would then silently never
67
+ // fire at the sink, which is the exact failure mode this context exists to
68
+ // make visible. No tsconfig covers internal/domain/** today (esbuild/tsx strip
69
+ // types without checking), so the annotation is documentation now and becomes
70
+ // real protection under any future typecheck of these trees.
71
+ function toPersonalizationRequestContext(request, now, onRuleRejected) {
66
72
  const url = new URL(request.url);
67
73
  const cookies = parseCookieHeader(request.headers.get('cookie'));
68
74
  const query = {};
@@ -80,6 +86,11 @@ function toPersonalizationRequestContext(request) {
80
86
  query,
81
87
  headers,
82
88
  cookies: Object.fromEntries(cookies.entries()),
89
+ // The host's pinned per-request clock resolves the time.now fact
90
+ // (ADR 0038), so claims verification and window evaluation read one
91
+ // instant — and config.now-injected tests are deterministic.
92
+ now: now.getTime(),
83
93
  ...(Object.keys(geo).length > 0 ? { signals: { geo } } : {}),
94
+ ...(onRuleRejected ? { onRuleRejected } : {}),
84
95
  };
85
96
  }
@@ -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
+ }
@@ -4,10 +4,17 @@ export interface EdgeDispatch {
4
4
  /** Release-relative immutable HTML artifact selected for this route. */
5
5
  artifactPath: string;
6
6
  }
7
+ /**
8
+ * One member of an expression group: a leaf condition, or a nested envelope
9
+ * when it carries `all`/`any` (ADR 0040). Presence of a group key decides
10
+ * which it is, so flat records stay a byte-identical subset.
11
+ */
7
12
  export interface EdgeRuleCondition {
8
- field: string;
9
- operator: 'exists' | 'equals' | 'contains' | 'startsWith' | string;
13
+ field?: string;
14
+ operator?: 'exists' | 'equals' | 'contains' | 'startsWith' | 'greaterThan' | 'greaterThanOrEqual' | 'lessThan' | 'lessThanOrEqual' | 'before' | 'after' | 'between' | 'in' | string;
10
15
  value?: unknown;
16
+ all?: EdgeRuleCondition[];
17
+ any?: EdgeRuleCondition[];
11
18
  }
12
19
  export interface EdgeRuleExpression {
13
20
  all?: EdgeRuleCondition[];
@@ -48,9 +55,28 @@ export interface PersonalizationRequestContext {
48
55
  signals?: {
49
56
  geo?: Record<string, string>;
50
57
  };
58
+ /**
59
+ * Epoch milliseconds resolving the time.now clock fact (ADR 0038).
60
+ * Defaults to Date.now(); hosts may pin it once per request so every
61
+ * expression in one evaluation reads the same instant.
62
+ */
63
+ now?: number;
51
64
  identityHash?: string;
52
65
  identityNamespace?: string;
53
66
  siteId?: string;
67
+ /**
68
+ * Called when a variant's rule fails validation during serving (ADR 0040).
69
+ * Records pass the write-time gate before shipping, so this only fires on
70
+ * version skew or a projection bug — a precise signal that must not vanish.
71
+ * Receives routing facts and diagnostic codes only, never request data.
72
+ */
73
+ onRuleRejected?: (event: RuleRejectionEvent) => void;
74
+ }
75
+ export interface RuleRejectionEvent {
76
+ variantKey: string;
77
+ diagnosticCode: string;
78
+ diagnosticPath: string;
79
+ diagnosticCount: number;
54
80
  }
55
81
  export interface PersonalizationClaims {
56
82
  aud?: string[];
@@ -58,13 +84,7 @@ export interface PersonalizationClaims {
58
84
  iat: number;
59
85
  exp: number;
60
86
  }
61
- export interface PersonalizationVariant {
62
- variantKey: string;
63
- variantValues?: Record<string, string>;
64
- priority?: number;
65
- rule?: EdgeRuleExpression;
66
- dispatch: EdgeDispatch;
67
- }
87
+ export type PersonalizationVariant = EdgeRouteVariant;
68
88
  export interface PersonalizationRoute {
69
89
  variants?: Record<string, PersonalizationVariant>;
70
90
  }
@@ -174,6 +194,13 @@ export interface PersonalizationHostConfig {
174
194
  aud?: string[];
175
195
  seg?: string[];
176
196
  };
197
+ /**
198
+ * Passed straight through to the request context's rejection sink so a host
199
+ * can record a variant skipped for a rule it could not validate (ADR 0040).
200
+ * Unset means the skip stays silent, which is what made the skew incident
201
+ * the ADR predicts undebuggable.
202
+ */
203
+ onRuleRejected?: (event: RuleRejectionEvent) => void;
177
204
  }
178
205
  export interface PersonalizationResolution {
179
206
  variantKey: string;
@@ -1,7 +1,7 @@
1
1
  // Shared edge-decisioning types.
2
2
  //
3
3
  // These types are the canonical shape for the edge decisioning algorithm.
4
- // All host adapters (AWS, Cloudflare, Vercel, emulator) import from here.
5
- // The SDK's personalization-core types mirror these — the two are kept in
6
- // sync by cross-language round-trip tests (spec edge-decisioning R-conformance).
4
+ // All host adapters (AWS, Cloudflare, Vercel, emulator) import from here,
5
+ // and the SDK build compiles this module into its dist/internal tree — there
6
+ // is no SDK-side mirror (ADR 0041 retired the hand-ported copy).
7
7
  export { IdentityErasureFencedError } from '../../platform/collectionstore/errors.js';
@@ -1,34 +1,2 @@
1
- export interface MimicClaims {
2
- scope: string;
3
- siteId: string;
4
- aud?: string[];
5
- seg?: string[];
6
- sub?: string;
7
- jti?: string;
8
- iat: string;
9
- expiresAt: string;
10
- }
11
- export declare const MIMIC_PARAM = "aci_mimic";
12
- export declare const MIMIC_COOKIE = "aci_mimic";
13
- export declare const MIMIC_EXIT = "exit";
14
- export declare const MIMIC_MAX_MEMBERSHIPS = 32;
15
- export declare const MIMIC_MAX_TOKEN_BYTES = 2048;
16
- export declare function validateMimicToken(token: string, signKey: string, siteId: string): Promise<MimicClaims | null>;
17
- export interface MimicCookie {
18
- name: string;
19
- value: string;
20
- options: {
21
- httpOnly: boolean;
22
- sameSite: 'lax';
23
- maxAge: number;
24
- path: string;
25
- secure?: boolean;
26
- };
27
- }
28
- export declare function createMimicEntryCookie(token: string, expiresAt: string, secure?: boolean): MimicCookie;
29
- export declare function createMimicClearCookie(secure?: boolean): MimicCookie;
30
- export declare function createMimicBannerHTML(claims: Pick<MimicClaims, 'aud' | 'seg'>, expiresAt: string): string;
31
- export declare function injectMimicBannerIntoHTML(html: string, banner: string): string;
32
1
  export declare const MIMIC_BANNER_HEADER = "x-aci-mimic-banner";
33
- export declare function encodeMimicBanner(claims: MimicClaims): string;
34
2
  export declare function decodeMimicBanner(encoded: string): string;