Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e3e5cfc
wip(spec): compose EvaluatedExpressionInputSchema into all 36 evaluat…
claude Sep 17, 2026
f597deb
wip(formula): printCelAst — the lossless recovery path for an ast-onl…
claude Sep 17, 2026
b2c0357
wip(spec): one sentence at the outer unions, and close the inert trac…
claude Sep 17, 2026
9f377f1
chore(spec): regenerate migration registry, api-surface, export-origi…
claude Sep 17, 2026
6909192
refactor(spec): keep the union refusal helper package-internal
claude Sep 17, 2026
950da7d
chore(spec): api-surface and export-origins return to their base content
claude Sep 17, 2026
72dd999
fix(spec): correct the two false arm claims and pin the tracing slot'…
claude Sep 17, 2026
56a8946
chore(spec): regenerate the migration registry and the tracing refere…
claude Sep 17, 2026
559cbfb
Merge remote-tracking branch 'origin/main' into claude/issue-15811-ev…
claude Sep 17, 2026
6290243
fix(spec): drop the internal issue id from the published tracing desc…
claude Sep 17, 2026
fe2812a
Merge remote-tracking branch 'origin/main' into claude/issue-15811-ev…
claude Sep 17, 2026
a2a328c
chore(spec): regenerate the reference docs on the merged tree
claude Sep 17, 2026
115de33
fix(spec): the four expression constructors return EvaluatedExpression
claude Sep 17, 2026
9f7013f
docs(changeset): record the constructor return-type narrowing
claude Sep 17, 2026
d731084
Merge remote-tracking branch 'origin/main' into claude/issue-15811-ev…
claude Sep 18, 2026
7040732
chore(spec): regenerate generated artifacts after merging origin/main
claude Sep 18, 2026
7dc26d4
Merge origin/main into claude/issue-15811-evaluated-slot-narrowing
claude Sep 18, 2026
e892c86
chore(spec): regenerate the deferred api-surface-declarations shards …
claude Sep 18, 2026
ab00016
Merge remote-tracking branch 'origin/main' into claude/issue-15811-ev…
claude Sep 18, 2026
34a63b9
chore(spec): regenerate the four artifacts the os-regen driver deferr…
claude Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .changeset/15811-evaluated-expression-slots-source-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
"@objectstack/spec": minor
"@objectstack/formula": minor
---

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)

<!-- adr-0087: registered evaluated-expression-slots-source-required -->

**BREAKING** accept-set narrowing on 36 published metadata slots. Each of them
composed `ExpressionInputSchema` and now composes `EvaluatedExpressionInputSchema`,
so an envelope carrying only `ast` (`{ dialect: 'cel', ast: … }` with no `source`)
and a `source` that is blank after trimming — through the envelope key or through
the bare-string shorthand — are refused at the door instead of parsing and then
faulting at run time. The prescription is registered under protocol major 18 as
the semantic migration `evaluated-expression-slots-source-required`.

**⚠️ Graded `minor`, not `major`, and the ruling said `major`.** Decision batch
#122 item 3 ordered a 「`major` changeset」. This repo's launch-window convention
ships breaking changes as `minor` while the fixed group versions in lockstep, and
`scripts/check-changeset-no-major.mjs` enforces it: a `major` marker here would
promote all ~70 packages to a whole-stack major release, which is a release act.
The convention's own written carriers for breaking-ness are used instead and both
are present — this **BREAKING** banner and the ADR-0087 disposition above. The
ruling's substance (a breaking narrowing, carried by an ADR-0087 semantic
migration entry) is delivered; only the marker differs, and it differs because a
repo gate forbids the marker.

**What is NOT narrowed.** `ExpressionSchema` / `ExpressionInputSchema` remain the
persistence contract (`source` OR `ast`), by item 2 of the same ruling, and so
does `PredicateInputSchema`, which is a plain alias of the latter. A slot that
only PERSISTS an envelope is untouched; the narrowing is at the slots an engine
EVALUATES. An `ast` carried BESIDE a string `source` stays admitted everywhere.

**The population was re-derived, not inherited.** By identity — a negative
lookaround on identifier characters, so `CronExpressionInputSchema` and
`TemplateExpressionInputSchema` cannot leak in as substrings — over
`packages/spec/src`, non-test: 34 declaring source lines, two of which are
file-local alias consts (`ui/action.zod.ts` `ActionConditionInputSchema`,
`system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema`) that mount two
slots each, giving **36 declaring positions**. Three of them reach the schema as a
union member rather than head-of-declaration (`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 happens to carry a `dialect` key.

⚠️ **On the third, `TraceSamplingConfig.composite[].condition`, the sibling arm
narrows too, and deliberately.** Its structured-filter arm is a bare
`z.record(z.string(), z.unknown())`, which accepted `{ dialect: 'cel', ast }` as an
ordinary filter — so swapping the expression arm changed nothing at all there. That
arm now declines any object carrying a `dialect` key, and six shapes the base
accepted THROUGH THAT ARM ALONE (measured: the base's `ExpressionInputSchema`
refused every one of them) are refused at this slot:

| authored `condition` | base | now |
|---|---|---|
| `{ dialect: 'cel' }` | accepted | refused |
| `{ dialect: 'js', source: 'x' }` | accepted | refused |
| `{ dialect: 'nope', source: 'x' }` | accepted | refused |
| `{ dialect: 'cel', source: 5 }` | accepted | refused |
| `{ dialect: 'cel', source: 'x', meta: { rationale: 5 } }` | accepted | refused |
| `{ dialect: 'zzz', foo: 1 }` | accepted | refused |

FROM → TO at that slot: if the value really is a **structured filter**, drop the
`dialect` key (`{ dialect: 'cel', service: 'api' }` → `{ service: 'api' }`); if it is
an **expression**, give it a dialect this platform evaluates and a non-blank `source`
(`{ dialect: 'js', source: 'x' }` → `{ dialect: 'cel', source: 'x' }`). A structured
filter that carries no `dialect` key — `{}`, `{ service: 'api' }`,
`{ attributes: { 'http.route': '/v1/orders' } }` — is accepted exactly as before.

**Why an authoring-time refusal and not a run-time one.** Measured at the
chokepoint, `celEngine.evaluate` never silently succeeds on either shape — it
returns a `parse` fault — so what happened next was decided entirely by the
slot's fail policy, and the two halves of that population fail in opposite
directions: fail-CLOSED slots (`ObjectFieldGroup.visibleWhen`,
`RowCrudActionOverride.visibleWhen`, `BulkActionDef.visible`, the two
settings-manifest `visible` slots) hid a group, a row button, or silently excluded
every selected record from a bulk run and reported them as *skipped*; fail-SOFT
slots left a gate that had stopped gating. Nothing in between said a word: the
authoring lint `validateVisibilityPredicates` measured 0 findings on an `ast`-only
envelope and 0 on a blank `source`, against two control legs that each measured 1.

**`@objectstack/formula` gains `printCelAst(ast)`** — the inverse of
`parseCelToAst`, and the lossless half of the migration: an `ast`-only CEL
envelope is printed back to surface syntax mechanically, with no judgment asked of
the author. It is lossless about MEANING, not bytes (the printer re-renders from
the parse tree, so `'x'` comes back as `"x"`), and it answers `null` — never a
guess — for anything it cannot round-trip through the platform's own bounded
parser. That `null`, and every blank `source`, are what the semantic migration
entry's structured TODO covers.

**The published TypeScript interface `RowCrudPredicates` narrows with it**
(`Expression | ExpressionInput` → `EvaluatedExpression | EvaluatedExpressionInput`),
because it mirrors the two `RowCrudActionOverride` slots and a type that still
promised an `ast`-only envelope would advertise what the schema now refuses.

**So do the four expression constructors — `expression()`, `cel`, `tmpl`, `cron`
(and therefore the `F` / `P` aliases) — which now return `EvaluatedExpression`
instead of `Expression`.** Each one assigns a `string` to `source`
unconditionally, so the wider return type described none of them; it was slop
that cost nothing until an evaluated slot began requiring `source`, at which
point ``visibleWhen: P`…` `` — the spelling the spec's own docblock teaches —
stopped type-checking, and `@objectstack/platform-objects` failed its DTS build
on exactly that. `EvaluatedExpression` is assignable to `Expression`, so every
persistence-contract slot keeps accepting these values unchanged; what the
narrower return type adds is that an evaluated slot accepts them too. An author
who genuinely has no `source` was never calling these constructors — an
`ast`-only envelope is an object literal, and an evaluated slot refuses it on
purpose.
Loading
Loading