@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
@@ -5,12 +5,6 @@
5
5
  // This is the pure decision algorithm — no I/O, no cookies, no identity
6
6
  // minting. The caller resolves identity and claims, then calls this.
7
7
  import { variantValuesFromKey } from './record/codec.js';
8
- // Build-time constant. The CloudFront Functions bundle replaces this with
9
- // true and uses the runtime's built-in crypto module; other host bundles
10
- // replace it with false and retain the portable implementation.
11
- export function selectPersonalizationVariant(request, route, claims, experiments, identity) {
12
- return resolvePersonalizationDecision(request, route, claims, experiments, identity).variant;
13
- }
14
8
  export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
15
9
  if (!route)
16
10
  return { variant: null, experiment: null };
@@ -31,6 +25,13 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
31
25
  continue;
32
26
  if (assignment && !candidate.variantKey)
33
27
  candidate.variantKey = assignment.variantKey;
28
+ // A bucket assignment is candidacy, not entitlement: an arm variant
29
+ // carrying a rule must pass the same serve-time evaluation the rule
30
+ // leg applies (ADR 0040). A rejection fires the onRuleRejected sink
31
+ // inside evaluateRule; the candidate is skipped — never served, and
32
+ // never left behind as a stale experiment decision.
33
+ if (candidate.rule && !evaluateRule(request, candidate.rule, claims, candidate.variantKey))
34
+ continue;
34
35
  if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
35
36
  variant = candidate;
36
37
  experiment = assignment;
@@ -58,7 +59,7 @@ function selectRuleVariant(request, route, claims, experiments) {
58
59
  continue;
59
60
  const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
60
61
  || (values.segment && claims && (claims.seg || []).includes(values.segment));
61
- const ruleMatch = variant.rule ? evaluateRule(request, variant.rule) : false;
62
+ const ruleMatch = variant.rule ? evaluateRule(request, variant.rule, claims, variant.variantKey) : false;
62
63
  if (membershipMatch || ruleMatch)
63
64
  matches.push(variant);
64
65
  }
@@ -140,10 +141,54 @@ function requestDimensionsMatch(request, values) {
140
141
  return true;
141
142
  }
142
143
  const MAX_EXPRESSION_CONDITIONS = 64;
144
+ // Authored nesting depth (ADR 0040). The top-level envelope is depth 1 and a
145
+ // group node is one deeper than the list containing it. Pinned to the
146
+ // capability artifact by the drift test, like the grammars above.
147
+ const MAX_EXPRESSION_DEPTH = 5;
148
+ // A member carrying `all` or `any` is an envelope, not a condition. Presence
149
+ // decides — never the value's type — so all three runtimes classify the same
150
+ // member the same way.
151
+ function isExpressionGroup(member) {
152
+ // Null-guarded because this runs on a viewer request path: a malformed
153
+ // member must fail closed to the base experience, never throw. A non-object
154
+ // member is classified as a leaf and rejected by ordinary validation.
155
+ return !!member && typeof member === 'object'
156
+ && (Object.prototype.hasOwnProperty.call(member, 'all')
157
+ || Object.prototype.hasOwnProperty.call(member, 'any'));
158
+ }
159
+ // expressionMembers returns a group node's member list: [] when the key is
160
+ // absent, null when it is present but not an array. Go rejects that document
161
+ // at decode; here it fails closed as a diagnostic rather than letting the
162
+ // member reclassify as a leaf.
163
+ function expressionMembers(member, group) {
164
+ if (!Object.prototype.hasOwnProperty.call(member, group))
165
+ return [];
166
+ const value = member[group];
167
+ if (!Array.isArray(value))
168
+ return null;
169
+ return value;
170
+ }
171
+ // countExpressionLeaves counts leaves across the tree, applying the same depth
172
+ // cutoff the validating walk does so the two bounds cannot disagree about one
173
+ // expression.
174
+ function countExpressionLeaves(members, depth) {
175
+ let total = 0;
176
+ for (const member of members) {
177
+ if (!isExpressionGroup(member)) {
178
+ total++;
179
+ continue;
180
+ }
181
+ if (depth + 1 > MAX_EXPRESSION_DEPTH)
182
+ continue;
183
+ total += countExpressionLeaves(expressionMembers(member, 'all') || [], depth + 1)
184
+ + countExpressionLeaves(expressionMembers(member, 'any') || [], depth + 1);
185
+ }
186
+ return total;
187
+ }
143
188
  /** Evaluate one request expression and produce the same redacted trace used for qualification. */
144
189
  export function evaluateRequestExpression(rule, resolve) {
145
- const all = (rule.all || []);
146
- const any = (rule.any || []);
190
+ const all = (Array.isArray(rule.all) ? rule.all : []);
191
+ const any = (Array.isArray(rule.any) ? rule.any : []);
147
192
  const empty = all.length + any.length === 0;
148
193
  const diagnostics = validateRequestExpression(rule, all, any);
149
194
  const trace = { scope: 'request', empty, groups: [], leaves: [] };
@@ -152,46 +197,136 @@ export function evaluateRequestExpression(rule, resolve) {
152
197
  const qualified = qualifyRequestExpression(all, any, resolve, trace);
153
198
  return { qualified, diagnostics, trace };
154
199
  }
155
- function evaluateRule(request, rule) {
156
- const all = (rule.all || []);
157
- const any = (rule.any || []);
158
- if (validateRequestExpression(rule, all, any).length > 0)
200
+ function evaluateRule(request, rule, claims, variantKey) {
201
+ const all = (Array.isArray(rule.all) ? rule.all : []);
202
+ const any = (Array.isArray(rule.any) ? rule.any : []);
203
+ const diagnostics = validateRequestExpression(rule, all, any);
204
+ if (diagnostics.length > 0) {
205
+ // Records pass the write-time gate before they ship, so a rule rejected
206
+ // HERE — at serve time — is always version skew or a projection bug, never
207
+ // ordinary authoring error. That makes it a precise signal, and discarding
208
+ // it silently is what made the predicted incident (author previews a rule
209
+ // successfully while a stale lane serves base) undebuggable (ADR 0040).
210
+ // Codes and paths only; never a fact, operand, or request value.
211
+ if (request.onRuleRejected) {
212
+ request.onRuleRejected({
213
+ variantKey: variantKey || '',
214
+ diagnosticCode: diagnostics[0].code,
215
+ diagnosticPath: diagnostics[0].path,
216
+ diagnosticCount: diagnostics.length,
217
+ });
218
+ }
159
219
  return false;
160
- return qualifyRequestExpression(all, any, (field) => signalFact(request, field));
220
+ }
221
+ // audience.memberships resolves from the verified (or mimic) claims object
222
+ // the decision entry points already hold, never from the request bag — an
223
+ // unverified cookie can never impersonate membership (ADR 0039).
224
+ return qualifyRequestExpression(all, any, (field) => field === 'audience.memberships'
225
+ ? membershipsFact(claims)
226
+ : signalFact(request, field));
227
+ }
228
+ // No claims → unavailable: an unconsented visitor is not a known non-member,
229
+ // so every operator fails closed, exists:false included. Verified claims with
230
+ // no memberships → absent, so exists:false selects exactly the consented
231
+ // visitor who belongs to nothing (ADR 0039).
232
+ function membershipsFact(claims) {
233
+ if (!claims)
234
+ return { status: 'unavailable' };
235
+ // Array.isArray, not || []: a malformed truthy aud (a scalar from a buggy
236
+ // claims writer) must degrade to absent, never reach the matcher's
237
+ // .some() and throw on a viewer-request path.
238
+ const memberships = Array.isArray(claims.aud) ? claims.aud : [];
239
+ return memberships.length > 0 ? { status: 'present', values: memberships } : { status: 'absent' };
161
240
  }
162
241
  function validateRequestExpression(rule, all, any) {
163
242
  const diagnostics = [];
164
243
  if (all.length + any.length === 0)
165
244
  diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
166
- if (all.length + any.length > MAX_EXPRESSION_CONDITIONS)
245
+ if (countExpressionLeaves(all, 1) + countExpressionLeaves(any, 1) > MAX_EXPRESSION_CONDITIONS)
167
246
  diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
168
247
  Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
169
248
  diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
170
249
  });
171
- for (const [group, conditions] of [['all', all], ['any', any]]) {
172
- conditions.forEach((condition, index) => validateRequestCondition(condition, `expression.${group}[${index}]`, diagnostics));
250
+ for (const [group, members] of [['all', all], ['any', any]]) {
251
+ validateExpressionMembers('expression', group, members, 1, diagnostics);
173
252
  }
174
253
  return diagnostics;
175
254
  }
176
- function qualifyRequestExpression(all, any, resolve, trace) {
255
+ // validateExpressionMembers validates one envelope's member list. depth is the
256
+ // depth of the envelope owning these members; the top-level envelope is 1.
257
+ function validateExpressionMembers(path, group, members, depth, diagnostics) {
258
+ members.forEach((member, index) => {
259
+ const memberPath = `${path}.${group}[${index}]`;
260
+ if (!member || typeof member !== 'object' || !isExpressionGroup(member)) {
261
+ validateRequestCondition(member || {}, memberPath, diagnostics);
262
+ return;
263
+ }
264
+ // One diagnostic per broken node, and no descent past it: a too-deep
265
+ // branch reports its depth once rather than once per buried condition.
266
+ if (depth + 1 > MAX_EXPRESSION_DEPTH) {
267
+ diagnostics.push({ code: 'expression.depth.exceeded', path: memberPath, scope: 'request', message: `${memberPath} must not nest more than ${MAX_EXPRESSION_DEPTH} levels deep` });
268
+ return;
269
+ }
270
+ const nestedAll = expressionMembers(member, 'all');
271
+ const nestedAny = expressionMembers(member, 'any');
272
+ if (nestedAll === null || nestedAny === null) {
273
+ const malformed = nestedAll === null ? 'all' : 'any';
274
+ diagnostics.push({ code: 'expression.group.unsupported', path: `${memberPath}.${malformed}`, scope: 'request', message: `${memberPath}.${malformed} must be an array of conditions or groups` });
275
+ return;
276
+ }
277
+ if (nestedAll.length + nestedAny.length === 0) {
278
+ diagnostics.push({ code: 'expression.empty', path: memberPath, scope: 'request', message: `${memberPath} must include at least one all or any condition` });
279
+ return;
280
+ }
281
+ Object.keys(member).filter((key) => key !== 'all' && key !== 'any').sort().forEach((key) => {
282
+ // Name the mistake, not its symptom: telling an author that `field` "is
283
+ // not a supported group" describes the classification, not the error.
284
+ const message = key === 'field' || key === 'operator' || key === 'value'
285
+ ? `${memberPath}.${key} is not supported: a member carrying all or any is a group, not a condition, so it cannot also carry ${key}`
286
+ : `${memberPath}.${key} is not a supported expression group`;
287
+ diagnostics.push({ code: 'expression.group.unsupported', path: `${memberPath}.${key}`, scope: 'request', message });
288
+ });
289
+ validateExpressionMembers(memberPath, 'all', nestedAll, depth + 1, diagnostics);
290
+ validateExpressionMembers(memberPath, 'any', nestedAny, depth + 1, diagnostics);
291
+ });
292
+ }
293
+ // qualifyRequestExpression applies the envelope truth table to one node's
294
+ // member lists, recursing through group nodes (ADR 0040). Evaluation stays
295
+ // exhaustive so the trace holds every leaf outcome.
296
+ //
297
+ // topLevel carries nesting's one asymmetry: the top envelope always records
298
+ // both group rows because they express the scope's empty-envelope policy,
299
+ // while a nested node records only for a list it carries members in — which
300
+ // also keeps the trace stable across a codec that drops empty lists.
301
+ function qualifyRequestExpression(all, any, resolve, trace, path = 'expression', topLevel = true) {
177
302
  let qualified = true;
178
- for (const [group, conditions] of [['all', all], ['any', any]]) {
303
+ for (const [group, members] of [['all', all], ['any', any]]) {
304
+ if (!topLevel && members.length === 0)
305
+ continue;
179
306
  let matched = 0;
180
- conditions.forEach((condition, index) => {
181
- const fact = normalizeFact(resolve(String(condition.field || '')));
182
- const outcome = matchRequestCondition(condition, fact);
183
- if (outcome.matched)
307
+ members.forEach((member, index) => {
308
+ const memberPath = `${path}.${group}[${index}]`;
309
+ let passed = false;
310
+ if (isExpressionGroup(member)) {
311
+ passed = qualifyRequestExpression(expressionMembers(member, 'all') || [], expressionMembers(member, 'any') || [], resolve, trace, memberPath, false);
312
+ }
313
+ else {
314
+ const fact = normalizeFact(resolve(String(member.field || '')));
315
+ const outcome = matchRequestCondition(member, fact);
316
+ passed = outcome.matched;
317
+ if (trace)
318
+ trace.leaves.push({
319
+ path: memberPath, group, index,
320
+ field: String(member.field || ''), operator: String(member.operator || ''),
321
+ status: fact.status, matched: outcome.matched, reason: outcome.reason,
322
+ });
323
+ }
324
+ if (passed)
184
325
  matched++;
185
- if (trace)
186
- trace.leaves.push({
187
- path: `expression.${group}[${index}]`, group, index,
188
- field: String(condition.field || ''), operator: String(condition.operator || ''),
189
- status: fact.status, matched: outcome.matched, reason: outcome.reason,
190
- });
191
326
  });
192
- const passed = group === 'all' ? matched === conditions.length : conditions.length === 0 || matched > 0;
327
+ const passed = group === 'all' ? matched === members.length : members.length === 0 || matched > 0;
193
328
  if (trace)
194
- trace.groups.push({ group, total: conditions.length, matched, passed });
329
+ trace.groups.push({ path: `${path}.${group}`, group, total: members.length, matched, passed });
195
330
  qualified = qualified && passed;
196
331
  }
197
332
  return qualified;
@@ -201,36 +336,94 @@ function validateRequestCondition(condition, path, diagnostics) {
201
336
  diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
202
337
  });
203
338
  const field = String(condition.field || '');
204
- if (field !== 'referrer' && field !== 'device.class' && !['query.', 'header.', 'cookie.', 'geo.', 'utm.'].some((prefix) => field.startsWith(prefix) && field.length > prefix.length)) {
339
+ 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)) {
205
340
  diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
206
341
  }
207
342
  const operator = String(condition.operator || '');
208
- if (!['equals', 'contains', 'startsWith', 'exists'].includes(operator)) {
343
+ if (!EXPRESSION_OPERATORS.includes(operator)) {
209
344
  diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
210
345
  return;
211
346
  }
212
347
  // device.class compares against a closed four-value set, so substring
213
348
  // operators express nothing the set cannot — and admitting them would
214
349
  // re-create the user-agent-substring habit inside the safe field (ADR 0037).
215
- if (field === 'device.class' && operator !== 'equals' && operator !== 'exists') {
350
+ if (field === 'device.class' && !['equals', 'in', 'exists'].includes(operator)) {
216
351
  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` });
217
352
  return;
218
353
  }
354
+ // time.now is a sub-second-churning clock: string operators are silently
355
+ // inert on it and the numeric family can never match an RFC 3339 string
356
+ // (ADR 0038).
357
+ if (field === 'time.now' && !['before', 'after', 'between', 'exists'].includes(operator)) {
358
+ 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` });
359
+ return;
360
+ }
361
+ // audience.memberships is an opaque-ID list: equals is already
362
+ // any-member-equals under the multi-valued match loop, so the substring
363
+ // family would only add silent overmatching of prefixed IDs, and the
364
+ // numeric/instant families can never match an ID (ADR 0039).
365
+ if (field === 'audience.memberships' && !['equals', 'in', 'exists'].includes(operator)) {
366
+ 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` });
367
+ return;
368
+ }
219
369
  const value = condition.value;
220
370
  if (operator === 'exists') {
221
371
  if (value !== undefined && value !== null && typeof value !== 'boolean') {
222
372
  diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
223
373
  }
374
+ return;
224
375
  }
225
- else if (value === undefined || value === null) {
376
+ if (value === undefined || value === null) {
226
377
  diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
378
+ return;
379
+ }
380
+ if (['equals', 'contains', 'startsWith'].includes(operator)) {
381
+ if (!['string', 'number', 'boolean'].includes(typeof value)
382
+ || (typeof value === 'number' && !expressionNumberInDomain(value))) {
383
+ 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` });
384
+ }
385
+ else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
386
+ 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)}` });
387
+ }
388
+ return;
389
+ }
390
+ if (['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual'].includes(operator)) {
391
+ if (typeof value !== 'number' || !expressionNumberInDomain(value)) {
392
+ 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}` });
393
+ }
394
+ return;
395
+ }
396
+ if (operator === 'before' || operator === 'after') {
397
+ if (typeof value !== 'string' || parseExpressionTimestamp(value) === null) {
398
+ 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}` });
399
+ }
400
+ return;
401
+ }
402
+ if (operator === 'between') {
403
+ if (!validExpressionRange(value)) {
404
+ 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` });
405
+ }
406
+ else if (field === 'time.now' && typeof value.min === 'number') {
407
+ // An instant field's observed values are instants: a numeric range on
408
+ // it would validate and then sit silently inert at execution.
409
+ diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value bounds must be timestamps for field ${JSON.stringify(field)}` });
410
+ }
411
+ return;
227
412
  }
228
- else if (!['string', 'number', 'boolean'].includes(typeof value)
229
- || (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) {
230
- 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` });
413
+ // in: bounded, non-empty, every member one canonicalizable scalar, all
414
+ // members one JSON type — a mixed list is almost always an authoring bug.
415
+ const members = homogeneousExpressionMembers(value);
416
+ if (members === null) {
417
+ 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` });
418
+ return;
231
419
  }
232
- else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
233
- 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)}` });
420
+ if (field === 'device.class') {
421
+ for (const member of members) {
422
+ if (DEVICE_CLASSES.indexOf(member) < 0) {
423
+ 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)}` });
424
+ return;
425
+ }
426
+ }
234
427
  }
235
428
  }
236
429
  function matchRequestCondition(condition, fact) {
@@ -243,12 +436,198 @@ function matchRequestCondition(condition, fact) {
243
436
  }
244
437
  if (fact.status === 'absent')
245
438
  return { matched: false, reason: 'absent' };
246
- const expected = canonicalScalar(condition.value);
247
- const matched = (fact.values || []).some((value) => condition.operator === 'equals' ? value === expected
248
- : condition.operator === 'contains' ? value.includes(expected)
249
- : condition.operator === 'startsWith' ? value.startsWith(expected) : false);
439
+ const matcher = expressionConditionMatcher(condition);
440
+ if (!matcher)
441
+ return { matched: false, reason: 'not_matched' };
442
+ const matched = (fact.values || []).some(matcher);
250
443
  return { matched, reason: matched ? 'matched' : 'not_matched' };
251
444
  }
445
+ export const EXPRESSION_OPERATORS = [
446
+ 'equals', 'contains', 'startsWith', 'exists',
447
+ 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
448
+ 'before', 'after', 'between', 'in',
449
+ ];
450
+ const MAX_IN_VALUES = 32;
451
+ // Observed values are gated on the published grammars before any host parser
452
+ // runs (ADR 0038): Number() and Date.parse accept supersets (hex, empty
453
+ // string, vendor date formats) that would let the same fact match here and
454
+ // not in Go. Keep these literals identical to the capability artifact — the
455
+ // drift test compares them byte-for-byte.
456
+ export const EXPRESSION_NUMBER_GRAMMAR = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
457
+ export const EXPRESSION_TIMESTAMP_GRAMMAR = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
458
+ function expressionNumberInDomain(value) {
459
+ if (!Number.isFinite(value))
460
+ return false;
461
+ if (Number.isInteger(value) && !Number.isSafeInteger(value))
462
+ return false;
463
+ return true;
464
+ }
465
+ function parseObservedExpressionNumber(value) {
466
+ if (!EXPRESSION_NUMBER_GRAMMAR.test(value))
467
+ return null;
468
+ const parsed = Number(value);
469
+ return expressionNumberInDomain(parsed) ? parsed : null;
470
+ }
471
+ // Explicit calendar validation runs before Date arithmetic because Date.UTC
472
+ // (like Go's time.Date) silently normalizes impossible dates. Instants
473
+ // compare as epoch milliseconds; fractional seconds truncate to milliseconds.
474
+ function parseExpressionTimestamp(value) {
475
+ const match = EXPRESSION_TIMESTAMP_GRAMMAR.exec(value);
476
+ if (!match)
477
+ return null;
478
+ const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
479
+ const hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6]);
480
+ if (month < 1 || month > 12 || day < 1 || day > expressionDaysInMonth(year, month))
481
+ return null;
482
+ if (hour > 23 || minute > 59 || second > 59)
483
+ return null;
484
+ let offsetMinutes = 0;
485
+ if (match[8] !== 'Z') {
486
+ const offsetHour = Number(match[8].slice(1, 3)), offsetMinute = Number(match[8].slice(4, 6));
487
+ if (offsetHour > 23 || offsetMinute > 59)
488
+ return null;
489
+ offsetMinutes = offsetHour * 60 + offsetMinute;
490
+ if (match[8][0] === '-')
491
+ offsetMinutes = -offsetMinutes;
492
+ }
493
+ const fractionMillis = match[7] ? Number((match[7].slice(1) + '00').slice(0, 3)) : 0;
494
+ // setUTCFullYear, not Date.UTC: Date.UTC maps two-digit years to 1900+.
495
+ const date = new Date(0);
496
+ date.setUTCFullYear(year, month - 1, day);
497
+ date.setUTCHours(hour, minute, second, 0);
498
+ return date.getTime() - offsetMinutes * 60000 + fractionMillis;
499
+ }
500
+ function expressionDaysInMonth(year, month) {
501
+ if (month === 4 || month === 6 || month === 9 || month === 11)
502
+ return 30;
503
+ if (month === 2)
504
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
505
+ return 31;
506
+ }
507
+ function scalarExpressionOperand(value) {
508
+ if (value === undefined || value === null)
509
+ return null;
510
+ const kind = typeof value;
511
+ if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
512
+ return null;
513
+ if (kind === 'number' && !expressionNumberInDomain(value))
514
+ return null;
515
+ return canonicalScalar(value);
516
+ }
517
+ function validExpressionRange(value) {
518
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
519
+ return false;
520
+ const record = value;
521
+ if (Object.keys(record).some((key) => key !== 'min' && key !== 'max'))
522
+ return false;
523
+ const min = record.min, max = record.max;
524
+ if (min === undefined || min === null || max === undefined || max === null)
525
+ return false;
526
+ if (typeof min === 'number' && expressionNumberInDomain(min)) {
527
+ return typeof max === 'number' && expressionNumberInDomain(max) && min <= max;
528
+ }
529
+ const minMillis = typeof min === 'string' ? parseExpressionTimestamp(min) : null;
530
+ if (minMillis === null)
531
+ return false;
532
+ const maxMillis = typeof max === 'string' ? parseExpressionTimestamp(max) : null;
533
+ return maxMillis !== null && minMillis <= maxMillis;
534
+ }
535
+ function homogeneousExpressionMembers(value) {
536
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IN_VALUES)
537
+ return null;
538
+ let memberKind = '';
539
+ const members = [];
540
+ for (const member of value) {
541
+ const kind = typeof member;
542
+ if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
543
+ return null;
544
+ if (kind === 'number' && !expressionNumberInDomain(member))
545
+ return null;
546
+ if (!memberKind)
547
+ memberKind = kind;
548
+ else if (memberKind !== kind)
549
+ return null;
550
+ members.push(canonicalScalar(member));
551
+ }
552
+ return members;
553
+ }
554
+ // expressionConditionMatcher builds the per-observed-value predicate for one
555
+ // condition. Null means the authored side cannot be interpreted — validation
556
+ // rejects those expressions first, so this only fails closed for direct use.
557
+ function expressionConditionMatcher(condition) {
558
+ const operator = String(condition.operator || '');
559
+ const operand = condition.value;
560
+ switch (operator) {
561
+ case 'equals':
562
+ case 'contains':
563
+ case 'startsWith': {
564
+ const expected = scalarExpressionOperand(operand);
565
+ if (expected === null)
566
+ return null;
567
+ return (value) => operator === 'equals' ? value === expected
568
+ : operator === 'contains' ? value.includes(expected) : value.startsWith(expected);
569
+ }
570
+ case 'greaterThan':
571
+ case 'greaterThanOrEqual':
572
+ case 'lessThan':
573
+ case 'lessThanOrEqual': {
574
+ if (typeof operand !== 'number' || !expressionNumberInDomain(operand))
575
+ return null;
576
+ return (value) => {
577
+ const observed = parseObservedExpressionNumber(value);
578
+ if (observed === null)
579
+ return false;
580
+ return operator === 'greaterThan' ? observed > operand
581
+ : operator === 'greaterThanOrEqual' ? observed >= operand
582
+ : operator === 'lessThan' ? observed < operand : observed <= operand;
583
+ };
584
+ }
585
+ case 'before':
586
+ case 'after': {
587
+ const operandMillis = typeof operand === 'string' ? parseExpressionTimestamp(operand) : null;
588
+ if (operandMillis === null)
589
+ return null;
590
+ return (value) => {
591
+ const observed = parseExpressionTimestamp(value);
592
+ if (observed === null)
593
+ return false;
594
+ return operator === 'after' ? observed > operandMillis : observed < operandMillis;
595
+ };
596
+ }
597
+ case 'between': {
598
+ if (!validExpressionRange(operand))
599
+ return null;
600
+ const record = operand;
601
+ if (typeof record.min === 'number') {
602
+ const min = record.min, max = record.max;
603
+ return (value) => {
604
+ const observed = parseObservedExpressionNumber(value);
605
+ return observed !== null && observed >= min && observed <= max;
606
+ };
607
+ }
608
+ const min = parseExpressionTimestamp(record.min);
609
+ const max = parseExpressionTimestamp(record.max);
610
+ return (value) => {
611
+ const observed = parseExpressionTimestamp(value);
612
+ return observed !== null && observed >= min && observed <= max;
613
+ };
614
+ }
615
+ case 'in': {
616
+ if (!Array.isArray(operand) || operand.length === 0 || operand.length > MAX_IN_VALUES)
617
+ return null;
618
+ const members = [];
619
+ for (const member of operand) {
620
+ const canonical = scalarExpressionOperand(member);
621
+ if (canonical === null)
622
+ return null;
623
+ members.push(canonical);
624
+ }
625
+ return (value) => members.includes(value);
626
+ }
627
+ default:
628
+ return null;
629
+ }
630
+ }
252
631
  function canonicalScalar(value) {
253
632
  if (typeof value === 'number')
254
633
  return Object.is(value, -0) ? '0' : JSON.stringify(value);
@@ -295,8 +674,32 @@ function signalFact(request, field) {
295
674
  return { status: 'unavailable' };
296
675
  return own(record, k) ? { status: 'present', values: [record[k]] } : { status: 'absent' };
297
676
  }
677
+ if (p === 'time') {
678
+ // The one clock fact (ADR 0038): resolved from the runtime's own clock,
679
+ // one RFC 3339 UTC value, so the kernel stays clock-free. Hosts set
680
+ // request.now (epoch ms) once per request for determinism.
681
+ if (k !== 'now')
682
+ return { status: 'absent' };
683
+ return { status: 'present', values: [cachedTimeNow(request)] };
684
+ }
298
685
  return { status: 'absent' };
299
686
  }
687
+ const TIME_NOW_CACHE = new WeakMap();
688
+ // cachedTimeNow resolves the clock ONCE per request context, like
689
+ // cachedDeviceClass: every time.now leaf in one evaluation reads the same
690
+ // instant. A host-supplied request.now outside Date's representable range
691
+ // falls back to the runtime clock rather than throwing from a viewer-request
692
+ // path — this module fails closed, never open.
693
+ function cachedTimeNow(request) {
694
+ const hit = TIME_NOW_CACHE.get(request);
695
+ if (hit !== undefined)
696
+ return hit;
697
+ const pinned = typeof request.now === 'number' && Number.isFinite(request.now)
698
+ && Math.abs(request.now) <= 8640000000000000 ? request.now : Date.now();
699
+ const value = new Date(pinned).toISOString();
700
+ TIME_NOW_CACHE.set(request, value);
701
+ return value;
702
+ }
300
703
  // SHA-256 mirrors experiment/assignment.go: the first eight digest bytes are
301
704
  // interpreted big-endian and reduced modulo 10,000 basis points. Keep the
302
705
  // implementation runtime-neutral: this module is bundled into browser and
@@ -387,9 +790,10 @@ function utf8(value) {
387
790
  // Phase 2 for cheap set-membership lookups per route.
388
791
  //
389
792
  // Phase 2 (decide): for a given route record, check which variants match
390
- // the classification (set membership + priority). No rule evaluation —
391
- // just "is this audience in matchedAudiences?" and "is this experiment arm
392
- // the one we assigned?"
793
+ // the classification (set membership + priority). Audience/segment rules
794
+ // were evaluated in Phase 1; an experiment arm variant's own rule is
795
+ // evaluated here at commit time, because a bucket assignment is candidacy,
796
+ // not entitlement (ADR 0040).
393
797
  //
394
798
  // The per-route record stays small: just variant keys (e.g.
395
799
  // "audience=trade") + priorities. Rules live in the classification data,
@@ -408,11 +812,12 @@ function utf8(value) {
408
812
  * @returns Classification — matched audiences, segments, and experiment arm assignments
409
813
  */
410
814
  export function classify(ctx, data, identity, claims) {
411
- // Evaluate audience rules
815
+ // Evaluate audience rules. Claims feed the audience.memberships fact
816
+ // (ADR 0039) exactly as they do in the request-scoped resolver path.
412
817
  const matchedAudiences = [];
413
818
  for (const key in data.audiences) {
414
819
  const def = data.audiences[key];
415
- if (def.rule && evaluateRule(ctx, def.rule)) {
820
+ if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
416
821
  matchedAudiences.push(key);
417
822
  }
418
823
  }
@@ -420,7 +825,7 @@ export function classify(ctx, data, identity, claims) {
420
825
  const matchedSegments = [];
421
826
  for (const key in data.segments) {
422
827
  const def = data.segments[key];
423
- if (def.rule && evaluateRule(ctx, def.rule)) {
828
+ if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
424
829
  matchedSegments.push(key);
425
830
  }
426
831
  }
@@ -447,13 +852,20 @@ export function classify(ctx, data, identity, claims) {
447
852
  * Phase 2: Decide the winning variant for a specific route.
448
853
  *
449
854
  * Uses the classification from Phase 1 for cheap set-membership lookups.
450
- * No rule evaluation — just set membership and priority comparison.
855
+ * Audience/segment rules were already evaluated in Phase 1; the one rule
856
+ * evaluated here is an experiment arm variant's own rule, because the arm
857
+ * assignment is candidacy, not entitlement (ADR 0040).
451
858
  *
452
859
  * @param record The route entry (path, dispatch, variants)
453
860
  * @param classification The visitor's classification from Phase 1
861
+ * @param ctx The request context (signal facts + rejection sink)
862
+ * @param claims The verified claims the caller already holds — the
863
+ * same object passed to classify() — so an arm variant's
864
+ * audience.memberships rule resolves identically to the
865
+ * resolver path and fails closed without it (ADR 0039).
454
866
  * @returns The decision — winning variant + experiment assignment, or nulls
455
867
  */
456
- export function decide(record, classification, ctx) {
868
+ export function decide(record, classification, ctx, claims) {
457
869
  if (!record.variants)
458
870
  return { variant: null, experiment: null };
459
871
  let bestVariant = null;
@@ -483,8 +895,14 @@ export function decide(record, classification, ctx) {
483
895
  if (!matched && experimentID) {
484
896
  const arm = classification.experimentArms[experimentID];
485
897
  if (arm && arm.variantKey === variant.variantKey) {
486
- matched = true;
487
- armMatch = arm;
898
+ // Same ADR 0040 posture as the resolver's experiment leg: the arm
899
+ // assignment is candidacy only. A rule rejected at serve time (or
900
+ // unmatched) fires the onRuleRejected sink inside evaluateRule and
901
+ // skips the variant — never served, no stale experiment decision.
902
+ if (!variant.rule || evaluateRule(ctx, variant.rule, claims, variant.variantKey)) {
903
+ matched = true;
904
+ armMatch = arm;
905
+ }
488
906
  }
489
907
  }
490
908
  if (matched) {