|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/formula": minor |
| 4 | +--- |
| 5 | + |
| 6 | +feat(spec)!: every engine-evaluated expression slot requires a non-blank `source` — the #15430 rule generalised from the flow-node ledger to the other 36 declaring positions (#15811, decision batch #122 item 2) |
| 7 | + |
| 8 | +<!-- adr-0087: registered evaluated-expression-slots-source-required --> |
| 9 | + |
| 10 | +**BREAKING** accept-set narrowing on 36 published metadata slots. Each of them |
| 11 | +composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`, |
| 12 | +so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`) |
| 13 | +and a `source` that is blank after trimming — through the envelope key or through |
| 14 | +the bare-string shorthand — are refused at the door instead of parsing and then |
| 15 | +faulting at run time. The prescription is registered under protocol major 18 as |
| 16 | +the semantic migration `evaluated-expression-slots-source-required`. |
| 17 | + |
| 18 | +**⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch |
| 19 | +#122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention |
| 20 | +ships breaking changes as `minor` while the fixed group versions in lockstep, and |
| 21 | +`scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would |
| 22 | +promote all ~70 packages to a whole-stack major release, which is a release act. |
| 23 | +The convention's own written carriers for breaking-ness are used instead and both |
| 24 | +are present — this **BREAKING** banner and the ADR-0087 disposition above. The |
| 25 | +ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic |
| 26 | +migration entry) is delivered; only the marker differs, and it differs because a |
| 27 | +repo gate forbids the marker. |
| 28 | + |
| 29 | +**What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the |
| 30 | +persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so |
| 31 | +does `PredicateInputSchema`, which is a plain alias of the latter. A slot that |
| 32 | +only PERSISTS an envelope is untouched; the narrowing is at the slots an engine |
| 33 | +EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere. |
| 34 | + |
| 35 | +**The population was re-derived, not inherited.** By identity — a negative |
| 36 | +lookaround on identifier characters, so `CronExpressionInputSchema` and |
| 37 | +`TemplateExpressionInputSchema` cannot leak in as substrings — over |
| 38 | +`packages/spec/src`, non-test: 34 declaring source lines, two of which are |
| 39 | +file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`, |
| 40 | +`system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two |
| 41 | +slots each, giving **36 declaring positions**. Three of them reach the schema as a |
| 42 | +union member rather than head-of-declaration (`RecordAlertProps.visible`, |
| 43 | +`ServiceLevelIndicator.successCriteria`, `TraceSamplingConfig.composite[].condition`). |
| 44 | + |
| 45 | +On **two of those three the sibling arm is untouched**: `RecordAlertProps.visible` |
| 46 | +still takes a boolean literal, and `ServiceLevelIndicator.successCriteria` still |
| 47 | +takes its structured `{ threshold, operator, percentile? }` object — including one |
| 48 | +that happens to carry a `dialect` key. |
| 49 | + |
| 50 | +⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm |
| 51 | +narrows too, and deliberately.** Its structured-filter arm is a bare |
| 52 | +`z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an |
| 53 | +ordinary filter — so swapping the expression arm changed nothing at all there. That |
| 54 | +arm now declines any object carrying a `dialect` key, and six shapes the base |
| 55 | +accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema` |
| 56 | +refused every one of them) are refused at this slot: |
| 57 | + |
| 58 | +| authored `condition` | base | now | |
| 59 | +|---|---|---| |
| 60 | +| `{ dialect: 'cel' }` | accepted | refused | |
| 61 | +| `{ dialect: 'js', source: 'x' }` | accepted | refused | |
| 62 | +| `{ dialect: 'nope', source: 'x' }` | accepted | refused | |
| 63 | +| `{ dialect: 'cel', source: 5 }` | accepted | refused | |
| 64 | +| `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused | |
| 65 | +| `{ dialect: 'zzz', foo: 1 }` | accepted | refused | |
| 66 | + |
| 67 | +FROM → TO at that slot: if the value really is a **structured filter**, drop the |
| 68 | +`dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is |
| 69 | +an **expression**, give it a dialect this platform evaluates and a non-blank `source` |
| 70 | +(`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured |
| 71 | +filter that carries no `dialect` key — `{}`, `{ service: 'api' }`, |
| 72 | +`{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before. |
| 73 | + |
| 74 | +**Why an authoring-time refusal and not a run-time one.** Measured at the |
| 75 | +chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it |
| 76 | +returns a `parse` fault — so what happened next was decided entirely by the |
| 77 | +slot's fail policy, and the two halves of that population fail in opposite |
| 78 | +directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`, |
| 79 | +`RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two |
| 80 | +settings-manifest `visible` slots) hid a group, a row button, or silently excluded |
| 81 | +every selected record from a bulk run and reported them as *skipped*; fail-SOFT |
| 82 | +slots left a gate that had stopped gating. Nothing in between said a word: the |
| 83 | +authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only |
| 84 | +envelope and 0 on a blank `source`, against two control legs that each measured 1. |
| 85 | + |
| 86 | +**`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of |
| 87 | +`parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL |
| 88 | +envelope is printed back to surface syntax mechanically, with no judgment asked of |
| 89 | +the author. It is lossless about MEANING, not bytes (the printer re-renders from |
| 90 | +the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a |
| 91 | +guess — for anything it cannot round-trip through the platform's own bounded |
| 92 | +parser. That `null`, and every blank `source`, are what the semantic migration |
| 93 | +entry's structured TODO covers. |
| 94 | + |
| 95 | +**The published TypeScript interface `RowCrudPredicates` narrows with it** |
| 96 | +(`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`), |
| 97 | +because it mirrors the two `RowCrudActionOverride` slots and a type that still |
| 98 | +promised an `ast`-only envelope would advertise what the schema now refuses. |
| 99 | + |
| 100 | +**So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron` |
| 101 | +(and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression` |
| 102 | +instead of `Expression`.** Each one assigns a `string` to `source` |
| 103 | +unconditionally, so the wider return type described none of them; it was slop |
| 104 | +that cost nothing until an evaluated slot began requiring `source`, at which |
| 105 | +point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches — |
| 106 | +stopped type-checking, and `@objectstack/platform-objects` failed its DTS build |
| 107 | +on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every |
| 108 | +persistence-contract slot keeps accepting these values unchanged; what the |
| 109 | +narrower return type adds is that an evaluated slot accepts them too. An author |
| 110 | +who genuinely has no `source` was never calling these constructors — an |
| 111 | +`ast`-only envelope is an object literal, and an evaluated slot refuses it on |
| 112 | +purpose. |
0 commit comments