From f91344148bff73a550ca983780626b5e5f3a39d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:05:30 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec)!:=20an=20evaluated=20expression?= =?UTF-8?q?=20slot=20requires=20a=20non-blank=20source=20=E2=80=94=20Evalu?= =?UTF-8?q?atedExpressionSchema,=20composed=20by=20the=20assignment=20valu?= =?UTF-8?q?e=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- ...aluated-expression-slot-requires-source.md | 57 ++++++++++ .../lint/src/validate-expressions.test.ts | 33 ++++-- .../builtin/assignment-value-envelope.test.ts | 96 +++++++++++------ .../automation/builtin-node-config.test.ts | 68 +++++++++++- .../src/automation/builtin-node-config.zod.ts | 34 +++--- packages/spec/src/index.ts | 3 + .../src/shared/evaluated-expression.test.ts | 100 ++++++++++++++++++ packages/spec/src/shared/expression.zod.ts | 58 ++++++++++ 8 files changed, 387 insertions(+), 62 deletions(-) create mode 100644 .changeset/evaluated-expression-slot-requires-source.md create mode 100644 packages/spec/src/shared/evaluated-expression.test.ts diff --git a/.changeset/evaluated-expression-slot-requires-source.md b/.changeset/evaluated-expression-slot-requires-source.md new file mode 100644 index 0000000000..606b5482b6 --- /dev/null +++ b/.changeset/evaluated-expression-slot-requires-source.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430) + + + +**BREAKING** in the accept-set sense, landing in the launch window as `minor` +(the lockstep convention): on the schemas that type an EVALUATED expression +slot — today the `assignment` node's value envelope, +`AssignmentExpressionValueSchema` — an envelope with no `source` the engine can +evaluate is now **refused at authoring**, where it used to parse, register, +pass `objectstack validate`, and then fault at run time. + +Two spellings of one seam, refused by ONE rule with one message at `source` +(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`): + +```yaml +assignments: + digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it + greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF +``` + +> An expression in an evaluated slot needs a non-blank `source`: the expression +> engine evaluates `source` (the canonical persisted form of phase M9.1) and +> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` +> that is blank after trimming, would validate and register and then fault at +> run time. Write `{ dialect: 'cel', source: '…' }`. + +- **`ExpressionSchema` is NOT narrowed.** It is the persistence contract — + `source` OR `ast` — and its docblock declares that `ast` becomes required in + build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and + its type `EvaluatedExpression`) is a sibling: the same envelope with `source` + required and non-blank, spelled once and composed by every evaluated slot, so + when AST-only evaluation lands the flip is one edit there rather than a + per-slot unwinding. The rule is worded as "an evaluated slot requires whatever + the engine can actually evaluate"; what that is today is `source`. +- **The notion of blank is the engine's own** — `.trim()`, which + `cel-engine.ts`'s helpers already apply — not a third one beside the shape + rule's `min(1)` and `validateExpression`'s trim. +- **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate` + and the runtime publish gate report a located `error` at the author's own + variable (`config.assignments..source`), and the executor's own shape + pass refuses the same set — all through the spec schema, so none of them + grew a rule of its own. + +**FROM → TO.** An assignment value that carried only `ast` has no evaluable +form to migrate to under M9.1: author its `source`. A whitespace-only `source` +was never an expression: delete the entry, or write the expression. Every +envelope with a non-blank `source` is unchanged. + +Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s +silent `false` on an envelope without a `source` — is a behaviour change on a +live path with its own card, and the edge-condition schema that carries that +envelope is narrowed in a follow-up once the in-flight change to +`automation/flow.zod.ts` lands. diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 8a3544333d..8f0b6b089e 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; // argument for the allowlist, and a hand-copied list would go green on exactly // the root the rule never saw. import { SCOPE_ROOTS } from '@objectstack/formula'; -import { ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec'; +import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec'; import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data'; import { SharingRuleSchema } from '@objectstack/spec/security'; // [#15137] The published refusal sentence a `value`-slot finding must lead @@ -3729,6 +3729,9 @@ describe('assignment value envelope — located findings (#15137)', () => { it.each([ ['no `source` — the shape only the spec schema catches', { dialect: 'cel' }], ['an empty `source`', { dialect: 'cel', source: '' }], + // #15430 — an `ast`-only envelope is a valid `ExpressionSchema` that no + // engine evaluates; the spec's evaluated-slot rule refuses it at `source`. + ['an `ast`-only envelope — no `source` the engine can evaluate', { dialect: 'cel', ast: { kind: 'const' } }], ['a non-`cel` dialect', { dialect: 'template', source: 'Hello {name}' }], ['CEL that does not parse', { dialect: 'cel', source: 'rows.map(r,' }], ['an unknown function', { dialect: 'cel', source: 'nosuchfn(rows)' }], @@ -3755,16 +3758,24 @@ describe('assignment value envelope — located findings (#15137)', () => { })).toHaveLength(0); }); - it('is silent on a whitespace-only `source` — the seam this pass cannot see (#15430)', () => { - // `ExpressionSchema.source` is `z.string().min(1)`, so `' '` passes the - // shape rule, and `validateExpression` trims it to empty and answers - // `ok: true` ("not authored"). Build says nothing; the CEL engine parses it - // untrimmed and the run faults loudly (pinned in `service-automation`'s - // `assignment-value-envelope.test.ts`). Pinned as the BOUND of the - // build/run agreement, not as desired behaviour — ⛔ do not close it with a - // trim rule invented here: that is a third notion of "malformed", which is - // the defect this arm exists to avoid. The fix belongs in the shape rule. - expect(valueIssues({ digest: { dialect: 'cel', source: ' ' } })).toHaveLength(0); + it('reports a whitespace-only `source` — the seam is now visible through the shape pass (#15430)', () => { + // FLIPPED. This used to pin the seam this pass could NOT see: `' '` + // passed `ExpressionSchema.source`'s `min(1)`, `validateExpression` trimmed + // it to empty and answered `ok: true` ("not authored"), and only the run + // faulted. The file said the fix belonged in the shape rule, not in a trim + // rule invented here — and it landed there: `AssignmentExpressionValueSchema` + // now composes the spec's `EvaluatedExpressionSchema`, so the object-level + // shape pass this arm already runs (`AssignmentValueSchema.safeParse`) + // refuses it, located at the author's variable, led by the published + // sentence and carrying the evaluated-slot rule's own — measured here, not + // assumed: nothing in this pass changed, the refusal arrives through the + // spec dependency. + const issues = valueIssues({ digest: { dialect: 'cel', source: ' ' } }); + expect(issues).toHaveLength(1); + expect(issues[0]!.severity).toBe('error'); + expect(issues[0]!.where).toContain('config.assignments.digest'); + expect(issues[0]!.message.startsWith(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL)).toBe(true); + expect(issues[0]!.message).toContain('`source`: ' + EVALUATED_EXPRESSION_SOURCE_REQUIRED); }); it('says nothing about the legacy array form — it is not a declared slot', () => { diff --git a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts index 4c2d67365c..c2a194cf4f 100644 --- a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts @@ -38,9 +38,21 @@ import { isExpressionEnvelopeShaped, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, } from '@objectstack/spec/automation'; +import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; +import { ExpressionEngine } from '@objectstack/formula'; import { AutomationEngine } from '../engine.js'; import { registerLogicNodes } from './logic-nodes.js'; +/** The thrown error, so a pin can assert its message substance rather than `toThrow()` alone. */ +function catchError(fn: () => unknown): Error { + try { + fn(); + } catch (error) { + return error as Error; + } + throw new Error('expected the call to throw'); +} + function createTestLogger() { return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, @@ -80,6 +92,11 @@ const MALFORMED: ReadonlyArray<{ label: string; envelope: Record expect(result.error).toContain('rows.map(r, r.subject)'); }); - it('an `ast`-only envelope faults with the engine\'s own prescription, not a wrong value', () => { - // `ExpressionSchema` accepts `source`-or-`ast`, so this passes both - // validators; the CEL engine evaluates `source` only. Refused loudly at the - // one layer that can see it. (Reported upward as a spec-lane finding — the - // contract accepts a shape no engine evaluates.) - expect(() => engine.evaluateValueEnvelope({ dialect: 'cel', ast: { op: 'value' } }, new Map(), 'assignments.digest')) - .toThrow(/AST-only evaluation not yet supported/); + it('an `ast`-only envelope is refused at the shape door with the spec\'s own sentence — the engine\'s fault is no longer reachable (#15430)', () => { + // FLIPPED. `ExpressionSchema` accepts `source`-or-`ast`, but this slot is + // EVALUATED: `AssignmentExpressionValueSchema` composes the spec's + // `EvaluatedExpressionSchema`, which requires a non-blank `source` — what + // the CEL engine actually evaluates. So the shape half refuses it, located + // at `source`, and `evaluateValueEnvelope` (which runs that same shape + // pass first) never hands it to the engine. The engine's own arm — "AST-only + // evaluation not yet supported; persist `source`" — is reachable only by + // calling `ExpressionEngine.evaluate` directly, bypassing every schema; + // pinned below as defence in depth, not as a path metadata can take. + const error = catchError(() => engine.evaluateValueEnvelope({ dialect: 'cel', ast: { op: 'value' } }, new Map(), 'assignments.digest')); + expect(error.message).toContain('assignments.digest'); + expect(error.message).toContain(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL); + expect(error.message).toContain('`source`: ' + EVALUATED_EXPRESSION_SOURCE_REQUIRED); + expect(error.message).not.toContain('AST-only evaluation not yet supported'); + }); + + it('defence in depth — the engine itself, reached with no schema in front of it, still refuses an `ast`-only envelope', () => { + // Not a path authored metadata can take (both validators and the executor + // run the schema first); kept so the last layer never silently answers a + // value if a future caller bypasses the door. + const result = ExpressionEngine.evaluate({ dialect: 'cel', ast: { op: 'value' } }, {} as never); + expect(result.ok).toBe(false); + expect(result.error?.message).toContain('AST-only evaluation not yet supported'); }); /** - * The BOUND of the property above, pinned rather than papered over (found by - * the #15137 contract review). - * - * A whitespace-only `source` REGISTERS — `ExpressionSchema.source` is - * `z.string().min(1)`, so `' '` passes the shape rule, and - * `validateExpression` trims it to empty and answers `ok: true` ("not - * authored") — and then faults at run time, because the CEL engine parses the - * string untrimmed. + * FLIPPED by #15430 — this used to pin the BOUND of the property: a + * whitespace-only `source` passed `ExpressionSchema.source`'s `min(1)`, + * `validateExpression` trimmed it to empty and answered `ok: true` ("not + * authored"), the flow REGISTERED, and the run faulted because the CEL engine + * parses the string untrimmed. The file said the fix did not belong here (a + * trim rule of the engine's own would be a third notion of "malformed"), but + * where the shape rule is declared — and that is where it landed: the spec's + * `EvaluatedExpressionSchema` requires a non-blank `source` on an evaluated + * slot, with the engine's own notion of blank (`.trim()`). * - * So the true statement of the property is narrower than "a registered flow - * can never fault": registration and evaluation refuse the same set THE TWO - * PUBLISHED VALIDATORS DEFINE, and the shapes both of them accept while no - * engine can run them fault loudly at run time rather than assigning a wrong - * value. Same seam class as the `ast`-only case above, tracked in the same - * finding (#15430). - * - * ⛔ Not fixable here by adding a trim rule of the engine's own: a third, - * locally-invented notion of "malformed" is exactly what this file exists to - * prevent. The fix belongs where the shape rule is declared. + * So the property is now the unqualified one: registration and evaluation + * refuse the same set, and that set includes every shape no engine can run. + * The refusal is the SHAPE half's, at the value's own `source`, led by the + * published sentence — and the same call refuses it at run time (the + * `it.each(MALFORMED)` pin above holds both halves for this envelope too). */ - it('a whitespace-only `source` registers and then faults loudly — the bound of the property (#15430)', async () => { + it('a whitespace-only `source` is refused at registration by the schema, located at the value\'s `source` (#15430)', () => { const flow = assignmentFlow({ assignments: { digest: { dialect: 'cel', source: ' ' } } }); - expect(() => engine.registerFlow('assign_flow', flow), 'both validators accept it').not.toThrow(); - - const result = await engine.execute('assign_flow', {} as any); - expect(result.success).toBe(false); - // Loud, located and carrying the source — never a silent `undefined` in the - // variable, which is the property that does hold for every shape. - expect(result.error).toContain('assignments.digest'); - expect(result.error).toContain('failed to evaluate as CEL'); + const error = catchError(() => engine.registerFlow('assign_flow', flow)); + expect(error.message).toContain(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL); + expect(error.message).toContain('`source`: ' + EVALUATED_EXPRESSION_SOURCE_REQUIRED); + expect(error.message).toContain('assignments.digest'); + // Not the engine's parse fault any more — the run never happens. + expect(error.message).not.toContain('failed to evaluate as CEL'); }); }); diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index 7e8133c890..0bfab49209 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -32,6 +32,7 @@ import { ScreenFieldConfigSchema, UpdateRecordConfigSchema, } from './builtin-node-config.zod.js'; +import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionSchema } from '../shared/expression.zod.js'; import { LEDGER_DECLARED_NODE_CONFIG_SCHEMAS, SCHEMALESS_NODE_CONFIG_SCHEMAS, @@ -328,8 +329,11 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol }); it.each([ - ['no `source` (and no `ast`)', { dialect: 'cel' }, ['assignments', 'digest'], 'Expression requires at least one of'], - ['an empty `source`', { dialect: 'cel', source: '' }, ['assignments', 'digest', 'source'], ''], + // Flipped by #15430: `{ dialect: 'cel' }` used to be refused by the + // persistence contract's source-or-ast rule at the envelope's own path; the + // evaluated-slot rule now answers first, at `source`, with its one sentence. + ['no `source` (and no `ast`)', { dialect: 'cel' }, ['assignments', 'digest', 'source'], 'non-blank `source`'], + ['an empty `source`', { dialect: 'cel', source: '' }, ['assignments', 'digest', 'source'], 'non-blank `source`'], ['a non-string `source`', { dialect: 'cel', source: 42 }, ['assignments', 'digest', 'source'], ''], ['an unknown dialect', { dialect: 'javascript', source: '1 + 1' }, ['assignments', 'digest', 'dialect'], ''], ] as ReadonlyArray<[string, unknown, ReadonlyArray, string]>)( @@ -360,7 +364,9 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol assignments: { fine: DIGEST_ENVELOPE, broken: { dialect: 'cel' }, alsoFine: '{x}' }, }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['assignments.broken']); + // `{ dialect: 'cel' }` is refused by the evaluated-slot rule at its `source` + // (#15430) — one segment deeper than the envelope, still naming the variable. + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['assignments.broken.source']); }); it('refuses the legacy array form as a type error carrying the map as the prescription', () => { @@ -399,3 +405,59 @@ describe('assignment value contract — a CEL envelope beside `{token}` interpol expect(projected.properties?.assignments?.additionalProperties?.xExpression).toBe('value'); }); }); + +/** + * The `value` slot is EVALUATED (#15430): `AssignmentExpressionValueSchema` + * composes `EvaluatedExpressionSchema`, so the two shapes the persistence + * contract accepts while no engine can run them — an `ast`-only envelope and a + * whitespace-only `source` — are refused at authoring, by ONE rule with one + * message, at the value's own `source` path. Before this they validated, + * registered, and faulted at run time (the `ast`-only one with the engine's + * own prescription; the blank one with a parse error on `" "`). + */ +describe('assignment value envelope — an evaluated slot requires what the engine can evaluate (#15430)', () => { + const AST_ONLY = { dialect: 'cel', ast: { kind: 'const', value: 1 } }; + const BLANK_SOURCE = { dialect: 'cel', source: ' ' }; + + /** Every issue beneath `assignments.`, flattened to what a pin asserts. */ + function issuesAt(key: string, value: unknown) { + const result = AssignmentConfigSchema.safeParse({ assignments: { [key]: value } }); + if (result.success) return []; + return result.error!.issues + .filter((i) => i.path[0] === 'assignments' && i.path[1] === key) + .map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message })); + } + + it.each([ + ['an `ast`-only envelope', AST_ONLY], + ['a whitespace-only `source`', BLANK_SOURCE], + ])('REFUSES %s through the node config: ONE issue, code `custom`, at the value\'s `source`, refusal sentence first', (_label, value) => { + const issues = issuesAt('digest', value); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe('assignments.digest.source'); + expect(issues[0]!.message.startsWith(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL)).toBe(true); + expect(issues[0]!.message).toContain(EVALUATED_EXPRESSION_SOURCE_REQUIRED); + }); + + it('REFUSES both spellings on the narrowed schema directly, with the same sentence at `source`', () => { + for (const value of [AST_ONLY, BLANK_SOURCE]) { + const result = AssignmentExpressionValueSchema.safeParse(value); + expect(result.success).toBe(false); + expect(result.error!.issues).toHaveLength(1); + expect(result.error!.issues[0]!.path).toEqual(['source']); + expect(result.error!.issues[0]!.message).toBe(EVALUATED_EXPRESSION_SOURCE_REQUIRED); + } + }); + + it('ACCEPTS a well-formed envelope unchanged — `source` present, even beside an `ast`', () => { + const good = { dialect: 'cel', source: 'a > 1' }; + expect(issuesAt('digest', good)).toEqual([]); + expect(AssignmentExpressionValueSchema.safeParse({ ...good, ast: { kind: 'x' } }).success).toBe(true); + }); + + it('CONTROL — `ExpressionSchema`, the persistence contract, still ACCEPTS both shapes', () => { + expect(ExpressionSchema.safeParse(AST_ONLY).success).toBe(true); + expect(ExpressionSchema.safeParse(BLANK_SOURCE).success).toBe(true); + }); +}); diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 43fddd26c0..88381ff6f7 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -77,7 +77,7 @@ */ import { z } from 'zod'; -import { ExpressionSchema } from '../shared/expression.zod'; +import { EvaluatedExpressionSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { isExpressionEnvelopeShaped } from './flow-node-expression-paths'; @@ -540,15 +540,20 @@ export const ASSIGNMENT_VALUE_ENVELOPE_REFUSAL = * * The envelope is the spelling `shared/expression.zod.ts` already defines and * `validateExpression` already reads — not a second one: `ExpressionSchema` - * itself, `safeExtend`ed (the form Zod reserves for a refined object, keeping - * its `source`-or-`ast` rule) with the one key narrowed, at the type level too - * (`dialect: 'cel'`, so a `template` envelope is a compile error before it is - * a parse error). `validateExpression('value', …)` refuses a `template` or - * `cron` envelope in a value slot ("expected a CEL expression but got a … - * dialect"), so the contract refuses it here, at authoring, with the same - * verdict — and refuses the one shape that validator lets through: an envelope - * with no `source` reads as "not authored" there (`ok: true`), so this parse - * is the only gate that catches `{ dialect: 'cel' }` before it is stored. + * in its EVALUATED form (`EvaluatedExpressionSchema` — this slot's value is + * run by the expression engine, so `source` is required and non-blank, the + * one rule both spellings of that seam are refused by), `safeExtend`ed (the + * form Zod reserves for a refined object, keeping its rules) with the one + * further key narrowed, at the type level too (`dialect: 'cel'`, so a + * `template` envelope is a compile error before it is a parse error). + * `validateExpression('value', …)` refuses a `template` or `cron` envelope in + * a value slot ("expected a CEL expression but got a … dialect"), so the + * contract refuses it here, at authoring, with the same verdict — and refuses + * the shapes that validator lets through: an envelope with no `source` + * (`{ dialect: 'cel' }` and the `ast`-only envelope alike) reads as "not + * authored" there (`ok: true`), and a whitespace-only `source` trims to the + * same answer while the engine parses it untrimmed and faults. This parse is + * the gate that catches every one of them before it is stored. * * A bare string is deliberately NOT accepted as CEL shorthand the way * `ExpressionInputSchema` accepts it elsewhere: in an assignment value a plain @@ -556,7 +561,7 @@ export const ASSIGNMENT_VALUE_ENVELOPE_REFUSAL = * kept. The envelope is the only CEL spelling in this slot — which is exactly * what lets the two forms coexist without a mode switch. */ -export const AssignmentExpressionValueSchema = ExpressionSchema +export const AssignmentExpressionValueSchema = EvaluatedExpressionSchema .safeExtend({ dialect: z.literal('cel', { error: () => @@ -592,9 +597,10 @@ export type AssignmentExpressionValueParsed = z.infer 1' }; + +function issuesOf(value: unknown) { + const result = EvaluatedExpressionSchema.safeParse(value); + return result.success ? [] : result.error.issues.map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message })); +} + +describe('EvaluatedExpressionSchema — an evaluated slot requires a non-blank `source` (#15430)', () => { + it('REFUSES an `ast`-only envelope: one issue, at `source`, the published sentence', () => { + expect(issuesOf(AST_ONLY)).toEqual([ + { code: 'invalid_type', path: 'source', message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }, + ]); + }); + + it.each([ + ['three spaces', ' '], + ['a tab and a newline', '\t\n'], + ['the empty string', ''], + ])('REFUSES a `source` that is blank after trimming (%s): one issue, at `source`, the same sentence', (_label, source) => { + expect(issuesOf({ dialect: 'cel', source })).toEqual([ + { code: 'custom', path: 'source', message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }, + ]); + }); + + it('ONE rule — both spellings carry the same message, and the message names what the engine needs and why', () => { + const [astIssue] = issuesOf(AST_ONLY); + const [blankIssue] = issuesOf(BLANK_SOURCE); + expect(astIssue!.message).toBe(blankIssue!.message); + expect(EVALUATED_EXPRESSION_SOURCE_REQUIRED).toContain('non-blank `source`'); + expect(EVALUATED_EXPRESSION_SOURCE_REQUIRED).toContain('cannot evaluate `ast` alone'); + expect(EVALUATED_EXPRESSION_SOURCE_REQUIRED).toContain("{ dialect: 'cel', source: '…' }"); + }); + + it('the notion of blank is the engine\'s own — `.trim()` — so a source with inner whitespace is authored', () => { + // A source that trims to something is a source; the engine trims the same way. + expect(EvaluatedExpressionSchema.safeParse({ dialect: 'cel', source: ' record.amount > 1 ' }).success).toBe(true); + expect(EvaluatedExpressionSchema.safeParse({ dialect: 'cel', source: '\n1\n' }).success).toBe(true); + }); + + it('ACCEPTS a well-formed envelope unchanged — `source` plus `ast`, `meta`, every dialect', () => { + const parsed = EvaluatedExpressionSchema.safeParse(GOOD); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(GOOD); + // `ast` beside a source is fine: the engine still has what it evaluates. + expect(EvaluatedExpressionSchema.safeParse({ ...GOOD, ast: { kind: 'x' }, meta: { rationale: 'r' } }).success).toBe(true); + for (const dialect of ['cron', 'template']) { + expect(EvaluatedExpressionSchema.safeParse({ dialect, source: 'x' }).success).toBe(true); + } + }); + + it('keeps `ExpressionSchema`\'s own rules — a dialect outside the enum is still refused there', () => { + const issues = issuesOf({ dialect: 'js', source: '1 + 1' }); + expect(issues.map((i) => i.path)).toEqual(['dialect']); + }); + + it('CONTROL — `ExpressionSchema`, the persistence contract, still ACCEPTS both shapes (it is not narrowed)', () => { + expect(ExpressionSchema.safeParse(AST_ONLY).success).toBe(true); + expect(ExpressionSchema.safeParse(BLANK_SOURCE).success).toBe(true); + // And still refuses what it always refused: neither `source` nor `ast`. + expect(ExpressionSchema.safeParse({ dialect: 'cel' }).success).toBe(false); + }); + + it('narrows the TYPE too: `source` is required on `EvaluatedExpression`, so an `ast`-only envelope is a compile error', () => { + const ok: EvaluatedExpression = { dialect: 'cel', source: 'x' }; + // @ts-expect-error — `source` is required in an evaluated slot; `ast` alone is not evaluable. + const astOnly: EvaluatedExpression = { dialect: 'cel', ast: {} }; + expect([ok, astOnly]).toHaveLength(2); + }); +}); diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 66e8875cb1..b7c3d4d62e 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -89,6 +89,64 @@ export const ExpressionSchema = z.object({ }); export type Expression = z.input; +/** + * The one sentence an EVALUATED slot refuses a non-evaluable envelope with — + * the same words for both spellings of the seam (an `ast`-only envelope, a + * `source` that is blank after trimming), so an author learns the rule before + * the detail. Names what the engine needs and why, so the prescription travels + * with the refusal. + */ +export const EVALUATED_EXPRESSION_SOURCE_REQUIRED = + 'An expression in an evaluated slot needs a non-blank `source`: the expression engine evaluates `source` ' + + '(the canonical persisted form of phase M9.1) and cannot evaluate `ast` alone, so an envelope carrying only ' + + '`ast`, or a `source` that is blank after trimming, would validate and register and then fault at run time. ' + + 'Write `{ dialect: \'cel\', source: \'…\' }`.'; + +/** + * An {@link ExpressionSchema} envelope in an EVALUATED slot — a slot whose + * value the expression engine runs (a flow `value` slot, a predicate the + * engine gates on), as opposed to a slot that only PERSISTS the envelope. + * + * The rule is "an evaluated slot requires whatever the engine can actually + * evaluate", and this schema spells out what that is TODAY: the CEL engine + * evaluates `source` and refuses an envelope without one (`cel-engine.ts` + * `evaluate`: "AST-only evaluation not yet supported; persist `source`" — its + * own note revisits AST execution when the spec persistence cuts over). So + * `source` is required here, and required to be non-blank after trimming — + * the notion of blank the engine's own helpers apply (`source.trim()`), not a + * third one. Two spellings of one seam are refused by this ONE rule, with one + * message at `source`: `{ dialect: 'cel', ast }` (a valid Expression that no + * engine runs) and `{ dialect: 'cel', source: ' ' }` (passes `min(1)`; + * reads as "not authored" to `validateExpression`, which trims; the engine + * parses it untrimmed and faults). + * + * `ExpressionSchema` itself is NOT narrowed: it is the persistence contract, + * and its docblock declares that `ast` becomes required in build output at + * phase M9.2. When AST-only evaluation lands, this schema is the one place to + * revisit — relax `source` and require "`source` or `ast`, whichever the + * engine evaluates" — and every evaluated slot composes it, so that flip is + * one edit rather than a per-slot unwinding. + * + * Spelled as a property override rather than an object-level `.refine`, for a + * measured reason: Zod runs an object's refinements even after a property has + * failed (a `.refine` sibling reports `source: ''` twice — `min(1)` and the + * refinement — and `{ dialect: 'cel' }` twice — the source-or-ast rule and the + * refinement), while an aborting property issue skips them. This spelling + * yields exactly one issue, this message, at `source`, for every refused + * shape, and narrows the TYPE too (`source: string`) — the same move + * `AssignmentExpressionValueSchema` makes for `dialect`, so an `ast`-only + * envelope is a compile error before it is a parse error. + */ +export const EvaluatedExpressionSchema = ExpressionSchema.safeExtend({ + /** + * Surface syntax — required and non-blank in an evaluated slot: it is what + * the engine evaluates (M9.1), and `ast` alone cannot be run. + */ + source: z.string({ error: () => EVALUATED_EXPRESSION_SOURCE_REQUIRED }) + .refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }), +}); +export type EvaluatedExpression = z.input; + /** * Author-time input shape: a bare string is shorthand for `{ dialect: 'cel', * source }`. Engines that need other dialects must use the full envelope. From 5eb7c5738ab6d5a506e7905e9fbfc88897a817cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:14:54 +0000 Subject: [PATCH 2/5] chore(spec): regenerate api-surface, export-origins, declaration-map and docs for EvaluatedExpressionSchema Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .../automation/builtin-node-config.mdx | 2 +- content/docs/references/index.mdx | 10 +++++----- content/docs/references/shared/expression.mdx | 18 ++++++++++++++++-- packages/spec/api-surface/root.json | 3 +++ packages/spec/api-surface/shared.json | 3 +++ packages/spec/authorable-surface/shared.json | 4 ++++ packages/spec/declaration-map/shared.json | 2 ++ packages/spec/export-origins/root.json | 3 +++ packages/spec/export-origins/shared.json | 3 +++ packages/spec/json-schema.manifest/shared.json | 1 + 10 files changed, 41 insertions(+), 8 deletions(-) diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index c700aa2556..497748f8a8 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -113,7 +113,7 @@ CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **dialect** | `'cel'` | ✅ | | -| **source** | `string` | optional | | +| **source** | `string` | ✅ | | | **ast** | `any` | optional | | | **meta** | `{ rationale?: string; generatedBy?: string }` | optional | | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 153d797bc5..7f93854e16 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -29,11 +29,11 @@ counts are sums of the rows they head. Regenerate with | [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. | -| [Shared Protocol](/docs/references/shared) | 8 | 26 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | +| [Shared Protocol](/docs/references/shared) | 8 | 27 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1589** | 14 protocol modules | +| **Total** | **200** | **1590** | 14 protocol modules | --- @@ -286,14 +286,14 @@ Permission sets, row-level security, sharing rules, tenancy posture. ## Shared Protocol -**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 26 schemas** +**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 27 schemas** Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | File | Schemas | | :--- | :--- | | [`enums.zod.ts`](/docs/references/shared/enums) | `IsolationLevelEnum`, `MutationEventEnum`, `SortDirectionEnum`, `SortItem` | -| [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` | +| [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `EvaluatedExpression`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` | | [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpMethodSubset`, `HttpRequest`, `RateLimitConfig`, `StaticMount` | | [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `MetadataItemName`, `SnakeCaseIdentifier`, `SystemIdentifier` | | [`mapping.zod.ts`](/docs/references/shared/mapping) | `FieldMapping` | diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 932b4103c5..21209058fd 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -44,8 +44,8 @@ See also: content/docs/concepts/north-star.mdx §8 "No private expression DSL" ## TypeScript Usage ```typescript -import { CronExpressionInputSchema, ExpressionSchema, ExpressionDialect, ExpressionInputSchema, ExpressionMetaSchema, PredicateSchema, PredicateInputSchema, TemplateExpressionInputSchema } from '@objectstack/spec/shared'; -import type { CronExpressionInput, Expression, ExpressionDialect, ExpressionInput, ExpressionMeta, Predicate, PredicateInput, TemplateExpressionInput } from '@objectstack/spec/shared'; +import { CronExpressionInputSchema, EvaluatedExpressionSchema, ExpressionSchema, ExpressionDialect, ExpressionInputSchema, ExpressionMetaSchema, PredicateSchema, PredicateInputSchema, TemplateExpressionInputSchema } from '@objectstack/spec/shared'; +import type { CronExpressionInput, EvaluatedExpression, Expression, ExpressionDialect, ExpressionInput, ExpressionMeta, Predicate, PredicateInput, TemplateExpressionInput } from '@objectstack/spec/shared'; // Validate data const result = CronExpressionInputSchema.parse(data); @@ -79,6 +79,20 @@ Type: `string` --- +--- + +## EvaluatedExpression + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **dialect** | `Enum<'cel' \| 'cron' \| 'template'>` | ✅ | | +| **source** | `string` | ✅ | | +| **ast** | `any` | optional | | +| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | | + + --- ## Expression diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 9dc62d7d5d..0df5561e76 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -56,9 +56,12 @@ "DatasourceMappingRule (type)", "DatasourceMappingRuleSchema (const)", "DefineStackOptions (interface)", + "EVALUATED_EXPRESSION_SOURCE_REQUIRED (const)", "EVERYONE_POSITION (const)", "EvalUser (type)", "EvalUserSchema (const)", + "EvaluatedExpression (type)", + "EvaluatedExpressionSchema (const)", "ExpandViewResult (interface)", "ExpandedViewItem (interface)", "Expression (type)", diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index d3d56924f4..9e9ada35e4 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -10,8 +10,11 @@ "CorsConfigSchema (const)", "CronExpressionInput (type)", "CronExpressionInputSchema (const)", + "EVALUATED_EXPRESSION_SOURCE_REQUIRED (const)", "EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS (const)", + "EvaluatedExpression (type)", + "EvaluatedExpressionSchema (const)", "Expression (type)", "ExpressionDialect (type)", "ExpressionInput (type)", diff --git a/packages/spec/authorable-surface/shared.json b/packages/spec/authorable-surface/shared.json index bfbb05d77f..a857c20659 100644 --- a/packages/spec/authorable-surface/shared.json +++ b/packages/spec/authorable-surface/shared.json @@ -11,6 +11,10 @@ "shared/CorsConfig:maxAge", "shared/CorsConfig:methods", "shared/CorsConfig:origins", + "shared/EvaluatedExpression:ast", + "shared/EvaluatedExpression:dialect", + "shared/EvaluatedExpression:meta", + "shared/EvaluatedExpression:source", "shared/Expression:ast", "shared/Expression:dialect", "shared/Expression:meta", diff --git a/packages/spec/declaration-map/shared.json b/packages/spec/declaration-map/shared.json index 6b2c3b7f71..97097e5859 100644 --- a/packages/spec/declaration-map/shared.json +++ b/packages/spec/declaration-map/shared.json @@ -8,6 +8,8 @@ "CorsConfigSchema": "shared/CorsConfig", "CronExpressionInput": "shared/CronExpressionInput", "CronExpressionInputSchema": "shared/CronExpressionInput", + "EvaluatedExpression": "shared/EvaluatedExpression", + "EvaluatedExpressionSchema": "shared/EvaluatedExpression", "Expression": "shared/Expression", "ExpressionDialect": "shared/ExpressionDialect", "ExpressionInput": "shared/ExpressionInput", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 53cc05063c..66c1a7babb 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -56,9 +56,12 @@ "DatasourceMappingRule": "src/stack.zod.ts#DatasourceMappingRule (type)", "DatasourceMappingRuleSchema": "src/stack.zod.ts#DatasourceMappingRuleSchema (const)", "DefineStackOptions": "src/stack.zod.ts#DefineStackOptions (interface)", + "EVALUATED_EXPRESSION_SOURCE_REQUIRED": "src/shared/expression.zod.ts#EVALUATED_EXPRESSION_SOURCE_REQUIRED (const)", "EVERYONE_POSITION": "src/identity/position.zod.ts#EVERYONE_POSITION (const)", "EvalUser": "src/identity/eval-user.zod.ts#EvalUser (type)", "EvalUserSchema": "src/identity/eval-user.zod.ts#EvalUserSchema (const)", + "EvaluatedExpression": "src/shared/expression.zod.ts#EvaluatedExpression (type)", + "EvaluatedExpressionSchema": "src/shared/expression.zod.ts#EvaluatedExpressionSchema (const)", "ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)", "ExpandedViewItem": "src/ui/view.zod.ts#ExpandedViewItem (interface)", "Expression": "src/shared/expression.zod.ts#Expression (type)", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 1991e1234d..337ff17229 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -10,8 +10,11 @@ "CorsConfigSchema": "src/shared/http.zod.ts#CorsConfigSchema (const)", "CronExpressionInput": "src/shared/expression.zod.ts#CronExpressionInput (type)", "CronExpressionInputSchema": "src/shared/expression.zod.ts#CronExpressionInputSchema (const)", + "EVALUATED_EXPRESSION_SOURCE_REQUIRED": "src/shared/expression.zod.ts#EVALUATED_EXPRESSION_SOURCE_REQUIRED (const)", "EXTERNAL_ERROR_CODES": "src/shared/external-errors.ts#EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS": "src/shared/external-errors.ts#EXTERNAL_ERROR_HTTP_STATUS (const)", + "EvaluatedExpression": "src/shared/expression.zod.ts#EvaluatedExpression (type)", + "EvaluatedExpressionSchema": "src/shared/expression.zod.ts#EvaluatedExpressionSchema (const)", "Expression": "src/shared/expression.zod.ts#Expression (type)", "ExpressionDialect": "src/shared/expression.zod.ts#ExpressionDialect (type)", "ExpressionInput": "src/shared/expression.zod.ts#ExpressionInput (type)", diff --git a/packages/spec/json-schema.manifest/shared.json b/packages/spec/json-schema.manifest/shared.json index a5350b4de9..76fcd93b7a 100644 --- a/packages/spec/json-schema.manifest/shared.json +++ b/packages/spec/json-schema.manifest/shared.json @@ -5,6 +5,7 @@ "shared/BaseMetadataRecord", "shared/CorsConfig", "shared/CronExpressionInput", + "shared/EvaluatedExpression", "shared/Expression", "shared/ExpressionDialect", "shared/ExpressionInput", From b22f44590745336da06497840f3386c39b1317b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:33:09 +0000 Subject: [PATCH 3/5] fix(spec): declare EvaluatedExpressionParsed beside the author-state alias (ADR-0122) and regenerate Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- packages/spec/api-surface/root.json | 1 + packages/spec/api-surface/shared.json | 1 + packages/spec/export-origins/root.json | 1 + packages/spec/export-origins/shared.json | 1 + packages/spec/src/index.ts | 1 + packages/spec/src/shared/expression.zod.ts | 1 + 6 files changed, 6 insertions(+) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 0df5561e76..785cc37061 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -61,6 +61,7 @@ "EvalUser (type)", "EvalUserSchema (const)", "EvaluatedExpression (type)", + "EvaluatedExpressionParsed (type)", "EvaluatedExpressionSchema (const)", "ExpandViewResult (interface)", "ExpandedViewItem (interface)", diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 9e9ada35e4..8cb6bb7860 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -14,6 +14,7 @@ "EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS (const)", "EvaluatedExpression (type)", + "EvaluatedExpressionParsed (type)", "EvaluatedExpressionSchema (const)", "Expression (type)", "ExpressionDialect (type)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 66c1a7babb..9042946abc 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -61,6 +61,7 @@ "EvalUser": "src/identity/eval-user.zod.ts#EvalUser (type)", "EvalUserSchema": "src/identity/eval-user.zod.ts#EvalUserSchema (const)", "EvaluatedExpression": "src/shared/expression.zod.ts#EvaluatedExpression (type)", + "EvaluatedExpressionParsed": "src/shared/expression.zod.ts#EvaluatedExpressionParsed (type)", "EvaluatedExpressionSchema": "src/shared/expression.zod.ts#EvaluatedExpressionSchema (const)", "ExpandViewResult": "src/ui/view.zod.ts#ExpandViewResult (interface)", "ExpandedViewItem": "src/ui/view.zod.ts#ExpandedViewItem (interface)", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 337ff17229..4a0b18a446 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -14,6 +14,7 @@ "EXTERNAL_ERROR_CODES": "src/shared/external-errors.ts#EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS": "src/shared/external-errors.ts#EXTERNAL_ERROR_HTTP_STATUS (const)", "EvaluatedExpression": "src/shared/expression.zod.ts#EvaluatedExpression (type)", + "EvaluatedExpressionParsed": "src/shared/expression.zod.ts#EvaluatedExpressionParsed (type)", "EvaluatedExpressionSchema": "src/shared/expression.zod.ts#EvaluatedExpressionSchema (const)", "Expression": "src/shared/expression.zod.ts#Expression (type)", "ExpressionDialect": "src/shared/expression.zod.ts#ExpressionDialect (type)", diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 093a01797d..bd6f84650c 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -271,6 +271,7 @@ export { export type { Expression, EvaluatedExpression, + EvaluatedExpressionParsed, ExpressionMeta, ExpressionInput, Predicate, diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index b7c3d4d62e..b18088cd90 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -146,6 +146,7 @@ export const EvaluatedExpressionSchema = ExpressionSchema.safeExtend({ .refine((source) => source.trim().length > 0, { message: EVALUATED_EXPRESSION_SOURCE_REQUIRED }), }); export type EvaluatedExpression = z.input; +export type EvaluatedExpressionParsed = z.infer; /** * Author-time input shape: a bare string is shorthand for `{ dialect: 'cel', From 23b2881411abee9c197dc1840b0686a16c5f74fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:35:43 +0000 Subject: [PATCH 4/5] =?UTF-8?q?chore(changeset):=20say=20the=20remedy=20wi?= =?UTF-8?q?thout=20a=20migration=20label=20=E2=80=94=20nothing=20is=20rena?= =?UTF-8?q?med,=20the=20refusal=20carries=20the=20prescription?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .../evaluated-expression-slot-requires-source.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/evaluated-expression-slot-requires-source.md b/.changeset/evaluated-expression-slot-requires-source.md index 606b5482b6..bb7db75b05 100644 --- a/.changeset/evaluated-expression-slot-requires-source.md +++ b/.changeset/evaluated-expression-slot-requires-source.md @@ -45,10 +45,12 @@ assignments: pass refuses the same set — all through the spec schema, so none of them grew a rule of its own. -**FROM → TO.** An assignment value that carried only `ast` has no evaluable -form to migrate to under M9.1: author its `source`. A whitespace-only `source` -was never an expression: delete the entry, or write the expression. Every -envelope with a non-blank `source` is unchanged. +**What an author does with a refused envelope.** An assignment value that +carried only `ast` has no evaluable form under M9.1: author its `source`. A +whitespace-only `source` was never an expression: delete the entry, or write +the expression. Every envelope with a non-blank `source` is unchanged, and +nothing is renamed, retired or rewritten — the refusal itself carries the +prescription. Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s silent `false` on an envelope without a `source` — is a behaviour change on a From db817a3a66e3d83d0616d54d605ef3a7c8cca1ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 09:10:52 +0000 Subject: [PATCH 5/5] test(service-automation): narrow the defence-in-depth EvalResult pin on its discriminant before reading error Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .../src/builtin/assignment-value-envelope.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts index c2a194cf4f..7f0d5345cc 100644 --- a/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts +++ b/packages/services/service-automation/src/builtin/assignment-value-envelope.test.ts @@ -278,9 +278,11 @@ describe('assignment value envelope — one notion of malformed (#15137)', () => // Not a path authored metadata can take (both validators and the executor // run the schema first); kept so the last layer never silently answers a // value if a future caller bypasses the door. - const result = ExpressionEngine.evaluate({ dialect: 'cel', ast: { op: 'value' } }, {} as never); - expect(result.ok).toBe(false); - expect(result.error?.message).toContain('AST-only evaluation not yet supported'); + const result = ExpressionEngine.evaluate({ dialect: 'cel', ast: { op: 'value' } }, {}); + // `EvalResult` is a discriminated union — `error` exists only on the + // `ok: false` arm, so narrow on the discriminant before reading it. + if (result.ok) throw new Error('expected the engine to refuse an `ast`-only envelope, got a value'); + expect(result.error.message).toContain('AST-only evaluation not yet supported'); }); /**