Skip to content

Commit 8968c29

Browse files
committed
spec: retire the CEL arms of SLI successCriteria and composite trace-sampling condition
Both slots were z.union([<a structured arm>, EvaluatedExpressionInputSchema]). The expression arm parsed, normalized a bare string to { dialect: 'cel', source }, registered and was served back, and nothing anywhere evaluated it — ADR-0049 enforce-or-remove. The arms are removed; the structured arms are untouched and measured on their own card. The prescription hangs on the surviving schema's own error map (dispatched on issue.input), because the KEY survives and only one of its two arms went away: retiredKey() and an ADR-0087 D2 strip both retire a key, neither retires an arm. The disposition is a D3 semantic entry, observability-cel-predicates-retired, so neither prescription carries an `os migrate meta` sentence. Mechanical consequences, all declared: the retired arm held the last transform in the system/MetricsConfig and system/TracingConfig subtrees, so both defs project in output mode and publish the defaults the parser always applied (DEFAULT_CHANGES_BY_MAJOR); dropped-refinements sites move off the union option path; the ADR-0058 D7 ledger row cel-declared-unwired-observability closes with the removal and the inline scan floor drops 3 to 1, naming both positions. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 176b035 commit 8968c29

17 files changed

Lines changed: 652 additions & 279 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
**BREAKING** — retire the CEL predicate arms of `ServiceLevelIndicator.successCriteria`
6+
and `TraceSamplingConfig.composite[].condition`, the two observability predicates nothing
7+
ever evaluated.
8+
9+
Both slots were `z.union([<a structured arm>, <the evaluated expression schema>])`. The
10+
expression arm parsed, normalized a bare string to `{ dialect: 'cel', source }`,
11+
registered, and was served back — and **nothing anywhere evaluated it**. An identity scan
12+
over the whole tree finds every hit for `successCriteria`, `ServiceLevelIndicatorSchema`
13+
and `TraceSamplingConfigSchema` outside `packages/spec/src` to be a generated artefact or
14+
prose; inside it the only readers are the schemas' own unit tests and the two census tests
15+
that enumerate expression slots. No service, plugin, runtime or CLI path reads either key.
16+
So an author — very often an AI reading the generated reference page (ADR-0033) — who
17+
wrote `successCriteria: 'p95 < 300ms'` got a green parse and no signal, indistinguishable
18+
from a predicate that ran and answered.
19+
20+
ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18 (director decision batch #160
21+
item 3, letter A). By the standing criterion that a declared-but-unread capability is kept
22+
only when mainstream platforms in the domain have it: application platforms do not carry
23+
SLI success criteria or trace-sampling conditions as authorable application metadata —
24+
that lives in observability infrastructure (SLO products, OTel sampling policy) and is
25+
structured there, not a free expression. The `cron-declared-unwired` family was retired
26+
outright under the same ADR after the same measurement.
27+
28+
## FROM → TO
29+
30+
| you wrote (17.4 and earlier) | write instead |
31+
| --- | --- |
32+
| `successCriteria: 'p95 < 300ms'` | `successCriteria: { threshold: 300, operator: 'lt', percentile: 0.95 }` — the structured rule this slot has always carried |
33+
| `successCriteria: { dialect: 'cel', source: 'p95 < 300ms' }` | the same structured rule; the envelope spelling goes with the bare-string one |
34+
| `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 |
35+
| `condition: { dialect: 'cel', source: 'record.amount > 10' }` | the same structured filter; an object carrying `dialect` is refused as an expression attempt |
36+
37+
**The one-line fix:** delete the predicate and write the structured shape the slot already
38+
carried. A criterion or a sampling rule the structured shape cannot express has no home in
39+
application metadata at all — it belongs in the SLO product or the OpenTelemetry sampler
40+
configuration that actually evaluates it. ⛔ Do not translate a predicate into a threshold
41+
by guessing the number: nothing was evaluating it, so there is no behaviour to preserve and
42+
a wrong number is worse than an absent one.
43+
44+
## The retirement kit
45+
46+
- **Neither KEY is retired — one ARM of each key's union is.** `successCriteria` and
47+
`condition` both survive with their structured arm intact, so `retiredKey()` and an
48+
ADR-0087 D2 strip are both the wrong tool: they retire a key. The prescription hangs on
49+
the surviving schema's own `error` map, dispatched on `issue.input` — the
50+
`HookBodyCapability` / `object.managedBy: 'system'` pattern for a narrowing a key
51+
survives.
52+
- **Where the prescription reaches, measured on zod 4.4.** A schema's `error` map is
53+
consulted for the top-level `invalid_type` a NON-OBJECT raises, and not for the child
54+
issues a wrong-shaped OBJECT raises. So on `successCriteria` the bare-string spelling
55+
carries the prescription and the `{ dialect, source }` envelope is refused by the
56+
structured arm's own missing-key issues (`threshold`, `operator`). On `condition` both
57+
spellings carry it, because the structured arm is a record whose aborting `dialect`
58+
refine sees the object itself. Pinned both ways in the schemas' unit tests, the negative
59+
included: a value refused for a reason that is NOT the retirement must not borrow its
60+
sentence.
61+
- **ADR-0087 disposition: a D3 SEMANTIC entry**, `observability-cel-predicates-retired`,
62+
not a D2 conversion. A predicate is an intent that no threshold/operator pair or
63+
attribute filter records; a mechanical strip would delete what the author meant and leave
64+
no trace of which SLI or which sampling branch lost it. The prescriptions therefore carry
65+
**no** `os migrate meta` sentence — that sentence is owed only where a conversion covers
66+
the surface.
67+
- **The surviving accept sets are pinned beside the refusals.** `successCriteria` still
68+
takes `{ threshold, operator, percentile? }`; a composite `condition` still takes any
69+
filter object carrying no `dialect` key — `{ source: 'x' }` included, because `source`
70+
alone is an ordinary filter key and the retirement narrowed the `dialect` door only.
71+
- **Two published JSON Schemas change projection direction**, and it is mechanical rather
72+
than chosen: the retired arm held the last `.transform()` in the `system/MetricsConfig`
73+
and `system/TracingConfig` subtrees, so both defs now project in output mode instead of
74+
falling back to the input shape. `system/MetricsConfig:slis` and
75+
`system/TracingConfig:sampling` therefore publish the `default` the parser has always
76+
applied, declared in `DEFAULT_CHANGES_BY_MAJOR`; and the nested type cells of both
77+
reference pages lose the `?` from their default-bearing keys. **No runtime default moves**
78+
— measured twice, by byte-identity of the untouched `.default(…)` and by parsing a
79+
minimal config on the built package.
80+
81+
## What is deliberately NOT in this change
82+
83+
- **The structured arms.** `{ threshold, operator, percentile }` and the sampling filter
84+
record are equally unread today. The ruling says so and leaves them to their own card:
85+
they carry no dialect and are outside the expression ledger's remit.
86+
- **`skills/objectstack-formula/SKILL.md`**, which still lists `metrics` / `tracing` under
87+
`structured | cel`. The ruling assigns that correction to the skills lane, at tier, and
88+
this diff does not touch it.
89+
- **`packages/spec/src/shared/expression.zod.ts`.** `EvaluatedExpressionInputSchema` is
90+
untouched and stays the schema of every remaining evaluated slot; what left is two
91+
references to it.
92+
93+
Shipped as `minor` under the repo's launch-window convention, in which `major` is refused
94+
by `check-changeset-no-major` and breaking-ness is carried by the banner above plus the
95+
ADR-0087 disposition rather than by the level.
96+
97+
Clause-②: yes (narrowing)
98+
99+
<!-- adr-0087: registered observability-cel-predicates-retired -->

content/docs/references/system/metrics.mdx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,13 @@ Metrics configuration
309309
| **metrics** | `{ name: string; label?: string; type: Enum<'counter' \| 'gauge' \| 'histogram' \| 'summary'>; unit?: Enum<'nanoseconds' \| 'microseconds' \| 'milliseconds' \| 'seconds' \| 'minutes' \| …>; … }[]` | optional (default: `[]`) | |
310310
| **defaultLabels** | `Record<string, string>` | optional (default: `{}`) | Metric labels |
311311
| **aggregations** | `{ type: Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>; window?: object; groupBy?: string[]; filters?: Record<string, any> }[]` | optional (default: `[]`) | |
312-
| **slis** | `{ name: string; label: string; description?: string; metric: string; … }[]` | optional | |
312+
| **slis** | `{ name: string; label: string; description?: string; metric: string; … }[]` | optional (default: `[]`) | |
313313
| **slos** | `{ name: string; label: string; description?: string; sli: string; … }[]` | optional (default: `[]`) | |
314-
| **exports** | `{ type: Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>; endpoint?: string; intervalSeconds?: integer; batch?: object; … }[]` | optional (default: `[]`) | |
314+
| **exports** | `{ type: Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>; endpoint?: string; intervalSeconds: integer; batch?: object; … }[]` | optional (default: `[]`) | |
315315
| **collectionIntervalSeconds** | `integer` | optional (default: `15`) | Collection interval in seconds |
316316
| **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. |
317-
| **retention** | `{ durationSeconds?: integer; downsampling?: object[] }` | optional | |
318-
| **cardinalityLimits** | `{ maxLabelCombinations?: integer; onLimitExceeded?: Enum<'drop' \| 'sample' \| 'alert'> }` | optional | |
317+
| **retention** | `{ durationSeconds: integer; downsampling?: object[] }` | optional | |
318+
| **cardinalityLimits** | `{ maxLabelCombinations: integer; onLimitExceeded: Enum<'drop' \| 'sample' \| 'alert'> }` | optional | |
319319

320320
### Nested Shape: `MetricsConfig.metrics[number]`
321321

@@ -330,7 +330,7 @@ Metric definition
330330
| **description** | `string` | optional | Metric description |
331331
| **labelNames** | `string[]` | optional (default: `[]`) | Label names |
332332
| **histogram** | `{ type: Enum<'linear' \| 'exponential' \| 'explicit'>; linear?: object; exponential?: object; explicit?: object }` | optional | Histogram bucket configuration |
333-
| **summary** | `{ quantiles?: number[]; maxAgeSeconds?: integer; ageBuckets?: integer }` | optional | |
333+
| **summary** | `{ quantiles: number[]; maxAgeSeconds: integer; ageBuckets: integer }` | optional | |
334334
| **enabled** | `boolean` | optional (default: `true`) | |
335335

336336
### Nested Shape: `MetricsConfig.aggregations[number]`
@@ -340,7 +340,7 @@ Metric aggregation configuration
340340
| Property | Type | Required | Description |
341341
| :--- | :--- | :--- | :--- |
342342
| **type** | `Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>` || Aggregation type |
343-
| **window** | `{ durationSeconds: integer; sliding?: boolean; slideInterval?: integer }` | optional | |
343+
| **window** | `{ durationSeconds: integer; sliding: boolean; slideInterval?: integer }` | optional | |
344344
| **groupBy** | `string[]` | optional | Group by label names |
345345
| **filters** | `Record<string, any>` | optional | Filter criteria |
346346

@@ -355,8 +355,8 @@ Service Level Indicator
355355
| **description** | `string` | optional | SLI description |
356356
| **metric** | `string` || Base metric name |
357357
| **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` || SLI type |
358-
| **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 |
359-
| **window** | `{ durationSeconds: integer; rolling?: boolean }` || Measurement window |
358+
| **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. |
359+
| **window** | `{ durationSeconds: integer; rolling: boolean }` || Measurement window |
360360
| **enabled** | `boolean` | optional (default: `true`) | |
361361

362362
### Nested Shape: `MetricsConfig.slos[number]`
@@ -371,7 +371,7 @@ Service Level Objective
371371
| **sli** | `string` || SLI name |
372372
| **target** | `number` || Target percentage |
373373
| **period** | `{ type: Enum<'rolling' \| 'calendar'>; durationSeconds?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` || Time period |
374-
| **errorBudget** | `{ enabled?: boolean; alertThreshold?: number; burnRateWindows?: object[] }` | optional | |
374+
| **errorBudget** | `{ enabled: boolean; alertThreshold: number; burnRateWindows?: object[] }` | optional | |
375375
| **alerts** | `{ name: string; severity: Enum<'info' \| 'warning' \| 'critical'>; condition: object }[]` | optional (default: `[]`) | |
376376
| **enabled** | `boolean` | optional (default: `true`) | |
377377

@@ -385,7 +385,7 @@ Metric export configuration
385385
| **endpoint** | `string` | optional | Export endpoint |
386386
| **intervalSeconds** | `integer` | optional (default: `60`) | Export interval in seconds |
387387
| **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. |
388-
| **batch** | `{ enabled?: boolean; size?: integer }` | optional | |
388+
| **batch** | `{ enabled: boolean; size: integer }` | optional | |
389389
| **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api_key'>; username?: string; password?: string; token?: string; … }` | optional | |
390390
| **config** | `Record<string, any>` | optional | Additional configuration |
391391

@@ -413,11 +413,11 @@ Service Level Indicator
413413
| **description** | `string` | optional | SLI description |
414414
| **metric** | `string` || Base metric name |
415415
| **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` || SLI type |
416-
| **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 |
417-
| **window** | `{ durationSeconds: integer; rolling?: boolean }` || Measurement window |
416+
| **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. |
417+
| **window** | `{ durationSeconds: integer; rolling: boolean }` || Measurement window |
418418
| **enabled** | `boolean` | optional (default: `true`) | |
419419

420-
### Nested Shape: `ServiceLevelIndicator.successCriteria[option 1]`
420+
### Nested Shape: `ServiceLevelIndicator.successCriteria`
421421

422422
| Property | Type | Required | Description |
423423
| :--- | :--- | :--- | :--- |

0 commit comments

Comments
 (0)