diff --git a/.changeset/18118-retire-observability-cel-arms.md b/.changeset/18118-retire-observability-cel-arms.md new file mode 100644 index 00000000000..4bd886b0232 --- /dev/null +++ b/.changeset/18118-retire-observability-cel-arms.md @@ -0,0 +1,123 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING** — retire the CEL predicate arms of `ServiceLevelIndicator.successCriteria` +and `TraceSamplingConfig.composite[].condition`, the two observability predicates nothing +ever evaluated. + +Both slots were `z.union([, ])`. The +expression arm parsed, normalized a bare string to `{ dialect: 'cel', source }`, +registered, and was served back — and **nothing anywhere evaluated it**. An identity scan +over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema` +and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or +prose; inside it the only readers are the schemas' own unit tests and the two census tests +that enumerate expression slots. No service, plugin, runtime or CLI path reads either key. +So an author — very often an AI reading the generated reference page (ADR-0033) — who +wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable +from a predicate that ran and answered. + +ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director decision batch #160 +item 3, letter A). By the standing criterion that a declared-but-unread capability is kept +only when mainstream platforms in the domain have it: application platforms do not carry +SLI success criteria or trace-sampling conditions as authorable application metadata — +that lives in observability infrastructure (SLO products, OTel sampling policy) and is +structured there, not a free expression. The `cron-declared-unwired` family was retired +outright under the same ADR after the same measurement. + +## FROM → TO + +| you wrote (17.4 and earlier) | write instead | +| --- | --- | +| `successCriteria: 'p95 < 300ms'` | `successCriteria: { threshold: 300, operator: 'lt', percentile: 0.95 }` — the structured rule this slot has always carried | +| `successCriteria: { dialect: 'cel', source: 'p95 < 300ms' }` | the same structured rule; the envelope spelling goes with the bare-string one | +| `condition: 'record.amount > 10'` on a composite sampling branch | `condition: { service: 'api', attributes: { 'http.route': '/v1/orders' } }` — a structured filter object carrying no `dialect` key | +| `condition: { dialect: 'cel', source: 'record.amount > 10' }` | the same structured filter; an object carrying `dialect` is refused as an expression attempt | + +**The one-line fix:** delete the predicate and write the structured shape the slot already +carried. A criterion or a sampling rule the structured shape cannot express has no home in +application metadata at all — it belongs in the SLO product or the OpenTelemetry sampler +configuration that actually evaluates it. ⛔ Do not translate a predicate into a threshold +by guessing the number: nothing was evaluating it, so there is no behaviour to preserve and +a wrong number is worse than an absent one. + +## The retirement kit + +- **Neither KEY is retired — one ARM of each key's union is.** `successCriteria` and + `condition` both survive with their structured arm intact, so `retiredKey()` and an + ADR-0087 D2 strip are both the wrong tool: they retire a key. The prescription hangs on + the surviving schema's own `error` map, dispatched on `issue.input` — the + `HookBodyCapability` / `object.managedBy: 'system'` pattern for a narrowing a key + survives. +- **Where the prescription reaches, measured on zod 4.4.** A schema's `error` map is + consulted for the top-level `invalid_type` a NON-OBJECT raises, and not for the child + issues a wrong-shaped OBJECT raises. So on `successCriteria` the bare-string spelling + carries the prescription and the `{ dialect, source }` envelope is refused by the + structured arm's own missing-key issues (`threshold`, `operator`). On `condition` both + spellings carry it, because the structured arm is a record whose aborting `dialect` + refine sees the object itself. Pinned both ways in the schemas' unit tests, the negative + included: a value refused for a reason that is NOT the retirement must not borrow its + sentence. +- **ADR-0087 disposition: a D3 SEMANTIC entry**, `observability-cel-predicates-retired`, + not a D2 conversion. A predicate is an intent that no threshold/operator pair or + attribute filter records; a mechanical strip would delete what the author meant and leave + no trace of which SLI or which sampling branch lost it — and it would not even be lossless + in the weak sense, because `successCriteria` is REQUIRED (a strip leaves an SLI that no + longer parses) and a composite branch stripped of its `condition` declares no condition at + all. That is the one place this retirement parts company with the two precedents it copies + its MECHANISM from: `crypto.hash` on `HookBodyCapability` and `managedBy: 'system'` both + ALSO registered a D2 conversion, because for each of them a mechanical rewrite existed. + Here none does, which is what makes D3 the right disposition rather than merely an + available one. The prescriptions therefore carry **no** `os migrate meta` sentence — that + sentence is owed only where a conversion covers the surface. +- **The same-major D3 record is absorbed, per the playbook's 「同 major 记账」.** The + `evaluated-expression-slots-source-required` entry landed into this same unpublished step, + and it enumerated these two slots among its 36 declaring positions while instructing the + upgrader to give a sampling `condition` a dialect and a non-blank `source` — the exact + envelope this head now refuses. Both entries first ship together, so the composite of the + two changes is the retirement alone: that entry now reads 34 positions, names the two + absentees and why, and routes them to this retirement instead of to its own repair. +- **The surviving accept sets are pinned beside the refusals.** `successCriteria` still + takes `{ threshold, operator, percentile? }`; a composite `condition` still takes any + filter object carrying no `dialect` key — `{ source: 'x' }` included, because `source` + alone is an ordinary filter key and the retirement narrowed the `dialect` door only. +- **FOUR published JSON Schemas change projection direction**, and it is mechanical rather + than chosen: the retired arm held the last `.transform()` in each of these subtrees, so + each def now projects in output mode instead of falling back to the input shape. All four + lose `x-io: input`, and what each gains differs: + + | published schema | gains | + | --- | --- | + | `system/MetricsConfig` | `default: []` on `slis`, plus 8 `required` members | + | `system/TracingConfig` | `default: {"type":"always_on","rules":[]}` on `sampling`, plus 4 `required` members | + | `system/ServiceLevelIndicator` | one `required` member, `enabled` | + | `system/TraceSamplingConfig` | one `required` member, `rules` | + + Only the first two carry a `default` move, so only those two are declarable in + `DEFAULT_CHANGES_BY_MAJOR` — the nested pair's `required` growth has no ratchet row to + live in and is stated here instead. A `required` that lists defaulted keys is this repo's + existing output-mode convention, not a new one, and the same-category control + `system/CacheConfig` is untouched. The reference pages show the same signature: the nested + type cells of both pages lose the `?` from their default-bearing keys. **No runtime default + moves** — measured twice, by byte-identity of the untouched `.default(…)` and by parsing a + minimal config on the built package. + +## What is deliberately NOT in this change + +- **The structured arms.** `{ threshold, operator, percentile }` and the sampling filter + record are equally unread today. The ruling says so and leaves them to their own card: + they carry no dialect and are outside the expression ledger's remit. +- **`skills/objectstack-formula/SKILL.md`**, which still lists `metrics` / `tracing` under + `structured | cel`. The ruling assigns that correction to the skills lane, at tier, and + this diff does not touch it. +- **`packages/spec/src/shared/expression.zod.ts`.** `EvaluatedExpressionInputSchema` is + untouched and stays the schema of every remaining evaluated slot; what left is two + references to it. + +Shipped as `minor` under the repo's launch-window convention, in which `major` is refused +by `check-changeset-no-major` and breaking-ness is carried by the banner above plus the +ADR-0087 disposition rather than by the level. + +Clause-②: yes (narrowing) + + diff --git a/content/docs/references/system/metrics.mdx b/content/docs/references/system/metrics.mdx index 1e6ae1db831..5423fdffc06 100644 --- a/content/docs/references/system/metrics.mdx +++ b/content/docs/references/system/metrics.mdx @@ -309,13 +309,13 @@ Metrics configuration | **metrics** | `{ name: string; label?: string; type: Enum<'counter' \| 'gauge' \| 'histogram' \| 'summary'>; unit?: Enum<'nanoseconds' \| 'microseconds' \| 'milliseconds' \| 'seconds' \| 'minutes' \| …>; … }[]` | optional (default: `[]`) | | | **defaultLabels** | `Record` | optional (default: `{}`) | Metric labels | | **aggregations** | `{ type: Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>; window?: object; groupBy?: string[]; filters?: Record }[]` | optional (default: `[]`) | | -| **slis** | `{ name: string; label: string; description?: string; metric: string; … }[]` | optional | | +| **slis** | `{ name: string; label: string; description?: string; metric: string; … }[]` | optional (default: `[]`) | | | **slos** | `{ name: string; label: string; description?: string; sli: string; … }[]` | optional (default: `[]`) | | -| **exports** | `{ type: Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>; endpoint?: string; intervalSeconds?: integer; batch?: object; … }[]` | optional (default: `[]`) | | +| **exports** | `{ type: Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>; endpoint?: string; intervalSeconds: integer; batch?: object; … }[]` | optional (default: `[]`) | | | **collectionIntervalSeconds** | `integer` | optional (default: `15`) | Collection interval in seconds | | **collectionInterval** | `never` | optional | [REMOVED] `MetricsConfig.collectionInterval` was renamed to `collectionIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (seconds) lived in a source JSDoc only and the key carried no describe at all, so a reader of the reference page could not tell 15 seconds from 15 milliseconds. The qualifier is kept — `collectionIntervalSeconds`, not `intervalSeconds` — because `MetricExportConfig.intervalSeconds` is a different cadence one def over. Rename the key to `collectionIntervalSeconds`; the value (seconds) is unchanged. | -| **retention** | `{ durationSeconds?: integer; downsampling?: object[] }` | optional | | -| **cardinalityLimits** | `{ maxLabelCombinations?: integer; onLimitExceeded?: Enum<'drop' \| 'sample' \| 'alert'> }` | optional | | +| **retention** | `{ durationSeconds: integer; downsampling?: object[] }` | optional | | +| **cardinalityLimits** | `{ maxLabelCombinations: integer; onLimitExceeded: Enum<'drop' \| 'sample' \| 'alert'> }` | optional | | ### Nested Shape: `MetricsConfig.metrics[number]` @@ -330,7 +330,7 @@ Metric definition | **description** | `string` | optional | Metric description | | **labelNames** | `string[]` | optional (default: `[]`) | Label names | | **histogram** | `{ type: Enum<'linear' \| 'exponential' \| 'explicit'>; linear?: object; exponential?: object; explicit?: object }` | optional | Histogram bucket configuration | -| **summary** | `{ quantiles?: number[]; maxAgeSeconds?: integer; ageBuckets?: integer }` | optional | | +| **summary** | `{ quantiles: number[]; maxAgeSeconds: integer; ageBuckets: integer }` | optional | | | **enabled** | `boolean` | optional (default: `true`) | | ### Nested Shape: `MetricsConfig.aggregations[number]` @@ -340,7 +340,7 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>` | ✅ | Aggregation type | -| **window** | `{ durationSeconds: integer; sliding?: boolean; slideInterval?: integer }` | optional | | +| **window** | `{ durationSeconds: integer; sliding: boolean; slideInterval?: integer }` | optional | | | **groupBy** | `string[]` | optional | Group by label names | | **filters** | `Record` | optional | Filter criteria | @@ -355,8 +355,8 @@ Service Level Indicator | **description** | `string` | optional | SLI description | | **metric** | `string` | ✅ | Base metric name | | **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` | ✅ | SLI type | -| **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number } \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Success criteria — structured or CEL predicate | -| **window** | `{ durationSeconds: integer; rolling?: boolean }` | ✅ | Measurement window | +| **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number }` | ✅ | Success criteria — a structured threshold rule. A CEL predicate is NOT accepted here: that arm was removed in 17.5.0 because nothing evaluated it. | +| **window** | `{ durationSeconds: integer; rolling: boolean }` | ✅ | Measurement window | | **enabled** | `boolean` | optional (default: `true`) | | ### Nested Shape: `MetricsConfig.slos[number]` @@ -371,7 +371,7 @@ Service Level Objective | **sli** | `string` | ✅ | SLI name | | **target** | `number` | ✅ | Target percentage | | **period** | `{ type: Enum<'rolling' \| 'calendar'>; durationSeconds?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | -| **errorBudget** | `{ enabled?: boolean; alertThreshold?: number; burnRateWindows?: object[] }` | optional | | +| **errorBudget** | `{ enabled: boolean; alertThreshold: number; burnRateWindows?: object[] }` | optional | | | **alerts** | `{ name: string; severity: Enum<'info' \| 'warning' \| 'critical'>; condition: object }[]` | optional (default: `[]`) | | | **enabled** | `boolean` | optional (default: `true`) | | @@ -385,7 +385,7 @@ Metric export configuration | **endpoint** | `string` | optional | Export endpoint | | **intervalSeconds** | `integer` | optional (default: `60`) | Export interval in seconds | | **interval** | `never` | optional | [REMOVED] `MetricExportConfig.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (seconds) lived in a source JSDoc only and the key carried no describe at all, so a reader of the reference page could not tell 60 seconds from 60 milliseconds. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. | -| **batch** | `{ enabled?: boolean; size?: integer }` | optional | | +| **batch** | `{ enabled: boolean; size: integer }` | optional | | | **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api_key'>; username?: string; password?: string; token?: string; … }` | optional | | | **config** | `Record` | optional | Additional configuration | @@ -413,11 +413,11 @@ Service Level Indicator | **description** | `string` | optional | SLI description | | **metric** | `string` | ✅ | Base metric name | | **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` | ✅ | SLI type | -| **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number } \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Success criteria — structured or CEL predicate | -| **window** | `{ durationSeconds: integer; rolling?: boolean }` | ✅ | Measurement window | +| **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number }` | ✅ | Success criteria — a structured threshold rule. A CEL predicate is NOT accepted here: that arm was removed in 17.5.0 because nothing evaluated it. | +| **window** | `{ durationSeconds: integer; rolling: boolean }` | ✅ | Measurement window | | **enabled** | `boolean` | optional (default: `true`) | | -### Nested Shape: `ServiceLevelIndicator.successCriteria[option 1]` +### Nested Shape: `ServiceLevelIndicator.successCriteria` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/system/tracing.mdx b/content/docs/references/system/tracing.mdx index b093f901356..6910c7f04df 100644 --- a/content/docs/references/system/tracing.mdx +++ b/content/docs/references/system/tracing.mdx @@ -404,8 +404,8 @@ Trace sampling configuration | **type** | `Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| 'probability' \| 'composite' \| 'custom'>` | ✅ | Sampling strategy | | **ratio** | `number` | optional | Sample ratio (0-1) | | **rateLimit** | `number` | optional | Traces per second | -| **parentBased** | `{ whenParentSampled?: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; whenParentNotSampled?: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; root?: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; rootRatio?: number }` | optional | | -| **composite** | `{ strategy: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; ratio?: number; condition?: Record \| string \| object }[]` | optional | | +| **parentBased** | `{ whenParentSampled: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; whenParentNotSampled: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; root: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; rootRatio: number }` | optional | | +| **composite** | `{ strategy: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; ratio?: number; condition?: Record }[]` | optional | | | **rules** | `{ name: string; match?: object; decision: Enum<'drop' \| 'record_only' \| 'record_and_sample'>; rate?: number }[]` | optional (default: `[]`) | | | **customSamplerId** | `string` | optional | Custom sampler identifier | @@ -424,7 +424,7 @@ Trace sampling configuration | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>` | ✅ | Strategy type | | **ratio** | `number` | optional | | -| **condition** | `Record \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Condition for this strategy — a structured filter object, or a CEL predicate an engine evaluates. ⚠️ The two are told apart by the `dialect` key: a structured filter must NOT carry one, and an object that does is judged as an expression — so it needs a dialect this platform evaluates and a non-blank `source` (`{ dialect: 'cel', source: 'record.amount > 10' }`). `{ dialect: 'cel', ast: … }` with no `source` is refused here. | +| **condition** | `Record` | optional | Condition for this strategy — a structured filter object of match criteria, carrying no `dialect` key. ⚠️ A CEL predicate is NOT accepted here: that arm was removed in 17.5.0 because nothing evaluated it, and an object carrying a `dialect` key is refused as an expression attempt. | ### Nested Shape: `TraceSamplingConfig.rules[number]` @@ -462,13 +462,13 @@ Tracing configuration | **name** | `string` | ✅ | Configuration name (snake_case, max 64 chars) | | **label** | `string` | ✅ | Display label | | **enabled** | `boolean` | optional (default: `true`) | | -| **sampling** | `{ type: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; ratio?: number; rateLimit?: number; parentBased?: object; … }` | optional | Trace sampling configuration | -| **propagation** | `{ formats?: Enum<'w3c' \| 'b3' \| 'b3_multi' \| 'jaeger' \| 'xray' \| 'ottrace' \| 'custom'>[]; extract?: boolean; inject?: boolean; headers?: object; … }` | optional (default: `{"formats":["w3c"],"extract":true,"inject":true}`) | Trace context propagation | +| **sampling** | `{ type: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; ratio?: number; rateLimit?: number; parentBased?: object; … }` | optional (default: `{"type":"always_on","rules":[]}`) | Trace sampling configuration | +| **propagation** | `{ formats: Enum<'w3c' \| 'b3' \| 'b3_multi' \| 'jaeger' \| 'xray' \| 'ottrace' \| 'custom'>[]; extract: boolean; inject: boolean; headers?: object; … }` | optional (default: `{"formats":["w3c"],"extract":true,"inject":true}`) | Trace context propagation | | **openTelemetry** | `{ sdkVersion?: string; exporter: object; resource: object; instrumentation?: object; … }` | optional | OpenTelemetry compatibility configuration | -| **spanLimits** | `{ maxAttributes?: integer; maxEvents?: integer; maxLinks?: integer; maxAttributeValueLength?: integer }` | optional | | +| **spanLimits** | `{ maxAttributes: integer; maxEvents: integer; maxLinks: integer; maxAttributeValueLength: integer }` | optional | | | **traceIdGenerator** | `Enum<'random' \| 'uuid' \| 'custom'>` | optional (default: `"random"`) | | | **customTraceIdGeneratorId** | `string` | optional | Custom generator identifier | -| **performance** | `{ asyncExport?: boolean; exportIntervalMs?: integer }` | optional | | +| **performance** | `{ asyncExport: boolean; exportIntervalMs: integer }` | optional | | ### Nested Shape: `TracingConfig.sampling` @@ -477,8 +477,8 @@ Tracing configuration | **type** | `Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>` | ✅ | Sampling strategy | | **ratio** | `number` | optional | Sample ratio (0-1) | | **rateLimit** | `number` | optional | Traces per second | -| **parentBased** | `{ whenParentSampled?: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; whenParentNotSampled?: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; root?: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; rootRatio?: number }` | optional | | -| **composite** | `{ strategy: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; ratio?: number; condition?: Record \| string \| object }[]` | optional | | +| **parentBased** | `{ whenParentSampled: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; whenParentNotSampled: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; root: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; rootRatio: number }` | optional | | +| **composite** | `{ strategy: Enum<'always_on' \| 'always_off' \| 'trace_id_ratio' \| 'rate_limiting' \| 'parent_based' \| …>; ratio?: number; condition?: Record }[]` | optional | | | **rules** | `{ name: string; match?: object; decision: Enum<'drop' \| 'record_only' \| 'record_and_sample'>; rate?: number }[]` | optional (default: `[]`) | | | **customSamplerId** | `string` | optional | Custom sampler identifier | @@ -489,7 +489,7 @@ Tracing configuration | **sdkVersion** | `string` | optional | OTel SDK version | | **exporter** | `{ type: Enum<'otlp_http' \| 'otlp_grpc' \| 'jaeger' \| 'zipkin' \| 'console' \| 'datadog' \| …>; endpoint?: string; protocol?: string; headers?: Record; … }` | ✅ | Exporter configuration | | **resource** | `{ serviceName: string; serviceVersion?: string; serviceInstanceId?: string; serviceNamespace?: string; … }` | ✅ | Resource attributes | -| **instrumentation** | `{ autoInstrumentation?: boolean; libraries?: string[]; disabledLibraries?: string[] }` | optional | | +| **instrumentation** | `{ autoInstrumentation: boolean; libraries?: string[]; disabledLibraries?: string[] }` | optional | | | **semanticConventionsVersion** | `string` | optional | Semantic conventions version | ### Nested Shape: `TracingConfig.performance` diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 649188a6b47..c7a4e63478a 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -500,19 +500,4 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ ], note: 'EXPERIMENTAL — declared policy conditions with no runtime evaluator yet (ADR-0056 D8 / ADR-0049 tracking). This row is why `unevaluated` was minted: it carried `fail-closed` — a RUNTIME refusal — while its own `enforcement` cell said `(no runtime consumer yet)`, so on a security-flavoured row the ledger read as a security guarantee over a slot nothing evaluates. `fail-closed` here was the one borrowing with a consequence past legibility, and spreading it to the other four unwired rows was refused for that reason.', }, - { - // The other half of #17630's first classifications. Same tier as the row - // above and as `template-prompt` / `cron-knowledge-refresh`: an existing - // classification applied to two more instances, not a new category. - id: 'cel-declared-unwired-observability', - summary: 'observability config predicates — SLI success criteria and composite trace-sampling condition (declared, documented, nothing reads them)', - dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', - enforcement: - 'PARSE ONLY — each slot is `z.union([, ExpressionInputSchema])`, so the envelope arm normalizes a bare string to `{dialect:"cel",source}` and refuses a blank one; NO EVALUATOR FOUND for either. Measured on `a26a114d7` by identity over the whole tree: `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` have ZERO hits outside `packages/spec/src` that are not generated artefacts — `packages/spec/{api-surface,authorable-surface,declaration-map,export-origins,json-schema.manifest}`, `content/docs/references/system/{metrics,tracing}.mdx`, a CHANGELOG and one `skills/objectstack-formula/SKILL.md` prose row. Inside the spec the only readers are the two schemas\' own unit tests. No service, plugin or runtime reads either key', - covers: [ - 'system/metrics.zod.ts:ServiceLevelIndicatorSchema.successCriteria', - 'system/tracing.zod.ts:TraceSamplingConfigSchema.condition', - ], - note: 'EXPERIMENTAL — a published, documented, author-facing predicate that nothing evaluates, which is the class the `unevaluated` tier exists for, and the class this ratchet exists to surface. This ratchet did not surface them: both positions mount the roster schema as a UNION MEMBER on its own line, so head-anchored discovery never saw them and the ledger read complete over them. ⛔ NOT a synonym for `experimental` in the "we have not looked" sense — the absence above is measured in this checkout, over the whole tree, and stated in the `enforcement` cell as the tier requires. Whether either key should instead be RETIRED under ADR-0049 enforce-or-remove is a separate decision on its own card: this row classifies what is there, and the card that widened discovery deliberately changed no behaviour and retired no key. The structured arm of each union is out of scope here — it is not an expression surface and carries no dialect.', - }, ]; diff --git a/packages/qa/dogfood/test/expression-conformance.test.ts b/packages/qa/dogfood/test/expression-conformance.test.ts index aaa29522187..3d089bb228d 100644 --- a/packages/qa/dogfood/test/expression-conformance.test.ts +++ b/packages/qa/dogfood/test/expression-conformance.test.ts @@ -393,7 +393,15 @@ function discoverSurfaces(): Set { */ const SCAN_CONTROLS: ReadonlyArray<{ via: Declaration['via']; min: number; mechanism: string }> = [ { via: 'head', min: 37, mechanism: 'a roster name immediately after `field:` (the pre-#17630 scan)' }, - { via: 'inline', min: 3, mechanism: 'mechanism A — a roster name used as a UNION MEMBER, not at the head of the declaration' }, + // [#18118] Lowered 3 → 1 in the commit that deleted the `cel-declared-unwired-observability` + // ledger row. The two positions that went away are named rather than subtracted: + // `system/metrics.zod.ts:ServiceLevelIndicatorSchema.successCriteria` and + // `system/tracing.zod.ts:TraceSamplingConfigSchema.condition` — both mounted the roster + // schema as a union member, and both of those CEL arms were RETIRED under ADR-0049 + // enforce-or-remove because nothing evaluated them. `ui/component.zod.ts` `RecordAlertProps.visible` + // is the one survivor of mechanism A, which is why the floor is 1 and not 0: a floor of 0 + // would stop measuring the mechanism instead of measuring less of it. + { via: 'inline', min: 1, mechanism: 'mechanism A — a roster name used as a UNION MEMBER, not at the head of the declaration' }, { via: 'alias', min: 2, mechanism: 'mechanism B — a slot typed with a file-local alias const of a roster member' }, ]; diff --git a/packages/spec/api-surface-declarations/system.txt b/packages/spec/api-surface-declarations/system.txt index f7fe736df00..dd4730335b9 100644 --- a/packages/spec/api-surface-declarations/system.txt +++ b/packages/spec/api-surface-declarations/system.txt @@ -56961,7 +56961,7 @@ declare const MetricsConfigSchema: z.ZodObject<{ error_rate: "error_rate"; saturation: "saturation"; }>; - successCriteria: z.ZodUnion; percentile: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion>, z.ZodObject<{ - dialect: z.ZodEnum<{ - cel: "cel"; - cron: "cron"; - template: "template"; - }>; - ast: z.ZodOptional; - meta: z.ZodOptional; - generatedBy: z.ZodOptional; - }, z.core.$strip>>; - source: z.ZodString; - }, z.core.$strip>]>]>; + }, z.core.$strip>; window: z.ZodObject<{ durationSeconds: z.ZodNumber; size: z.ZodOptional; @@ -69196,7 +69176,7 @@ declare const ServiceLevelIndicatorSchema: z.ZodObject<{ error_rate: "error_rate"; saturation: "saturation"; }>; - successCriteria: z.ZodUnion; percentile: z.ZodOptional; - }, z.core.$strip>, z.ZodUnion>, z.ZodObject<{ - dialect: z.ZodEnum<{ - cel: "cel"; - cron: "cron"; - template: "template"; - }>; - ast: z.ZodOptional; - meta: z.ZodOptional; - generatedBy: z.ZodOptional; - }, z.core.$strip>>; - source: z.ZodString; - }, z.core.$strip>]>]>; + }, z.core.$strip>; window: z.ZodObject<{ durationSeconds: z.ZodNumber; size: z.ZodOptional; @@ -71411,27 +71371,7 @@ declare const TraceSamplingConfigSchema: z.ZodObject<{ probability: "probability"; }>; ratio: z.ZodOptional; - condition: z.ZodOptional, z.ZodUnion>, z.ZodObject<{ - dialect: z.ZodEnum<{ - cel: "cel"; - cron: "cron"; - template: "template"; - }>; - ast: z.ZodOptional; - meta: z.ZodOptional; - generatedBy: z.ZodOptional; - }, z.core.$strip>>; - source: z.ZodString; - }, z.core.$strip>]>]>>; + condition: z.ZodOptional>; }, z.core.$strip>>>; rules: z.ZodDefault; ratio: z.ZodOptional; - condition: z.ZodOptional, z.ZodUnion>, z.ZodObject<{ - dialect: z.ZodEnum<{ - cel: "cel"; - cron: "cron"; - template: "template"; - }>; - ast: z.ZodOptional; - meta: z.ZodOptional; - generatedBy: z.ZodOptional; - }, z.core.$strip>>; - source: z.ZodString; - }, z.core.$strip>]>]>>; + condition: z.ZodOptional>; }, z.core.$strip>>>; rules: z.ZodDefault).optional().default([])` ' + + 'wrapper projected twice, once with a transform-bearing element and once with a ' + + 'transform-free one, publishes `default: []` only for the transform-free element while ' + + '`parse({})` answers `[]` for both. The direction is read off a second artifact in this ' + + 'same diff rather than assumed: `content/docs/references/system/metrics.mdx` loses the ' + + '`?` from every default-bearing key of every `system/MetricsConfig` nested type cell ' + + '(`summary`, `retention`, `cardinalityLimits`, `batch`, `errorBudget`, the aggregation ' + + '`window`), which is the output-mode signature — post-parse a defaulted key is always ' + + 'present — and those cells carried `?` at the base. Nothing ' + + 'deployed changes behaviour; the consumer affected is one outside this repo who reads ' + + 'the published JSON Schema\'s `default` himself, and what he now reads is what the ' + + 'parser has always applied. To keep the old value there is nothing to write — the key, ' + + 'its type and its default are unchanged.' + + ' ⚠️ FOUR published JSON Schemas change projection direction in this diff, not the two ' + + 'this table has rows for, and the other two are disclosed nowhere else: ' + + '`system/MetricsConfig` and `system/TracingConfig` lose `x-io: input` AND gain a `default` ' + + '(these two rows), while the nested `system/ServiceLevelIndicator` and ' + + '`system/TraceSamplingConfig` lose `x-io: input` and gain a `required` MEMBER — `enabled` ' + + 'and `rules` respectively — whose defaults (`true` and `[]`) were ALREADY PUBLISHED at the ' + + 'base and did not move. Only the first two carry a `default` MOVE, so only those two are ' + + 'declarable here: this ratchet records default VALUES per key, so it is blind to `required` ' + + 'growth by construction, and the nested pair has no row it could live in. Measured at head: ' + + 'all four carry no `x-io` and `required` lists the defaulted keys; ten other `system` defs ' + + 'are still input-mode, and the same-category control `system/CacheConfig` is untouched. A ' + + '`required` that lists defaulted keys is this repo\'s existing output-mode convention, not ' + + 'a new one.', + }, + { + key: 'system/TracingConfig:sampling', + from: '(none)', + to: '{"rules":[],"type":"always_on"}', + reason: + 'The runtime default did NOT move, measured the same two ways as the ' + + '`system/MetricsConfig:slis` row above: the ' + + '`.default({ type: \'always_on\', rules: [] })` on the key is byte-identical at the base ' + + 'and after the change — not in this diff — and ' + + '`TracingConfigSchema.parse({ name, label })` answers ' + + '`{ type: \'always_on\', rules: [] }` on the built package. Same mechanism and same ' + + 'direction: retiring the CEL arm of `TraceSamplingConfig.composite[].condition` (#18118) ' + + 'takes the last `.transform()` out of the `system/TracingConfig` subtree, so the ' + + '`default` keyword the key always had at parse time becomes expressible in the published ' + + 'JSON Schema. Same evidence as above, on this def\'s own page: ' + + '`content/docs/references/system/tracing.mdx` loses the `?` from every default-bearing ' + + 'key of every `system/TracingConfig` nested type cell (`parentBased`, `propagation`, ' + + '`spanLimits`, `performance`, `instrumentation`), the output-mode signature. ' + + 'Nothing deployed changes behaviour; the consumer affected is one outside this ' + + 'repo who reads the published JSON Schema\'s `default` himself. To keep the old value ' + + 'there is nothing to write — the key, its type and its default are unchanged.' + + ' ⚠️ FOUR published JSON Schemas change projection direction in this diff, not the two ' + + 'this table has rows for, and the other two are disclosed nowhere else: ' + + '`system/MetricsConfig` and `system/TracingConfig` lose `x-io: input` AND gain a `default` ' + + '(these two rows), while the nested `system/ServiceLevelIndicator` and ' + + '`system/TraceSamplingConfig` lose `x-io: input` and gain a `required` MEMBER — `enabled` ' + + 'and `rules` respectively — whose defaults (`true` and `[]`) were ALREADY PUBLISHED at the ' + + 'base and did not move. Only the first two carry a `default` MOVE, so only those two are ' + + 'declarable here: this ratchet records default VALUES per key, so it is blind to `required` ' + + 'growth by construction, and the nested pair has no row it could live in. Measured at head: ' + + 'all four carry no `x-io` and `required` lists the defaulted keys; ten other `system` defs ' + + 'are still input-mode, and the same-category control `system/CacheConfig` is untouched. A ' + + '`required` that lists defaulted keys is this repo\'s existing output-mode convention, not ' + + 'a new one.', + }, ], }; diff --git a/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts b/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts index 84343f2698b..2a0d6c2d4dc 100644 --- a/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts @@ -7,29 +7,28 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'evaluated-expression-slots-source-required', surface: - 'every EVALUATED expression slot in the spec — the 36 declaring positions the #15811 census ' - + 'enumerated by identity, not by a name scan: the formula Field.expression; the predicate ' + 'every EVALUATED expression slot in the spec — the 34 declaring positions of the #15811 census ' + + 'that survive into this major, enumerated by identity and not by a name scan: the formula ' + + 'Field.expression; the predicate ' + 'keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / ' + 'visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ' + 'ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, ' + 'RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport ' + 'routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, ' - + 'PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection, the ' - + 'settings-manifest Specifier and manifest visible, the ServiceLevelIndicator successCriteria ' - + 'union member and the TraceSamplingConfig composite condition union member — authored either ' + + 'PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the ' + + 'settings-manifest Specifier and manifest visible — authored either ' + 'as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), ' + 'or with a source that is blank after trimming, through the envelope key ' - + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it. ⚠️ At the ' - + 'TraceSamplingConfig composite condition slot the reachable set is WIDER than those two ' - + 'spellings, because the structured-filter arm beside the expression arm narrowed with it: a ' - + 'bare record accepted { dialect: \'cel\', ast } as an ordinary filter, which made the rule ' - + 'inert there, so that arm now declines ANY object carrying a dialect key. Six shapes the ' - + 'base accepted through that arm alone are refused at that slot: { dialect: \'cel\' }, ' - + '{ dialect: \'js\', source: \'x\' }, { dialect: \'nope\', source: \'x\' }, ' - + '{ dialect: \'cel\', source: 5 }, ' - + '{ dialect: \'cel\', source: \'x\', meta: { rationale: 5 } } and ' - + '{ dialect: \'zzz\', foo: 1 }. A structured filter carrying no dialect key is accepted ' - + 'exactly as before. The published ' + + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it. ⚠️ The census ' + + 'this entry was written against counted 36, and the two that are deliberately absent here are ' + + 'the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition ' + + 'expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished ' + + 'major by the observability-cel-predicates-retired entry of this step, under ADR-0049 ' + + 'enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an ' + + 'upgrader never meets those two slots under THIS rule — the composite of the two changes is ' + + 'the retirement alone, and stating the narrowing for a slot that no longer accepts an ' + + 'expression at all would send the upgrader to author one. That absorption is the only reason ' + + 'the count here is not the census figure the #15811 card records. The published ' + 'TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable ' + 'wherever metadata is authored or stored: defineStack sources, an exported stack passed to ' + 'objectstack validate, a POST body on any of these metadata types, and a row already sitting ' @@ -94,19 +93,15 @@ export const entry: SemanticMigration = { + 'the slots named in `surface`: an expression envelope with no `source` key, and a `source` ' + '(or bare-string shorthand) that is empty after trimming. ⚠️ Sweep by SLOT, not by key name ' + '— `visible` is on this list for actions, action params, nav items, bulk actions, record ' - + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and three of the ' - + 'positions are union members (`RecordAlertProps.visible`, ' - + '`ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`). On ' - + 'TWO of those three the sibling arm is untouched: `RecordAlertProps.visible` still takes a ' - + 'boolean literal, and `ServiceLevelIndicator.successCriteria` still takes its structured ' - + '`{ threshold, operator, percentile? }` object — including one that carries a `dialect` ' - + 'key. ⚠️ On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm ' - + 'narrows WITH the expression arm and that is deliberate: a bare record accepted ' - + '`{ dialect: \'cel\', ast }` as an ordinary filter, so the rule was inert there. The sweep ' - + 'at that ONE slot is therefore wider — flag every `condition` object carrying a `dialect` ' - + 'key, not only the two spellings above; if it is a structured filter, drop the `dialect` ' - + 'key, and if it is an expression it needs a dialect this platform evaluates AND a non-blank ' - + '`source`. For each hit: if it carries an `ast`, run ' + + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the ' + + 'positions is a union member, `RecordAlertProps.visible`, whose sibling arm is untouched: it ' + + 'still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union ' + + 'members the #15811 census listed — `ServiceLevelIndicator.successCriteria` and ' + + '`TraceSamplingConfig.composite[].condition` — are deliberately NOT on this sweep, because ' + + 'their expression arms were retired outright in this same major (see `surface`). Sweep those ' + + 'two under `observability-cel-predicates-retired` instead, whose instruction is the opposite ' + + 'of this one: there, an expression is not repaired, it is replaced by the structured shape or ' + + 'moved out of application metadata. For each hit: if it carries an `ast`, run ' + '`printCelAst(ast)`; a string result IS the migration and needs no judgment beyond reading ' + 'it back. A `null` result, or a blank `source`, is the hand-migration case — decide per the ' + '`replacement` note whether the slot was meant to carry a predicate (author the `source`) or ' diff --git a/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts b/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts new file mode 100644 index 00000000000..41277fd3190 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.observability-cel-predicates-retired.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code +// span already, and a nested backtick would close it. +export const entry: SemanticMigration = { + id: 'observability-cel-predicates-retired', + surface: + 'metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured ' + + '{ threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].' + + 'condition, the CEL predicate arm of the union (the structured filter arm is untouched). ' + + 'Both arms were reachable in two spellings: the bare-string shorthand and the ' + + '{ dialect: \'cel\', source } envelope. Reachable wherever metadata is authored or stored: ' + + 'defineStack sources, an exported stack passed to objectstack validate, a POST body on a ' + + 'metrics or tracing config, and a row already sitting in sys_metadata', + replacement: + 'the structured arm each slot already carried, or your observability infrastructure. On ' + + '`successCriteria` write the threshold rule — `{ threshold: 300, operator: \'lt\', ' + + 'percentile: 0.95 }` — which is the shape an SLO product consumes. On a composite sampling ' + + '`condition` write a structured filter: a plain object of match criteria carrying no ' + + '`dialect` key, e.g. `{ service: \'api\', attributes: { \'http.route\': \'/v1/orders\' } }`. ' + + '⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a ' + + 'sampling rule the structured shape cannot express has no home in application metadata at ' + + 'all and belongs in the SLO product or the OpenTelemetry sampler configuration that ' + + 'actually evaluates it', + reason: + 'DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO ' + + 'rather than a mechanical strip. Both arms parsed, normalized a bare string to ' + + '`{ dialect: \'cel\', source }`, registered and were served back, and no service, plugin, ' + + 'runtime or CLI path ever read either key: an identity scan over the whole tree finds every ' + + 'hit for `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` ' + + 'outside `packages/spec/src` to be a generated artefact or prose, and inside it the only ' + + 'readers are the schemas\' own unit tests plus the two census tests that enumerate ' + + 'expression slots. So an author — very often an AI reading the generated reference page, ' + + 'ADR-0033 — who wrote `successCriteria: \'p95 < 300ms\'` got a green parse and no signal, ' + + 'indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ' + + 'ruled A by the maintainer on 2026-09-18 (director decision batch #160 item 3): by the ' + + 'standing criterion that a declared-but-unread capability is kept only when mainstream ' + + 'platforms in the domain have it, application platforms do not carry SLI success criteria ' + + 'or trace-sampling conditions as authorable application metadata — that lives in ' + + 'observability infrastructure (SLO products, OTel sampling policy) and is structured there, ' + + 'not a free expression. The `cron-declared-unwired` family was retired outright under the ' + + 'same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a ' + + 'predicate is an intent no threshold/operator pair or attribute filter records, so ' + + 'stripping the key would delete what the author meant and leave no trace of which SLI or ' + + 'which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ' + + '⚠️ And a strip here is not merely lossy, it is INVALID: `successCriteria` is a REQUIRED ' + + 'key, so removing it leaves an SLI that no longer parses, and a composite sampling branch ' + + 'that loses its `condition` declares no condition at all — inert today, and the moment a ' + + 'sampler is wired it reads as UNCONDITIONAL. That is the difference from the ' + + 'two error-map precedents this retirement copies its MECHANISM from — `crypto.hash` on ' + + 'HookBodyCapability and `managedBy: \'system\'` — both of which also registered a D2 ' + + 'conversion, because for each of them a mechanical rewrite existed. Here none does, which is ' + + 'what makes D3 the right disposition rather than merely an available one. ' + + '⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and ' + + 'it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049.', + acceptanceCriteria: + 'Sweep every authored metadata source and every `sys_metadata` row of the metrics and ' + + 'tracing config types for a CEL predicate at the two slots — in BOTH spellings: a bare ' + + 'string, and an object carrying a `dialect` key. For each hit, decide per the `replacement` ' + + 'note whether the intent is expressible as the structured shape (write it) or belongs in ' + + 'your observability stack (delete the key and move the rule there). ⛔ Do not translate a ' + + 'predicate into a threshold by guessing the number — nothing was evaluating it, so there is ' + + 'no behaviour to preserve and a wrong number is worse than an absent one. Two proofs. ' + + '(1) `objectstack validate` is clean on a stack authored in config files: a surviving ' + + 'predicate is refused at the slot with the retirement prescription. ⚠️ TWO CHANNELS, and ' + + 'they do not cover the same set — measured, not assumed. `tsc` catches the BARE-STRING ' + + 'spelling at both slots, and the `{ dialect, source }` envelope at `successCriteria` only ' + + '(the structured arm is a closed object literal, so the envelope is an excess-property ' + + 'error). It does NOT catch the envelope at `condition`: the surviving arm there is a record ' + + 'of string to unknown, which admits `{ dialect, source }` structurally, so that one spelling ' + + 'compiles and is refused at PARSE by the arm\'s `dialect` rule. ⛔ Do not read a clean ' + + '`tsc` as a clean sweep of `condition`. The PRESCRIPTION divides differently again: it ' + + 'reaches the author for every refused spelling at `condition`, and for the string spelling ' + + 'only at `successCriteria`, where the envelope is refused by the structured arm\'s own ' + + 'missing-key issues (`threshold`, `operator`). All three legs are pinned in the schemas\' ' + + 'unit tests. (2) For stored rows, load the tenant and confirm every ' + + 'metrics and tracing config still rehydrates: a row carrying a predicate at either slot now ' + + 'fails its parse at the load seam and is reported there, naming the slot. A row whose ' + + '`successCriteria` is a structured rule and whose sampling `condition` objects carry no ' + + '`dialect` key parses byte-identically to before — the retirement removes accepted shapes ' + + 'and adds none.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index bd4beaf8963..5ae8c0ea048 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7961,29 +7961,28 @@ const step18: MigrationStep = { { id: 'evaluated-expression-slots-source-required', surface: - 'every EVALUATED expression slot in the spec — the 36 declaring positions the #15811 census ' - + 'enumerated by identity, not by a name scan: the formula Field.expression; the predicate ' + 'every EVALUATED expression slot in the spec — the 34 declaring positions of the #15811 census ' + + 'that survive into this major, enumerated by identity and not by a name scan: the formula ' + + 'Field.expression; the predicate ' + 'keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / ' + 'visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ' + 'ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, ' + 'RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport ' + 'routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, ' - + 'PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection, the ' - + 'settings-manifest Specifier and manifest visible, the ServiceLevelIndicator successCriteria ' - + 'union member and the TraceSamplingConfig composite condition union member — authored either ' + + 'PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the ' + + 'settings-manifest Specifier and manifest visible — authored either ' + 'as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), ' + 'or with a source that is blank after trimming, through the envelope key ' - + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it. ⚠️ At the ' - + 'TraceSamplingConfig composite condition slot the reachable set is WIDER than those two ' - + 'spellings, because the structured-filter arm beside the expression arm narrowed with it: a ' - + 'bare record accepted { dialect: \'cel\', ast } as an ordinary filter, which made the rule ' - + 'inert there, so that arm now declines ANY object carrying a dialect key. Six shapes the ' - + 'base accepted through that arm alone are refused at that slot: { dialect: \'cel\' }, ' - + '{ dialect: \'js\', source: \'x\' }, { dialect: \'nope\', source: \'x\' }, ' - + '{ dialect: \'cel\', source: 5 }, ' - + '{ dialect: \'cel\', source: \'x\', meta: { rationale: 5 } } and ' - + '{ dialect: \'zzz\', foo: 1 }. A structured filter carrying no dialect key is accepted ' - + 'exactly as before. The published ' + + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it. ⚠️ The census ' + + 'this entry was written against counted 36, and the two that are deliberately absent here are ' + + 'the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition ' + + 'expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished ' + + 'major by the observability-cel-predicates-retired entry of this step, under ADR-0049 ' + + 'enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an ' + + 'upgrader never meets those two slots under THIS rule — the composite of the two changes is ' + + 'the retirement alone, and stating the narrowing for a slot that no longer accepts an ' + + 'expression at all would send the upgrader to author one. That absorption is the only reason ' + + 'the count here is not the census figure the #15811 card records. The published ' + 'TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable ' + 'wherever metadata is authored or stored: defineStack sources, an exported stack passed to ' + 'objectstack validate, a POST body on any of these metadata types, and a row already sitting ' @@ -8048,19 +8047,15 @@ const step18: MigrationStep = { + 'the slots named in `surface`: an expression envelope with no `source` key, and a `source` ' + '(or bare-string shorthand) that is empty after trimming. ⚠️ Sweep by SLOT, not by key name ' + '— `visible` is on this list for actions, action params, nav items, bulk actions, record ' - + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and three of the ' - + 'positions are union members (`RecordAlertProps.visible`, ' - + '`ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`). On ' - + 'TWO of those three the sibling arm is untouched: `RecordAlertProps.visible` still takes a ' - + 'boolean literal, and `ServiceLevelIndicator.successCriteria` still takes its structured ' - + '`{ threshold, operator, percentile? }` object — including one that carries a `dialect` ' - + 'key. ⚠️ On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm ' - + 'narrows WITH the expression arm and that is deliberate: a bare record accepted ' - + '`{ dialect: \'cel\', ast }` as an ordinary filter, so the rule was inert there. The sweep ' - + 'at that ONE slot is therefore wider — flag every `condition` object carrying a `dialect` ' - + 'key, not only the two spellings above; if it is a structured filter, drop the `dialect` ' - + 'key, and if it is an expression it needs a dialect this platform evaluates AND a non-blank ' - + '`source`. For each hit: if it carries an `ast`, run ' + + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the ' + + 'positions is a union member, `RecordAlertProps.visible`, whose sibling arm is untouched: it ' + + 'still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union ' + + 'members the #15811 census listed — `ServiceLevelIndicator.successCriteria` and ' + + '`TraceSamplingConfig.composite[].condition` — are deliberately NOT on this sweep, because ' + + 'their expression arms were retired outright in this same major (see `surface`). Sweep those ' + + 'two under `observability-cel-predicates-retired` instead, whose instruction is the opposite ' + + 'of this one: there, an expression is not repaired, it is replaced by the structured shape or ' + + 'moved out of application metadata. For each hit: if it carries an `ast`, run ' + '`printCelAst(ast)`; a string result IS the migration and needs no judgment beyond reading ' + 'it back. A `null` result, or a blank `source`, is the hand-migration case — decide per the ' + '`replacement` note whether the slot was meant to carry a predicate (author the `source`) or ' @@ -9982,6 +9977,86 @@ const step18: MigrationStep = { + 'drift window carrying `indexes[].where` is rejected with the database-layer prescription ' + 'rather than saved with the key silently dropped.', }, + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span already, and a nested backtick would close it. + { + id: 'observability-cel-predicates-retired', + surface: + 'metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured ' + + '{ threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].' + + 'condition, the CEL predicate arm of the union (the structured filter arm is untouched). ' + + 'Both arms were reachable in two spellings: the bare-string shorthand and the ' + + '{ dialect: \'cel\', source } envelope. Reachable wherever metadata is authored or stored: ' + + 'defineStack sources, an exported stack passed to objectstack validate, a POST body on a ' + + 'metrics or tracing config, and a row already sitting in sys_metadata', + replacement: + 'the structured arm each slot already carried, or your observability infrastructure. On ' + + '`successCriteria` write the threshold rule — `{ threshold: 300, operator: \'lt\', ' + + 'percentile: 0.95 }` — which is the shape an SLO product consumes. On a composite sampling ' + + '`condition` write a structured filter: a plain object of match criteria carrying no ' + + '`dialect` key, e.g. `{ service: \'api\', attributes: { \'http.route\': \'/v1/orders\' } }`. ' + + '⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a ' + + 'sampling rule the structured shape cannot express has no home in application metadata at ' + + 'all and belongs in the SLO product or the OpenTelemetry sampler configuration that ' + + 'actually evaluates it', + reason: + 'DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO ' + + 'rather than a mechanical strip. Both arms parsed, normalized a bare string to ' + + '`{ dialect: \'cel\', source }`, registered and were served back, and no service, plugin, ' + + 'runtime or CLI path ever read either key: an identity scan over the whole tree finds every ' + + 'hit for `successCriteria`, `ServiceLevelIndicatorSchema` and `TraceSamplingConfigSchema` ' + + 'outside `packages/spec/src` to be a generated artefact or prose, and inside it the only ' + + 'readers are the schemas\' own unit tests plus the two census tests that enumerate ' + + 'expression slots. So an author — very often an AI reading the generated reference page, ' + + 'ADR-0033 — who wrote `successCriteria: \'p95 < 300ms\'` got a green parse and no signal, ' + + 'indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ' + + 'ruled A by the maintainer on 2026-09-18 (director decision batch #160 item 3): by the ' + + 'standing criterion that a declared-but-unread capability is kept only when mainstream ' + + 'platforms in the domain have it, application platforms do not carry SLI success criteria ' + + 'or trace-sampling conditions as authorable application metadata — that lives in ' + + 'observability infrastructure (SLO products, OTel sampling policy) and is structured there, ' + + 'not a free expression. The `cron-declared-unwired` family was retired outright under the ' + + 'same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a ' + + 'predicate is an intent no threshold/operator pair or attribute filter records, so ' + + 'stripping the key would delete what the author meant and leave no trace of which SLI or ' + + 'which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ' + + '⚠️ And a strip here is not merely lossy, it is INVALID: `successCriteria` is a REQUIRED ' + + 'key, so removing it leaves an SLI that no longer parses, and a composite sampling branch ' + + 'that loses its `condition` declares no condition at all — inert today, and the moment a ' + + 'sampler is wired it reads as UNCONDITIONAL. That is the difference from the ' + + 'two error-map precedents this retirement copies its MECHANISM from — `crypto.hash` on ' + + 'HookBodyCapability and `managedBy: \'system\'` — both of which also registered a D2 ' + + 'conversion, because for each of them a mechanical rewrite existed. Here none does, which is ' + + 'what makes D3 the right disposition rather than merely an available one. ' + + '⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and ' + + 'it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049.', + acceptanceCriteria: + 'Sweep every authored metadata source and every `sys_metadata` row of the metrics and ' + + 'tracing config types for a CEL predicate at the two slots — in BOTH spellings: a bare ' + + 'string, and an object carrying a `dialect` key. For each hit, decide per the `replacement` ' + + 'note whether the intent is expressible as the structured shape (write it) or belongs in ' + + 'your observability stack (delete the key and move the rule there). ⛔ Do not translate a ' + + 'predicate into a threshold by guessing the number — nothing was evaluating it, so there is ' + + 'no behaviour to preserve and a wrong number is worse than an absent one. Two proofs. ' + + '(1) `objectstack validate` is clean on a stack authored in config files: a surviving ' + + 'predicate is refused at the slot with the retirement prescription. ⚠️ TWO CHANNELS, and ' + + 'they do not cover the same set — measured, not assumed. `tsc` catches the BARE-STRING ' + + 'spelling at both slots, and the `{ dialect, source }` envelope at `successCriteria` only ' + + '(the structured arm is a closed object literal, so the envelope is an excess-property ' + + 'error). It does NOT catch the envelope at `condition`: the surviving arm there is a record ' + + 'of string to unknown, which admits `{ dialect, source }` structurally, so that one spelling ' + + 'compiles and is refused at PARSE by the arm\'s `dialect` rule. ⛔ Do not read a clean ' + + '`tsc` as a clean sweep of `condition`. The PRESCRIPTION divides differently again: it ' + + 'reaches the author for every refused spelling at `condition`, and for the string spelling ' + + 'only at `successCriteria`, where the envelope is refused by the structured arm\'s own ' + + 'missing-key issues (`threshold`, `operator`). All three legs are pinned in the schemas\' ' + + 'unit tests. (2) For stored rows, load the tenant and confirm every ' + + 'metrics and tracing config still rehydrates: a row carrying a predicate at either slot now ' + + 'fails its parse at the load seam and is reported there, naming the slot. A row whose ' + + '`successCriteria` is a structured rule and whose sampling `condition` objects carry no ' + + '`dialect` key parses byte-identically to before — the retirement removes accepted shapes ' + + 'and adds none.', + }, { id: 'package-rollback-response-retired', surface: diff --git a/packages/spec/src/shared/evaluated-slot-population.test.ts b/packages/spec/src/shared/evaluated-slot-population.test.ts index d1b1443e5e4..75fe9b9f67a 100644 --- a/packages/spec/src/shared/evaluated-slot-population.test.ts +++ b/packages/spec/src/shared/evaluated-slot-population.test.ts @@ -58,9 +58,7 @@ import { BulkActionDefSchema } from '../ui/bulk-action.zod.js'; import { PageTabsProps, RecordAlertProps } from '../ui/component.zod.js'; import { PageComponentSchema } from '../ui/page.zod.js'; import { FormFieldSchema, FormSectionSchema, ListViewSchema } from '../ui/view.zod.js'; -import { ServiceLevelIndicatorSchema } from '../system/metrics.zod.js'; import { SettingsManifestSchema, SpecifierSchema } from '../system/settings-manifest.zod.js'; -import { TraceSamplingConfigSchema } from '../system/tracing.zod.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const SPEC_SRC = join(HERE, '..'); @@ -193,10 +191,17 @@ function element(schema: unknown, ...path: string[]): z.ZodType { } /** - * The 36 declaring positions, keyed the way the ADR-0058 D7 conformance ledger + * The 34 declaring positions, keyed the way the ADR-0058 D7 conformance ledger * keys them (`file:Schema.field`). Re-derived by identity on this tree, ⛔ not - * inherited from the card: 34 declaring source lines, two of which are + * inherited from the card: 32 declaring source lines, two of which are * file-local alias consts mounting two slots each. + * + * [#18118] It was 36 over 34 lines. Two union members left with the CEL arms + * they mounted — `system/metrics.zod.ts:ServiceLevelIndicatorSchema.successCriteria` + * and `system/tracing.zod.ts:TraceSamplingConfigSchema.condition` — retired + * under ADR-0049 enforce-or-remove because nothing evaluated either. They are + * named here rather than silently absent: a position that leaves this table + * with no record is the #17630 failure in another costume. */ const POSITIONS: ReadonlyArray z.ZodType]> = [ ['data/field.zod.ts:FieldSchema.expression', () => slot(FieldSchema, 'expression')], @@ -235,15 +240,13 @@ const POSITIONS: ReadonlyArray z.ZodType]> = [ // The two the file-local alias `SettingsVisibilityInputSchema` mounts. ['system/settings-manifest.zod.ts:SpecifierSchema.visible', () => slot(SpecifierSchema, 'visible')], ['system/settings-manifest.zod.ts:SettingsManifestSchema.visible', () => slot(SettingsManifestSchema, 'visible')], - ['system/metrics.zod.ts:ServiceLevelIndicatorSchema.successCriteria', () => slot(ServiceLevelIndicatorSchema, 'successCriteria')], - ['system/tracing.zod.ts:TraceSamplingConfigSchema.condition', () => slot(element(TraceSamplingConfigSchema, 'composite'), 'condition')], ]; describe('#15811 — every evaluated slot refuses the two shapes no engine can run', () => { - it('reaches exactly the 36 declaring positions the census enumerated', () => { + it('reaches exactly the 34 declaring positions the census enumerated', () => { // A position that stops being reachable must red here rather than fall out // of the table: that silent drop is the #17630 failure in another costume. - expect(POSITIONS.length).toBe(36); + expect(POSITIONS.length).toBe(34); for (const [key, get] of POSITIONS) { expect(() => get(), `unreachable: ${key}`).not.toThrow(); } @@ -284,7 +287,7 @@ describe('#15811 — every evaluated slot refuses the two shapes no engine can r }); it('CONTROL — the persistence contract is NOT narrowed and still accepts both shapes', () => { - // This is what makes the 36 `false`s above a reading. Ruling item 2: + // This is what makes the 34 `false`s above a reading. Ruling item 2: // `ExpressionSchema` / `ExpressionInputSchema` keep `source` OR `ast`. for (const schema of [ExpressionSchema, ExpressionInputSchema, PredicateInputSchema]) { expect(schema.safeParse(AST_ONLY).success).toBe(true); @@ -292,7 +295,7 @@ describe('#15811 — every evaluated slot refuses the two shapes no engine can r } }); - it('CONTROL — a healthy predicate still parses at every one of the 36 positions', () => { + it('CONTROL — a healthy predicate still parses at every one of the 34 positions', () => { // The narrowing removes accepted shapes and adds none. The settings-manifest // pair speaks its own closed non-CEL grammar (#7169), so it gets the // predicate that grammar accepts; every other slot gets CEL. diff --git a/packages/spec/src/shared/evaluated-slot-union.ts b/packages/spec/src/shared/evaluated-slot-union.ts index af86efaca47..3f82fa723e6 100644 --- a/packages/spec/src/shared/evaluated-slot-union.ts +++ b/packages/spec/src/shared/evaluated-slot-union.ts @@ -7,18 +7,23 @@ * * `EvaluatedExpressionInputSchema` carries its own `error` map, so a slot * declared with it answers the published `EVALUATED_EXPRESSION_SOURCE_REQUIRED` - * sentence for both refused spellings. Five of the 36 evaluated positions are + * sentence for both refused spellings. Three of the 34 evaluated positions are * not declared with it alone — they wrap it in a union with something else: * * - `z.boolean()` beside it on `ui/action.zod.ts` `ActionConditionInputSchema` * (which mounts `Action.visible` and `Action.disabled`) and on - * `ui/component.zod.ts` `RecordAlertProps.visible`; - * - a structured object beside it on `system/metrics.zod.ts` - * `ServiceLevelIndicator.successCriteria`; - * - a structured filter beside it on `system/tracing.zod.ts` - * `TraceSamplingConfig.composite[].condition`. + * `ui/component.zod.ts` `RecordAlertProps.visible`. * - * On those five the OUTER union folds every branch into one top-level + * [#18118] It was five of 36. The other two — a structured object beside it on + * `system/metrics.zod.ts` `ServiceLevelIndicator.successCriteria`, a structured + * filter beside it on `system/tracing.zod.ts` + * `TraceSamplingConfig.composite[].condition` — are gone: their expression arms + * were RETIRED under ADR-0049 enforce-or-remove because nothing evaluated + * either, so those two slots are no longer evaluated positions and no longer + * unions. Each now hangs its own retirement prescription on the surviving arm's + * `error` map, module-local, the way this helper is package-internal. + * + * On those three the OUTER union folds every branch into one top-level * `invalid_union` whose own message is the literal `"Invalid input"`, and the * inner union's sentence never reaches the author — measured on #15811, where * the four positions refused correctly and said nothing useful about why. The @@ -39,7 +44,7 @@ * ## ⛔ Package-internal — NOT a public export * * Reachable only from inside `@objectstack/spec`, and deliberately absent from - * `shared/index.ts` and from the root barrel: it is machinery five declaring + * `shared/index.ts` and from the root barrel: it is machinery three declaring * sites need, not a contract anyone should author against (the #4001 pitfall — * do not export internals only these modules need). `api-surface/` and * `export-origins/` must not move for it, which is also what keeps #15811 a diff --git a/packages/spec/src/system/metrics.test.ts b/packages/spec/src/system/metrics.test.ts index 8f3208aa95f..3890d725227 100644 --- a/packages/spec/src/system/metrics.test.ts +++ b/packages/spec/src/system/metrics.test.ts @@ -786,3 +786,96 @@ describe('metrics duration rows declare seconds through the type (#18124)', () = expect(MetricsConfigSchema.parse(config(60)).retention?.downsampling?.[0]?.resolution).toBe(60); }); }); + + +// --------------------------------------------------------------------------- +// #18118 — ServiceLevelIndicator.successCriteria +// --------------------------------------------------------------------------- + +/** + * The slot whose CEL arm was RETIRED, and the structured rule that survived it. + * + * The union was `z.union([{ threshold, operator, percentile? }, ])`. Nothing anywhere evaluated the expression arm, so a + * criterion authored as a predicate parsed, registered and read back while + * deciding nothing (ADR-0049 enforce-or-remove, ruled A). The arm is gone; the + * structured object is untouched. + * + * Three facts, and any one alone is a green that proves nothing: + * + * - ACCEPT SET, the surviving half. The structured rule parses exactly as it + * did before, `percentile` included. Without this control a table of + * `false`s below would be a schema that refuses everything. + * - ACCEPT SET, the retired half. The bare-string spelling and the + * `{ dialect, source }` envelope were accepted before and are refused now — + * that pair IS the retirement. + * - MESSAGES, INCLUDING WHERE THEY DO NOT REACH. The prescription answers the + * STRING spelling, which is the one the reference page advertised and the + * one the ruling names (`successCriteria: 'p95 < 300ms'`). It does NOT + * answer the envelope spelling: zod 4.4 consults a schema's `error` map for + * the top-level `invalid_type` a non-object raises and NOT for the child + * issues a wrong-shaped OBJECT raises, so an envelope is refused by the + * structured arm's own missing-key issues. Both directions are pinned. ⛔ The + * second pin is not a wish — it is the measured cell, and a future change + * that makes the prescription reach the envelope must move it rather than + * delete it, because the day it silently stops being true is the day the + * retirement stops being audible in one of its two spellings. + */ +describe('#18118 ServiceLevelIndicator.successCriteria — the retired CEL arm', () => { + const sli = (successCriteria: unknown) => ServiceLevelIndicatorSchema.safeParse({ + name: 'api_latency', + label: 'API Latency', + metric: 'http_request_duration_seconds', + type: 'latency' as const, + successCriteria, + window: { durationSeconds: 2592000 }, + }); + const issuesAt = (successCriteria: unknown) => { + const r = sli(successCriteria); + expect(r.success, `expected a refusal for ${JSON.stringify(successCriteria)}`).toBe(false); + return r.success ? [] : r.error.issues; + }; + const PRESCRIPTION = /`metrics\.slis\[\]\.successCriteria` no longer accepts a CEL predicate.*removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*threshold: 300/s; + + it('CONTROL — the structured rule is accepted, as before', () => { + expect(sli({ threshold: 99.9, operator: 'gte' }).success).toBe(true); + expect(sli({ threshold: 300, operator: 'lt', percentile: 0.95 }).success).toBe(true); + }); + + it.each([ + ['a bare CEL predicate', 'p95 < 300ms'], + ['an empty bare string', ''], + ['a blank bare string', ' '], + ] as const)('REFUSES %s with the retirement prescription — it parsed before this card', (_label, criteria) => { + const issues = issuesAt(criteria); + const own = issues.filter((i) => i.path.join('.') === 'successCriteria'); + expect(own).toHaveLength(1); + expect(own[0].code).toBe('invalid_type'); + expect(own[0].message).toMatch(PRESCRIPTION); + }); + + it.each([ + ["a HEALTHY { dialect: 'cel', source } envelope", { dialect: 'cel', source: 'p95 < 300ms' }], + ["{ dialect: 'cel' }", { dialect: 'cel' }], + ] as const)('REFUSES %s — but by the structured arm\'s own missing keys, NOT the prescription (measured limit)', (_label, criteria) => { + const issues = issuesAt(criteria); + expect(issues.map((i) => i.path.join('.'))).toEqual( + expect.arrayContaining(['successCriteria.threshold', 'successCriteria.operator']), + ); + expect(issues.map((i) => i.message).join('\n')).not.toMatch(PRESCRIPTION); + }); + + it('does NOT borrow the retirement sentence for a refusal that is not the retirement', () => { + // A number is not an expression attempt in any spelling; zod's own + // `expected object` message is the honest answer and must stand. + const issues = issuesAt(5); + expect(issues.map((i) => i.message).join('\n')).not.toMatch(PRESCRIPTION); + expect(issues.some((i) => /expected object/.test(i.message))).toBe(true); + }); + + it('publishes the retirement in the `describe()` the reference page renders', () => { + const successCriteria = (ServiceLevelIndicatorSchema as any).shape.successCriteria as { description?: string }; + expect(successCriteria.description).toBeTypeOf('string'); + expect(successCriteria.description).toContain('A CEL predicate is NOT accepted here'); + }); +}); diff --git a/packages/spec/src/system/metrics.zod.ts b/packages/spec/src/system/metrics.zod.ts index 09aff31cc58..5d24d484c02 100644 --- a/packages/spec/src/system/metrics.zod.ts +++ b/packages/spec/src/system/metrics.zod.ts @@ -2,8 +2,6 @@ import { z } from 'zod'; import { DurationSeconds } from '../shared/duration.zod'; -import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; -import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; /** * Metrics Protocol - Performance and Operational Metrics @@ -433,6 +431,61 @@ export type MetricAggregationConfig = z.input; +/** + * Why `metrics.slis[].successCriteria` no longer takes a CEL predicate (#18118). + * + * The slot was `z.union([{ threshold, operator, percentile? }, ])`. The expression arm parsed, normalized a bare string to + * `{ dialect: 'cel', source }`, registered, and was served back — and NOTHING + * anywhere evaluated it. An author (very often an AI reading the generated + * reference page, ADR-0033) who wrote `successCriteria: 'p95 < 300ms'` got a + * green parse and no signal, indistinguishable from a predicate that ran and + * answered. ADR-0049 enforce-or-remove, ruled A on this card: application + * platforms do not carry SLI success criteria as authorable application + * metadata — that lives in observability infrastructure (SLO products), where + * it is structured rather than a free expression. So the arm is REMOVED, not + * wired. + * + * ⛔ No `os migrate meta` sentence: the house sentence is owed only where an + * ADR-0087 conversion covers the surface (`shared/retired-key.ts` module + * docblock). This retirement's disposition is a D3 SEMANTIC entry + * (`observability-cel-predicates-retired`) — no transform can turn a CEL + * predicate into a threshold/operator pair without inventing the numbers. + * + * ⚠️ The structured arm is UNTOUCHED, and whether it is read by anything is a + * separate measurement on its own card — this retirement makes no claim about + * it. + */ +const SLI_SUCCESS_CRITERIA_EXPRESSION_RETIRED = + '`metrics.slis[].successCriteria` no longer accepts a CEL predicate — the expression arm was ' + + 'removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever evaluated it, ' + + 'so a criterion authored as an expression parsed, registered and read back while deciding ' + + 'nothing. Delete the expression and write the structured rule instead: ' + + '`{ threshold: 300, operator: \'lt\', percentile: 0.95 }`, which is the shape this slot has ' + + 'always carried and the only one an SLO product can consume. A criterion the structured rule ' + + 'cannot express belongs in your observability stack, not in application metadata.'; + +/** + * The refusal for an input that is recognisably a RETIRED expression attempt. + * + * Deliberately narrow, the same discipline as `evaluatedExpressionUnionRefusal` + * (which this replaces here): the surviving arm is a structured object, so + * blaming a mistyped threshold on a retired CEL arm would send the author to + * the wrong key. It answers only for a string — the bare-string spelling the + * reference page advertised — and returns `undefined` for everything else, so + * zod's own message stands. + * + * ⚠️ MEASURED LIMIT, not an oversight: zod 4.4 consults this map for the + * top-level `invalid_type` a non-object raises, and NOT for the child issues a + * wrong-shaped OBJECT raises. So `successCriteria: { dialect: 'cel', source }` + * is refused by the structured arm's own missing-key issues (`threshold`, + * `operator`) rather than by this sentence. Both spellings are refused; only + * the string one carries the prescription. Pinned both ways in `metrics.test.ts`. + */ +function sliSuccessCriteriaExpressionRefusal(input: unknown): string | undefined { + return typeof input === 'string' ? SLI_SUCCESS_CRITERIA_EXPRESSION_RETIRED : undefined; +} + /** * Service Level Indicator (SLI) Schema */ @@ -472,17 +525,20 @@ export const ServiceLevelIndicatorSchema = lazySchema(() => z.object({ ]).describe('SLI type'), /** - * Success criteria — structured threshold/operator OR a CEL predicate. - */ - successCriteria: z.union([ - z.object({ - threshold: z.number().describe('Threshold value'), - operator: z.enum(['lt', 'lte', 'gt', 'gte', 'eq']).describe('Comparison operator'), - percentile: z.number().min(0).max(1).optional().describe('Percentile (0-1)'), - }), - EvaluatedExpressionInputSchema, - ], { error: (issue) => evaluatedExpressionUnionRefusal(issue.input) }) - .describe('Success criteria — structured or CEL predicate'), + * Success criteria — a structured threshold rule. + * + * [#18118] The CEL arm is RETIRED — see + * {@link SLI_SUCCESS_CRITERIA_EXPRESSION_RETIRED} above the schema. The + * prescription hangs on this object's own `error` map because the KEY + * survives and only one of its two arms went away: `retiredKey()` and the + * ADR-0087 D2 strip both retire a key, neither retires an arm. + */ + successCriteria: z.object({ + threshold: z.number().describe('Threshold value'), + operator: z.enum(['lt', 'lte', 'gt', 'gte', 'eq']).describe('Comparison operator'), + percentile: z.number().min(0).max(1).optional().describe('Percentile (0-1)'), + }, { error: (issue) => sliSuccessCriteriaExpressionRefusal(issue.input) }) + .describe('Success criteria — a structured threshold rule. A CEL predicate is NOT accepted here: that arm was removed in 17.5.0 because nothing evaluated it.'), /** * Measurement window diff --git a/packages/spec/src/system/tracing.test.ts b/packages/spec/src/system/tracing.test.ts index 5d02eb20e3a..0308ab21584 100644 --- a/packages/spec/src/system/tracing.test.ts +++ b/packages/spec/src/system/tracing.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest'; -import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '../shared/expression.zod'; import { TraceStateSchema, TraceFlagsSchema, @@ -691,33 +690,35 @@ describe('the OTel exporter and performance durations carry their unit (#17785)' // --------------------------------------------------------------------------- -// #15811 — TraceSamplingConfig.composite[].condition +// #18118 — TraceSamplingConfig.composite[].condition // --------------------------------------------------------------------------- /** - * The one evaluated slot whose SIBLING arm narrowed with it, and the one whose - * refusal messages nothing pinned. + * The slot whose CEL arm was RETIRED, and the structured filter that survived it. * - * Two facts, and either alone is a green that proves nothing: + * The union was `z.union([, ])`. Nothing anywhere evaluated the expression arm, so a + * condition authored as a predicate parsed, registered and read back while + * sampling nothing (ADR-0049 enforce-or-remove, ruled A). The arm is gone; the + * record arm is untouched. * - * - ACCEPT SET. The structured-filter arm is a bare - * `z.record(z.string(), z.unknown())`, so before #15811 it took - * `{ dialect: 'cel', ast }` as an ordinary filter and the narrowing at the - * expression arm changed nothing here. The arm now declines any object - * carrying a `dialect` key. Six shapes the base accepted THROUGH THAT ARM - * ALONE — measured: the base's `ExpressionInputSchema` refused all six — - * are refused here, and the control leg is the filters that carry no - * `dialect` key, which are accepted exactly as before. Without that control - * a table of `false`s would be a schema that refuses everything. - * - MESSAGES. A slot the author cannot read is a slot that gets re-broken - * silently. The refine aborts, so each refusal is answered by whoever owns - * it: one `custom` issue AT `source` for a blank `source`, one - * `invalid_union` at the slot carrying the published sentence for an - * `ast`-only envelope or a blank bare string. Both spellings of blank are - * pinned, and so is the negative: an object refused for a reason that is - * NOT about `source` must not be answered with the `source` sentence. + * Three facts, and any one alone is a green that proves nothing: + * + * - ACCEPT SET, the surviving half. A structured filter carrying no `dialect` + * key parses exactly as it did before. Without this control a table of + * `false`s below would be a schema that refuses everything. + * - ACCEPT SET, the retired half. A bare string and a HEALTHY + * `{ dialect: 'cel', source: '…' }` envelope were accepted before and are + * refused now — that pair IS the retirement, and pinning only the shapes + * #15811 already refused would pin nothing this card changed. + * - MESSAGES. A refusal the author cannot read is a retirement that gets + * re-authored. Every refusal in the retired half carries the prescription, + * by two different routes — the record's own `error` map for a non-object, + * the aborting `dialect` refine for an object — so a pin on one says nothing + * about the other. The negative is pinned too: a value refused for a reason + * that is NOT the retirement must not borrow its sentence. */ -describe('#15811 TraceSamplingConfig.composite[].condition — the narrowed structured-filter arm', () => { +describe('#18118 TraceSamplingConfig.composite[].condition — the retired CEL arm', () => { const parse = (condition: unknown) => TraceSamplingConfigSchema.safeParse({ type: 'composite', composite: [{ strategy: 'always_on', condition }], @@ -728,74 +729,55 @@ describe('#15811 TraceSamplingConfig.composite[].condition — the narrowed stru expect(r.success, `expected a refusal for ${JSON.stringify(condition)}`).toBe(false); return r.success ? [] : r.error.issues; }; + /** The one sentence this slot refuses a retired expression with. */ + const PRESCRIPTION = /`tracing\.sampling\.composite\[\]\.condition` no longer accepts a CEL predicate.*removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*structured filter/s; it('CONTROL — a structured filter carrying no `dialect` key is accepted, as before', () => { - // This is what makes the refusals below a reading about `dialect` and not - // about the arm having been switched off. + // This is what makes the refusals below a reading about the retired arm and + // not about the slot having been switched off. expect(parse({}).success).toBe(true); expect(parse({ service: 'api' }).success).toBe(true); expect(parse({ attributes: { 'http.route': '/v1/orders' } }).success).toBe(true); + // `source` alone carries no dialect, so it is an ordinary filter key and + // stays accepted — the retirement narrowed the `dialect` door, not this one. + expect(parse({ source: 'x' }).success).toBe(true); }); - it('CONTROL — a healthy predicate is still accepted in both spellings', () => { - expect(parse('record.amount > 10').success).toBe(true); - expect(parse({ dialect: 'cel', source: 'record.amount > 10' }).success).toBe(true); - // An `ast` BESIDE a string `source` stays admitted — the rule is about a - // MISSING source, never about carrying an ast. - expect(parse({ dialect: 'cel', source: 'record.amount > 10', ast: { kind: 'const' } }).success).toBe(true); + it.each([ + ['a bare CEL predicate', 'record.amount > 10'], + ['an empty bare string', ''], + ['a blank bare string', ' '], + ] as const)('REFUSES %s with the retirement prescription — it parsed before this card', (_label, condition) => { + const issues = topIssues(condition); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_type'); + expect(issues[0].path).toEqual(['composite', 0, 'condition']); + expect(issues[0].message).toMatch(PRESCRIPTION); }); it.each([ + ["a HEALTHY { dialect: 'cel', source } envelope", { dialect: 'cel', source: 'record.amount > 10' }], ["{ dialect: 'cel' }", { dialect: 'cel' }], + ["{ dialect: 'cel', ast }", { dialect: 'cel', ast: { kind: 'const', value: 1 } }], ["{ dialect: 'js', source: 'x' }", { dialect: 'js', source: 'x' }], - ["{ dialect: 'nope', source: 'x' }", { dialect: 'nope', source: 'x' }], - ["{ dialect: 'cel', source: 5 }", { dialect: 'cel', source: 5 }], - ["{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }", { dialect: 'cel', source: 'x', meta: { rationale: 5 } }], ["{ dialect: 'zzz', foo: 1 }", { dialect: 'zzz', foo: 1 }], - ] as const)('refuses %s — the base accepted it through the structured-filter arm alone', (_label, condition) => { - expect(parse(condition).success).toBe(false); - }); - - it('a blank `source` publishes the sentence AT `source`, not a bare `Invalid input`', () => { - // The cell this pins: the refine used to be non-aborting, so two arms - // survived, the union fell back to `invalid_union`, and its own map - // answers `undefined` for a string `source` — the slot published zod's - // bare `Invalid input` and the sentence was reachable only by walking - // into nested arm issues. - for (const blank of ['', ' ']) { - const issues = topIssues({ dialect: 'cel', source: blank }); - expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('custom'); - expect(issues[0].path).toEqual(['composite', 0, 'condition', 'source']); - expect(issues[0].message).toBe(EVALUATED_EXPRESSION_SOURCE_REQUIRED); - } + ] as const)('REFUSES %s with the retirement prescription — an object carrying `dialect` is an expression attempt', (_label, condition) => { + const issues = topIssues(condition); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['composite', 0, 'condition']); + expect(issues[0].message).toMatch(PRESCRIPTION); }); - it('an `ast`-only envelope and a blank bare string publish the sentence AT the slot', () => { - for (const condition of [{ dialect: 'cel', ast: { kind: 'const', value: 1 } }, '', ' ']) { - const issues = topIssues(condition); - expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('invalid_union'); - expect(issues[0].path).toEqual(['composite', 0, 'condition']); - expect(issues[0].message).toBe(EVALUATED_EXPRESSION_SOURCE_REQUIRED); - } - }); - - it('does NOT blame `source` for a refusal that is not about `source`', () => { - // `{ dialect: 'js', source: 'x' }` carries a perfectly good non-blank - // `source`; what is wrong is the dialect. It used to be refused with the - // published `source` sentence, which sent the author at the wrong key. - for (const condition of [ - { dialect: 'js', source: 'x' }, - { dialect: 'nope', source: 'x' }, - { dialect: 'cel', source: 'x', meta: { rationale: 5 } }, - ]) { - const issues = topIssues(condition); - expect(issues.map((i) => i.message)).not.toContain(EVALUATED_EXPRESSION_SOURCE_REQUIRED); - } + it('does NOT borrow the retirement sentence for a refusal that is not the retirement', () => { + // A number is not an expression attempt in any spelling; zod's own + // `expected record` message is the honest answer and must stand. + const issues = topIssues(5); + expect(issues.map((i) => i.message).join('\n')).not.toMatch(PRESCRIPTION); + expect(issues[0].message).toMatch(/expected record/); }); - it('publishes the `dialect` rule in the `describe()` the reference page renders', () => { + it('publishes the retirement in the `describe()` the reference page renders', () => { // `.refine()` has NO JSON Schema projection (zod 4.4, measured: the // projected node is byte-identical with and without it), so the reference // table's TYPE cell cannot carry this constraint and the description @@ -808,7 +790,7 @@ describe('#15811 TraceSamplingConfig.composite[].condition — the narrowed stru const element = (composite.def ?? composite._def).element; const condition = element.shape.condition as { description?: string }; expect(condition.description).toBeTypeOf('string'); - expect(condition.description).toContain('must NOT carry one'); - expect(condition.description).toContain('`dialect`'); + expect(condition.description).toContain('carrying no `dialect` key'); + expect(condition.description).toContain('A CEL predicate is NOT accepted here'); }); }); diff --git a/packages/spec/src/system/tracing.zod.ts b/packages/spec/src/system/tracing.zod.ts index e8ac2f9bc28..43c78d08294 100644 --- a/packages/spec/src/system/tracing.zod.ts +++ b/packages/spec/src/system/tracing.zod.ts @@ -1,8 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; -import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; /** * Tracing Protocol - Distributed Tracing & Observability @@ -296,22 +294,69 @@ export const SamplingStrategyType = z.enum([ export type SamplingStrategyType = z.input; /** - * Why the structured-filter arm of `TraceSamplingConfig.composite[].condition` - * refused an object (#15811). + * Why `tracing.sampling.composite[].condition` no longer takes a CEL predicate + * (#18118). * - * Module-local on purpose: it is ONE arm's rule at ONE slot, not a published - * contract, so it stays off `api-surface/` — the same reason - * `evaluatedExpressionUnionRefusal` is package-internal. It is deliberately - * NOT `EVALUATED_EXPRESSION_SOURCE_REQUIRED`: this arm does not refuse for a - * missing `source`, it refuses because the object is not a structured filter - * at all, and an arm that borrows the other arm's sentence is how - * `{ dialect: 'js', source: 'x' }` came to be blamed on `source`. + * The slot was `z.union([, ])`. The expression arm parsed, normalized a bare string to + * `{ dialect: 'cel', source }`, registered, and was served back — and NOTHING + * anywhere evaluated it. No composite sampler reads this key; an author (very + * often an AI reading the generated reference page, ADR-0033) who wrote a + * sampling condition got a green parse and no signal, indistinguishable from a + * predicate that ran and answered. ADR-0049 enforce-or-remove, ruled A on this + * card: application platforms do not carry trace-sampling conditions as + * authorable application metadata — that lives in observability infrastructure + * (OTel sampling policy), where it is structured rather than a free expression. + * So the arm is REMOVED, not wired. + * + * Module-local on purpose: it is ONE slot's rule, not a published contract, so + * it stays off `api-surface/` — the same reason `evaluatedExpressionUnionRefusal` + * (which this replaces here) is package-internal. + * + * The REFUSED SET at this slot is unchanged from #15811 in one direction and + * narrower in the other. An object carrying a `dialect` key was already refused + * there (a bare record accepted `{ dialect: 'cel', ast }` as an ordinary filter, + * which made #15811's narrowing inert until that arm learned to decline); it is + * still refused, and now says WHY in the retirement's words instead of routing + * the author to an arm that no longer exists. What genuinely narrows is the + * expression arm's own accept set: a bare string and a healthy + * `{ dialect: 'cel', source: '…' }` envelope were accepted and now are not. A + * structured filter carrying no `dialect` key is accepted exactly as before. + * + * ⛔ No `os migrate meta` sentence: the house sentence is owed only where an + * ADR-0087 conversion covers the surface (`shared/retired-key.ts` module + * docblock). This retirement's disposition is a D3 SEMANTIC entry + * (`observability-cel-predicates-retired`) — no transform can turn a predicate + * into an attribute filter without inventing the attributes. + * + * ⚠️ The structured-filter arm is UNTOUCHED, and whether it is read by anything + * is a separate measurement on its own card — this retirement makes no claim + * about it. + */ +const SAMPLING_CONDITION_EXPRESSION_RETIRED = + '`tracing.sampling.composite[].condition` no longer accepts a CEL predicate — the expression ' + + 'arm was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever ' + + 'evaluated it, so a condition authored as an expression parsed, registered and read back ' + + 'while sampling nothing. Delete the expression and write a structured filter instead: a ' + + 'plain object of match criteria carrying no `dialect` key, e.g. ' + + '`{ service: \'api\', attributes: { \'http.route\': \'/v1/orders\' } }`. A sampling rule the ' + + 'structured filter cannot express belongs in your OpenTelemetry sampler configuration, not ' + + 'in application metadata.'; + +/** + * The refusal for an input that is recognisably a RETIRED expression attempt. + * + * Deliberately narrow, the same discipline as `evaluatedExpressionUnionRefusal`: + * the surviving arm is a structured filter, so blaming a mistyped filter on a + * retired CEL arm would send the author to the wrong key. It answers only for a + * string — the bare-string spelling the reference page advertised — and returns + * `undefined` for everything else, so zod's own message stands. The ENVELOPE + * spelling is answered by the `dialect` refine below, which sees the object + * itself. */ -const STRUCTURED_FILTER_DIALECT_REFUSED = - 'A structured sampling filter must not carry a `dialect` key: an object that does is an ' - + 'expression attempt, and it is judged by this slot\'s expression arm — which needs a dialect ' - + 'this platform evaluates and a non-blank `source`. Drop the `dialect` key to author a ' - + 'structured filter, or write `{ dialect: \'cel\', source: \'…\' }`.'; +function samplingConditionExpressionRefusal(input: unknown): string | undefined { + return typeof input === 'string' ? SAMPLING_CONDITION_EXPRESSION_RETIRED : undefined; +} /** * Trace Sampling Configuration Schema @@ -363,36 +408,25 @@ export const TraceSamplingConfigSchema = lazySchema(() => z.object({ composite: z.array(z.object({ strategy: SamplingStrategyType.describe('Strategy type'), ratio: z.number().min(0).max(1).optional(), - condition: z.union([ - // ⚠️ The structured-filter arm must refuse an EXPRESSION-shaped object or - // it swallows the one this union's other arm exists to judge: a bare - // `z.record(z.string(), z.unknown())` accepts `{ dialect: 'cel', ast }` - // as an ordinary record, so #15811's narrowing was inert here until this - // arm learned to decline. An object carrying `dialect` is an expression - // attempt and belongs to the arm below, whatever it got wrong. - // - // ⚠️ `abort: true` is about the MESSAGE and never the accept set — the - // refused set is identical either way, measured. zod 4.4 reports the one - // arm that did not abort, else `invalid_union` at the slot. Left - // non-aborting this arm was the survivor for every expression-shaped - // refusal here, and it answered for all of them: a blank `source` — the - // one shape the expression arm refuses WITHOUT aborting — collided with - // it, so the slot published a bare `Invalid input` while the real - // sentence sat nested out of sight, and `{ dialect: 'js', source: 'x' }` - // was refused with a sentence about `source` that misnames its fault. - // Aborting hands each refusal back to the arm or map that owns it, and - // the slot answers exactly what every other evaluated slot answers: one - // `custom` issue at `source` for a blank `source`, one `invalid_union` - // carrying the published sentence for an `ast`-only envelope or a blank - // bare string. - z.record(z.string(), z.unknown()) - .refine((value) => !('dialect' in value), { - message: STRUCTURED_FILTER_DIALECT_REFUSED, - abort: true, - }), - EvaluatedExpressionInputSchema, - ], { error: (issue) => evaluatedExpressionUnionRefusal(issue.input) }) - .optional().describe('Condition for this strategy — a structured filter object, or a CEL predicate an engine evaluates. ⚠️ The two are told apart by the `dialect` key: a structured filter must NOT carry one, and an object that does is judged as an expression — so it needs a dialect this platform evaluates and a non-blank `source` (`{ dialect: \'cel\', source: \'record.amount > 10\' }`). `{ dialect: \'cel\', ast: … }` with no `source` is refused here.'), + // ⚠️ The structured-filter arm must refuse an EXPRESSION-shaped object or + // it swallows the shape this slot's RETIRED arm used to judge: a bare + // `z.record(z.string(), z.unknown())` accepts `{ dialect: 'cel', source }` + // as an ordinary filter, so an author whose predicate stopped being + // evaluated would go on writing one and it would go on parsing — the exact + // silent no-op #18118 removed. An object carrying `dialect` is an + // expression attempt and is answered with the retirement prescription. + // + // ⚠️ `abort: true` is about the MESSAGE and never the accept set — the + // refused set is identical either way, measured. It is kept from #15811 so + // the refusal is answered once, at the slot, instead of being nested. + condition: z.record(z.string(), z.unknown(), { + error: (issue) => samplingConditionExpressionRefusal(issue.input), + }) + .refine((value) => !('dialect' in value), { + message: SAMPLING_CONDITION_EXPRESSION_RETIRED, + abort: true, + }) + .optional().describe('Condition for this strategy — a structured filter object of match criteria, carrying no `dialect` key. ⚠️ A CEL predicate is NOT accepted here: that arm was removed in 17.5.0 because nothing evaluated it, and an object carrying a `dialect` key is refused as an expression attempt.'), })).optional(), /**