Skip to content

Commit ce57857

Browse files
os-litantclaude
andauthored
feat(spec)!: every engine-evaluated expression slot requires a non-blank source (#18638)
Fixes #15811 Clause-②: yes ## Rework round — the seat's three items, measured on the rework head (⚠️ NOT the current head — see 〈Base catch-up〉 below) Seat verdict #18638 (comment) (REWORK on a PASSed contract review). Three items, nothing else re-opened. ### 1. The changeset and the ADR-0087 entry said something this diff makes false Both claimed the three union-member positions leave their sibling arm untouched. **Re-measured here**, base `00115a8442` vs head, parsing each value AS MOUNTED through `TraceSamplingConfigSchema`: | position | sibling arm | base | head | |---|---|---|---| | `RecordAlertProps.visible` | `z.boolean()` | `true` / `false` accepted | identical | | `ServiceLevelIndicator.successCriteria` | structured `{ threshold, operator, percentile? }` | accepted, **including an object carrying a `dialect` key** | identical | | `TraceSamplingConfig.composite[].condition` | `z.record(z.string(), z.unknown())` | see below | **narrowed** | At the tracing slot, six shapes the base accepted **through that arm alone** — measured: the base's `ExpressionInputSchema` refuses all six, so the record arm was the only thing admitting them — are refused at head: | authored `condition` | base | head | |---|---|---| | `{ 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 | Control that HITS: a structured filter carrying no `dialect` key — `{}`, `{ service: 'api' }`, `{ attributes: { 'http.route': '/v1/orders' } }` — is accepted at base and at head alike. Without it the six `refused`s would be a schema that refuses everything. ⭐ The narrowing is correct and load-bearing (it is what makes the ruled change non-inert at that slot) and is **not** removed. What changed is the **description**: the changeset now carries the table and its FROM → TO, and the migration entry's `surface` and `acceptanceCriteria` both name the wider sweep that slot needs — flag every `condition` object carrying a `dialect` key, not only the two spellings. A changeset becomes the CHANGELOG and an ADR-0087 entry becomes the migration ledger; neither may ship a false sentence. ### 2. The published reference page `.refine()` has **no JSON Schema projection** — measured against zod 4.4.3: `z.toJSONSchema` returns byte-identical output for the plain record, the refined record and the aborting refined record (`{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}`). So regenerating alone could never move that TYPE cell, and hand-editing the page is forbidden and would be reverted. The fix is at source: the slot's `.describe()` now states the rule, and `gen:docs` republishes it. `content/docs/references/system/tracing.mdx` now reads: > 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', ast: … }` with no `source` is refused here. ⚠️ The type cell still renders that arm as a plain record of string to any, and that is **faithful to the JSON Schema this repo publishes** — which is itself wider than the zod schema, for every `.refine()` in the spec, not only this one. Making the page contradict the artifact beside it would be worse. Reported as an out-of-scope finding rather than repaired here. ### 3. The two unpinned message cells — the fix was in the schema, not only in a test Measured at the slot, on head as it arrived: - `{ dialect: 'cel', source: '' }` → one top-level `invalid_union` with the bare **`Invalid input`**; the published sentence appeared only inside nested arm issues; - `{ dialect: 'js', source: 'x' }` → refused with the **「needs a non-blank `source`」** sentence, which misnames the fault: that value's `source` is fine, its dialect is not. Root cause, measured: zod 4.4 reports the ONE arm that did not abort, else `invalid_union`. The record arm's `.refine()` was **non-aborting**, so it was the surviving arm for every expression-shaped refusal here and answered for all of them — and it answered with the other arm's sentence. ⇒ The repair is in the schema, not only in a test: the refine becomes **aborting**, and its message becomes the arm's own rule (module-local, ⛔ not a new published export). Ablation of the accept set: the refused set is **identical** with and without `abort` — both measured over the ten-value corpus above, so this is a message change and not a second narrowing. After it, the slot answers exactly what the other 35 answer, and exactly what the migration entry's own acceptance criteria promise: | authored `condition` | before | after | |---|---|---| | `{ dialect: 'cel', source: '' }` / `' '` | `invalid_union` @ slot, `Invalid input` | **one `custom` issue @ `…condition.source`, the published sentence** | | `{ dialect: 'cel', ast: … }` | `custom` @ slot, published sentence | `invalid_union` @ slot, published sentence | | `''` / `' '` (bare) | `invalid_union` @ slot, published sentence | unchanged | | `{ dialect: 'js', source: 'x' }` | `custom` @ slot, **published `source` sentence** | `invalid_union` @ slot, `Invalid input` — no longer blames `source` | Pins, in `packages/spec/src/system/tracing.test.ts`: the accept set (six refusals + the accepting control), both blank spellings' published sentence and its exact `code`/`path`, the `ast`-only and bare-string cells, the **negative** (a non-`source` fault is not answered with the `source` sentence), and the `.describe()` the reference page renders. And `evaluated-slot-population.test.ts`'s published-sentence pin now runs all **three** refused spellings at all 36 positions instead of only the `ast`-only one — 108 cases, all green. That is what would have caught this slot in the first place. ### Not re-opened ⚠️ **`Clause-②` is now `yes`, re-declared by the seat under ruling A (batch #155 item 3, `5725503887`, maintainer 「同意」 2026-09-18T05:13Z).** The 28-input strict-subtype measurement is NOT overturned — the conclusion drawn from it is: 「a member replaced on a key line is a change to a published contract … the same review a narrowing owes **regardless of the tell**」. ⇒ a false tell was never the question; a narrowing owes the at-tier review on its own. `minor` + BREAKING banner + ADR-0087 disposition stay. `printCelAst`, the package-internal helper and the 36-position census stay. `packages/spec/api-surface/shared.json` and `export-origins/shared.json` are still **hash-identical to base** (`git hash-object`: `cf260910f1…` / `0429ff67a6…`), and `git diff --stat 00115a8..HEAD -- packages/spec/api-surface packages/spec/export-origins` is empty. ### Gates, re-derived on the rework head (⚠️ two figures superseded — see the note under it) `node scripts/pm/dispatch-gates.mjs --commands` on the merged head, every exit code recorded as it ran, reconciled with `--ran`: **110 derived, 104 run, 6 NOT MEASURED, 0 unrun** (`--ran` exit 0). `pnpm --filter @objectstack/spec build && test && typecheck` green — 486 files / 14016 tests; `@objectstack/formula` 30 files / 871 tests, typecheck green. `check:generated`: all 15 artifacts up to date after the `origin/main` merge and the final rebuild. ⚠️ **Two figures in the paragraph above are readings on an EARLIER head.** They are pinned here, ⛔ not restated as current and ⛔ not retyped. - The `110 derived, 104 run, 6 NOT MEASURED, 0 unrun` reconciliation was taken on `e892c86e271`. The head is now `34a63b9d583`. ⛔ It is not re-derived here — read it as the reading it was. - **`all 15 artifacts` is superseded.** #18971 registered a new generated-artifact family and the registered count is now **16**. Measured: the `GATED` array in `packages/spec/scripts/check-generated.ts` carries **16** entries at `70407326463d`, at `e892c86e271` and at `34a63b9d583` alike — with a dark control on a non-existent array name extracting 0 lines — so the `15` predates all three heads rather than describing any of them. The `15` is left above verbatim as the historical reading. - Current reading, on `34a63b9d583`: `check:generated` **exit 0 — all 16 generated artifacts up to date**, working tree clean. ### Base catch-up — `origin/main` merged, four deferred artifacts regenerated Merged `origin/main` `b14610255101` at `ab00016c221`, regenerated in `34a63b9d583`. The `os-regen` driver **deferred** on four routed both-sides paths — `api-surface-declarations/automation.txt`, `data.txt`, `ui.txt` and `content/docs/references/ui/component.mdx` — and ⭐ **a deferral silently keeps ONE side**: in the merge commit, main's token reads **0** on all four while the branch's side is intact. Main's side was restored and all four re-derived from the merged tree; on the head each path carries **both** counts, against a positive control that hits on every blob of every path (⛔ a control that fires on one shard certifies one shard). The two ⛔ MIXED paths are deliberately not routed to the driver and were hand-resolved. `packages/spec/src/migrations/registry.ts`: generated regions stripped, the hand-written remainder byte-identical across base, both sides and the merge, line counts exactly additive (17142 + 121 + 74 = 17337), and **both** sides' migration entries present by id. `packages/spec/src/ui/component.zod.ts`: additive text merge (3750 + 4 + 45 = 3799), its `.superRefine()` untouched. Refinement census over non-test `packages/spec/src`, counted by occurrence rather than by matching line: `.refine(` 59 base / 60 branch / 59 main / **60 head**; `.superRefine(` 80 at all four; `.check(` 4 at all four. ⛔ Nothing deleted, nothing weakened. Non-zero exits, all declared: - six **exit 3 · PREREQUISITE NOT MET** (`check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) — each refuses an unbuilt workspace closure and says so. NOT MEASURED, not findings; - `check:skill-examples` **exit 1** — the same class in exit-1 clothing (`packages/client-react/dist` holds no `.d.ts`); - `check:react-declaration-parity` **exit 1** — run as CI runs it, `MANIFEST="$PWD/sdui.manifest.json" … --strict`: `111 spec-only divergences, 1 blocks missing from the registry`. Control: the identical command at base `00115a8442` prints **the same two numbers**, so it is pre-existing and this PR moves neither. One gate went red on this round's own work and is fixed: `check:doc-authoring` refused an internal issue id in customer-facing spec text — the `(#15811)` this round put in the tracing `.describe()`. Removed, page regenerated, gate green; the same gate at base is green, so the id was the only offender. `node scripts/pm/check-clause2-carriers.mjs --pair 18638` — **exit 4**, and the dual-carrier row C1 is gone: only **C5** remains, with the same two false tells (`ui/action.zod.ts:833` T2, `ui/component.zod.ts:1595` T1). ⛔ Reported, not acted on; the matcher repair is #18640's. Rework round authored by the `domain:spec` execution seat, session `session_01LvwGppdonww4zGLWZo5rho`. Decision batch #122 item 2 generalised the evaluated-slot rule: `EvaluatedExpressionInputSchema` now composes into **every** slot an engine evaluates, while `ExpressionSchema` / `ExpressionInputSchema` stay the persistence contract (`source` OR `ast`) by item 2 of the same ruling. An `ast`-only envelope and a `source` that is blank after trimming — through the envelope key or the bare-string shorthand — are refused at the door instead of parsing, registering, and faulting at run time. ## The population was re-derived, not inherited The census in the card is six days old and `shared/expression.zod.ts` moved after the ruling, so the 36 figure was treated as a premise. Re-derived by **identity** on this branch's base `00115a8442` — a negative lookaround on identifier characters, because the bare substring also fires inside `CronExpressionInputSchema`, `TemplateExpressionInputSchema` and `EvaluatedExpressionInputSchema`, which is the trap that inflated triage's own reading on this card (32 files, five of them Cron-only): | reading | count | |---|---| | declaring source lines mounting the schema (non-test, non-comment) | 34 | | of those, file-local alias consts mounting 2 slots each | 2 | | **declaring positions** | **36** | | lit control — identity hits in the definition file | 7 | | the same file counted by bare SUBSTRING | 17 | | dark control — `ZzzNoSuchSchema` | 0 | That 7-versus-17 gap in one file is the trap itself, in miniature. Identical to the measured census (#15811 (comment)), position for position. Two aliases: `ui/action.zod.ts` `ActionConditionInputSchema` (mounts `visible` + `disabled`) and `system/settings-manifest.zod.ts` `SettingsVisibilityInputSchema` (mounts the specifier and manifest `visible`). Three positions reach the schema as a union member rather than head-of-declaration. `PredicateInputSchema` is a plain alias of `ExpressionInputSchema` with zero slot users; it stays wide with the schema it aliases. ## Two defects found while measuring, both fixed here **1. The narrowing was INERT at `TraceSamplingConfig.composite[].condition`.** That slot is `z.union([z.record(z.string(), z.unknown()), …])`, and a bare record arm accepts `{ dialect: 'cel', ast }` as an ordinary record — so swapping the other arm changed nothing. Measured: after the swap and before this fix the slot still answered `success: true` on the `ast`-only envelope, while its 35 siblings answered `false`. The structured-filter arm now declines an object carrying a `dialect` key, which is an expression attempt whatever it got wrong. Shipping the swap alone would have been a declared-but-unenforced narrowing. **2. Four positions refused with zod's bare `Invalid input`.** Where the declaration wraps the evaluated schema in a WIDER union — a boolean beside it on `action.visible` / `action.disabled` / `RecordAlertProps.visible`, a structured object beside it on `ServiceLevelIndicator.successCriteria` — the outer union reports `invalid_union` at the slot and the inner union's sentence never surfaces. `evaluatedExpressionUnionRefusal` gives those unions the published sentence. It is deliberately stricter than the inner map it complements: it answers only for a blank string or an object carrying `dialect`, so a malformed threshold object is not blamed on `source`. It lives in `shared/evaluated-slot-union.ts`, **package-internal** and absent from both barrels, on the `union-branch-policy` convention: a narrowing PR that grows the published export surface widens on a second axis, so `api-surface/` and `export-origins/` do not move for it. ## Item 3 — the printer path is real, and measured The ruling asked for the lossless direction 「where the dialect has a printer」 before falling back to a structured TODO. Measured rather than assumed: `@marcbachmann/cel-js` ships `serialize`, and `cel-engine.ts` already uses it for its own scope rewrites. So `@objectstack/formula` gains **`printCelAst(ast)`**, the inverse of the existing `parseCelToAst`, and the migration entry prescribes it by name instead of describing a capability nobody can call. Measured round-trip, six sources, each re-evaluated on the same scope: ``` record.amount > 10 -> identical bytes record.priority == 'urgent' -> record.priority == "urgent" 'org_admin' in current_user.positions -> "org_admin" in current_user.positions record.a == 1 && (record.b != 2 || record.c > 3)-> identical bytes size(record.tags) > 0 -> identical bytes ``` Lossless about MEANING, not bytes — the printer re-renders from the parse tree, so quote style normalises. Dark controls, all four throwing rather than inventing a source: `{}`, `null`, `{ type: 'nope' }` and a plain string each raise `Unknown AST operation`. `printCelAst` converts that into `null` and additionally requires the printed text to parse back through the platform's own bounded `parseCelToAst`, so it can never widen what this platform evaluates. Where the printer answers `null`, and for every blank `source`, the ADR-0087 D3 entry `evaluated-expression-slots-source-required` is the structured TODO — naming the object, the field and the slot, and splitting the judgment by fail policy, because removing a key is safe on the fail-soft half of the population and a silent disclosure on the fail-closed half. **Why this is a D3 entry and not a D2 conversion, now that a printer exists.** The conversion layer lives in `packages/spec`, which is dependency-free by Prime Directive #2 and carries no engine — `packages/formula/src/normalize.ts` states the same boundary from the other side. A conversion that had to call the CEL printer could not live where conversions live, and one that guessed without a printer would be the platform inventing a predicate. ## ⚠️ Deviation: graded `minor`, and the ruling said `major` Item 3 ordered a 「`major` changeset」. `scripts/check-changeset-no-major.mjs` forbids a `major` marker during the launch window, because the fixed group versions in lockstep and one `major` promotes all ~70 packages to a whole-stack major — which is a release act reserved to the maintainer. The guard's own header names the two carriers the convention uses instead, and both are present: the **BREAKING** banner in the changeset body and the ADR-0087 disposition line. The ruling's substance ships; only the marker differs, and it differs because a repo gate forbids the marker. Flagged rather than chosen silently. ## Item 4 — the mechanical acceptance surface #17630 is closed and its widening is live on this base: discovery in `packages/qa/dogfood/test/expression-conformance.test.ts` matches a roster name by identity anywhere on a line, attributes it to the `field:` it mounts, and resolves file-local aliases. Both `ExpressionInputSchema` and `EvaluatedExpressionInputSchema` are on that roster, so every one of the 36 positions stays discovered across the swap, the ledger's `file:Schema.field` cover keys are unchanged, and the `SCAN_CONTROLS` floors (head 37 / inline 3 / alias 2) are unaffected — the swap changes the identifier, never the syntactic shape. No ledger row's `failPolicy` moves: the column records what the EVALUATOR does with a bad expression, and no evaluator changed. ## Clause-② carrier readings, reported rather than acted on ⚠️ **Superseded in part by ruling A (`5725503887`), and the seat has since acted.** C5 below reads three widening tells against a `Clause-②: no` that no longer stands: the declaration is now `yes`, so the C5 tell no longer gates this PR and the at-tier review does. C1 (the split dual carrier) is also closed — the seat hung `needs:contract-review` on BOTH card #15811 and this PR at 2026-09-18T09:52:56Z / 09:52:58Z. ⛔ The matcher was NOT touched and no C-class licence card was opened; ruling A refuses both by name. The readings below are kept unedited as the record of what was measured at the time. `node scripts/pm/check-clause2-carriers.mjs --pair 18638` — **exit 4**, two rows at the time of writing (re-read on the rework head: C1 has cleared, C5 stands — see the rework section above). ⛔ Neither carrier is touched from here; this is the reading, not a verdict. - **C1 — the dual carrier is split.** `needs:contract-review` is on card #15811 and NOT on this PR. That is the state as found; the seat that owns the gate hangs or clears both sides in one stroke. - **C5 — three widening tells against `Clause-②: no`.** One was real and is gone: the new published export `evaluatedExpressionUnionRefusal` in `api-surface/shared.json`, removed by moving the helper package-internal (above), so the published surface is byte-unchanged by this PR. The remaining two are **false**, and both for the same reason — the matcher fires on an ADDED LINE that has the shape of a widening, and these two lines were added because an options object was appended to a union that gained no member: - `ui/action.zod.ts` `ActionConditionInputSchema` — read as T2 「a new member of a closed set」. The union has the same two members before and after; what is new on the line is `, { error: … }`. - `ui/component.zod.ts` `RecordAlertProps.visible` — read as T1 「a new key on a Zod object schema」. `visible` existed before this PR; the line moved for the same options object. Per the gate's own instruction a false tell is repaired in the matcher (`scripts/pm/check-widening-tells.mjs`, with a `--self-test` case pinning the shape) or filed as its own card. Repairing a `scripts/pm/**` matcher is outside this card's surface, so it is filed rather than done here — see the report's `out_of_scope_findings`. ## Tests `packages/spec/src/shared/evaluated-slot-population.test.ts` is the new pin, in two halves because either alone is a green that proves nothing: - **structural** — no declaring position in `packages/spec/src` still mounts the persistence schema on a code line, with a lit control (the scan does find the name in the definition file and the barrel), a dark control, and an explicit assertion that the Cron / Template / Evaluated siblings do not leak in as substrings; - **behavioural** — all 36 positions parsed AS MOUNTED, refusing all three refused spellings and carrying the one published sentence, plus an assertion that the table reached exactly 36 positions so a position that stops being reachable reds instead of silently leaving; - **controls** — `ExpressionSchema` / `ExpressionInputSchema` / `PredicateInputSchema` still ACCEPT both shapes, and a healthy predicate still parses at all 36 (the settings pair gets the predicate its own closed grammar accepts). `packages/formula/src/print-cel-ast.test.ts` pins the printer's two claims, including seven dark-control inputs. Three existing pins were rewritten rather than relaxed — each pinned exactly the arm this PR deletes: - `system/settings-manifest.test.ts` 「an `ast`-only envelope is opaque at this layer」 now pins the refusal, and asserts the settings GRAMMAR message is *not* the one raised, so the two refusals stay independent; - `ui/action.test.ts` 「rejects composition with an AST-only visible loudly (ADR-0078)」 — ADR-0078's promise is unchanged, but the refusal moved from the `requiresFeature` lowering to the slot, so it now holds with **and without** the flag. A second case pins that #17631's shape (a blank `source` composed into `( ) && features.admin == true`) can no longer reach the lowering at all; - `ui/view-form-features-root.test.ts` 「documented boundary」 now asserts the refusal comes from the evaluated-slot rule and not from the features-root scanner this file is about. **Repo census for the migration:** zero authored occurrences of either refused spelling outside `packages/spec`'s own refusal fixtures, across `packages/`, `examples/`, `content/` and `skills/`, against a lit control that hits. Nothing in this repository needs rewriting. ## Acceptance notes - `PredicateInputSchema` (`shared/expression.zod.ts`) remains a plain value alias of `ExpressionInputSchema` with zero slot users. Left wide deliberately — it aliases the persistence contract. Noted, not filed; carrier is the ledger's own limit 2, already written up there. - `celEngine.evaluate` on `{ dialect: 'cel', source: '' }` answers with the AST-only message rather than an empty-source one. Message accuracy only; the verdict is correct. Unchanged here, still uncarried. Authored by the `domain:spec` execution seat, session `session_01LvwGppdonww4zGLWZo5rho`, under the dispatch claim #15811 (comment). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d98342 commit ce57857

59 files changed

Lines changed: 6028 additions & 4880 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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

Comments
 (0)