|
| 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 — and it would not even be lossless |
| 65 | + in the weak sense, because `successCriteria` is REQUIRED (a strip leaves an SLI that no |
| 66 | + longer parses) and a composite branch stripped of its `condition` declares no condition at |
| 67 | + all. That is the one place this retirement parts company with the two precedents it copies |
| 68 | + its MECHANISM from: `crypto.hash` on `HookBodyCapability` and `managedBy: 'system'` both |
| 69 | + ALSO registered a D2 conversion, because for each of them a mechanical rewrite existed. |
| 70 | + Here none does, which is what makes D3 the right disposition rather than merely an |
| 71 | + available one. The prescriptions therefore carry **no** `os migrate meta` sentence — that |
| 72 | + sentence is owed only where a conversion covers the surface. |
| 73 | +- **The same-major D3 record is absorbed, per the playbook's 「同 major 记账」.** The |
| 74 | + `evaluated-expression-slots-source-required` entry landed into this same unpublished step, |
| 75 | + and it enumerated these two slots among its 36 declaring positions while instructing the |
| 76 | + upgrader to give a sampling `condition` a dialect and a non-blank `source` — the exact |
| 77 | + envelope this head now refuses. Both entries first ship together, so the composite of the |
| 78 | + two changes is the retirement alone: that entry now reads 34 positions, names the two |
| 79 | + absentees and why, and routes them to this retirement instead of to its own repair. |
| 80 | +- **The surviving accept sets are pinned beside the refusals.** `successCriteria` still |
| 81 | + takes `{ threshold, operator, percentile? }`; a composite `condition` still takes any |
| 82 | + filter object carrying no `dialect` key — `{ source: 'x' }` included, because `source` |
| 83 | + alone is an ordinary filter key and the retirement narrowed the `dialect` door only. |
| 84 | +- **FOUR published JSON Schemas change projection direction**, and it is mechanical rather |
| 85 | + than chosen: the retired arm held the last `.transform()` in each of these subtrees, so |
| 86 | + each def now projects in output mode instead of falling back to the input shape. All four |
| 87 | + lose `x-io: input`, and what each gains differs: |
| 88 | + |
| 89 | + | published schema | gains | |
| 90 | + | --- | --- | |
| 91 | + | `system/MetricsConfig` | `default: []` on `slis`, plus 8 `required` members | |
| 92 | + | `system/TracingConfig` | `default: {"type":"always_on","rules":[]}` on `sampling`, plus 4 `required` members | |
| 93 | + | `system/ServiceLevelIndicator` | one `required` member, `enabled` | |
| 94 | + | `system/TraceSamplingConfig` | one `required` member, `rules` | |
| 95 | + |
| 96 | + Only the first two carry a `default` move, so only those two are declarable in |
| 97 | + `DEFAULT_CHANGES_BY_MAJOR` — the nested pair's `required` growth has no ratchet row to |
| 98 | + live in and is stated here instead. A `required` that lists defaulted keys is this repo's |
| 99 | + existing output-mode convention, not a new one, and the same-category control |
| 100 | + `system/CacheConfig` is untouched. The reference pages show the same signature: the nested |
| 101 | + type cells of both pages lose the `?` from their default-bearing keys. **No runtime default |
| 102 | + moves** — measured twice, by byte-identity of the untouched `.default(…)` and by parsing a |
| 103 | + minimal config on the built package. |
| 104 | + |
| 105 | +## What is deliberately NOT in this change |
| 106 | + |
| 107 | +- **The structured arms.** `{ threshold, operator, percentile }` and the sampling filter |
| 108 | + record are equally unread today. The ruling says so and leaves them to their own card: |
| 109 | + they carry no dialect and are outside the expression ledger's remit. |
| 110 | +- **`skills/objectstack-formula/SKILL.md`**, which still lists `metrics` / `tracing` under |
| 111 | + `structured | cel`. The ruling assigns that correction to the skills lane, at tier, and |
| 112 | + this diff does not touch it. |
| 113 | +- **`packages/spec/src/shared/expression.zod.ts`.** `EvaluatedExpressionInputSchema` is |
| 114 | + untouched and stays the schema of every remaining evaluated slot; what left is two |
| 115 | + references to it. |
| 116 | + |
| 117 | +Shipped as `minor` under the repo's launch-window convention, in which `major` is refused |
| 118 | +by `check-changeset-no-major` and breaking-ness is carried by the banner above plus the |
| 119 | +ADR-0087 disposition rather than by the level. |
| 120 | + |
| 121 | +Clause-②: yes (narrowing) |
| 122 | + |
| 123 | +<!-- adr-0087: registered observability-cel-predicates-retired --> |
0 commit comments