Skip to content

Commit ef3a138

Browse files
claude[bot]claude
andauthored
feat(spec)!: an evaluated expression slot requires a non-blank sourceEvaluatedExpressionSchema, composed by the assignment value envelope (#15430) (#15810)
* feat(spec)!: an evaluated expression slot requires a non-blank source — EvaluatedExpressionSchema, composed by the assignment value envelope Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(spec): regenerate api-surface, export-origins, declaration-map and docs for EvaluatedExpressionSchema Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * fix(spec): declare EvaluatedExpressionParsed beside the author-state alias (ADR-0122) and regenerate Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(changeset): say the remedy without a migration label — nothing is renamed, the refusal carries the prescription Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * test(service-automation): narrow the defence-in-depth EvalResult pin on its discriminant before reading error Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e75a904 commit ef3a138

18 files changed

Lines changed: 438 additions & 70 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: an evaluated expression slot requires a non-blank `source``EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `source` keeps its name and meaning, and every envelope that carried a non-blank `source` parses byte-identically. The two newly refused spellings — an envelope carrying only `ast`, and a `source` that is blank after trimming — never evaluated on any release (the `ast`-only one faulted at run time with the engine's own "persist `source`" prescription, the blank one with a parse error), and a repo-wide census found no in-repo instance of either, so `objectstack migrate meta` has nothing to rewrite and the remedy is authoring a `source`, which the refusal itself prescribes. -->
8+
9+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
10+
(the lockstep convention): on the schemas that type an EVALUATED expression
11+
slot — today the `assignment` node's value envelope,
12+
`AssignmentExpressionValueSchema` — an envelope with no `source` the engine can
13+
evaluate is now **refused at authoring**, where it used to parse, register,
14+
pass `objectstack validate`, and then fault at run time.
15+
16+
Two spellings of one seam, refused by ONE rule with one message at `source`
17+
(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`):
18+
19+
```yaml
20+
assignments:
21+
digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it
22+
greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF
23+
```
24+
25+
> An expression in an evaluated slot needs a non-blank `source`: the expression
26+
> engine evaluates `source` (the canonical persisted form of phase M9.1) and
27+
> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source`
28+
> that is blank after trimming, would validate and register and then fault at
29+
> run time. Write `{ dialect: 'cel', source: '…' }`.
30+
31+
- **`ExpressionSchema` is NOT narrowed.** It is the persistence contract —
32+
`source` OR `ast` — and its docblock declares that `ast` becomes required in
33+
build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and
34+
its type `EvaluatedExpression`) is a sibling: the same envelope with `source`
35+
required and non-blank, spelled once and composed by every evaluated slot, so
36+
when AST-only evaluation lands the flip is one edit there rather than a
37+
per-slot unwinding. The rule is worded as "an evaluated slot requires whatever
38+
the engine can actually evaluate"; what that is today is `source`.
39+
- **The notion of blank is the engine's own** — `.trim()`, which
40+
`cel-engine.ts`'s helpers already apply — not a third one beside the shape
41+
rule's `min(1)` and `validateExpression`'s trim.
42+
- **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate`
43+
and the runtime publish gate report a located `error` at the author's own
44+
variable (`config.assignments.<name>.source`), and the executor's own shape
45+
pass refuses the same set — all through the spec schema, so none of them
46+
grew a rule of its own.
47+
48+
**What an author does with a refused envelope.** An assignment value that
49+
carried only `ast` has no evaluable form under M9.1: author its `source`. A
50+
whitespace-only `source` was never an expression: delete the entry, or write
51+
the expression. Every envelope with a non-blank `source` is unchanged, and
52+
nothing is renamed, retired or rewritten — the refusal itself carries the
53+
prescription.
54+
55+
Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s
56+
silent `false` on an envelope without a `source` — is a behaviour change on a
57+
live path with its own card, and the edge-condition schema that carries that
58+
envelope is narrowed in a follow-up once the in-flight change to
59+
`automation/flow.zod.ts` lands.

content/docs/references/automation/builtin-node-config.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ CEL value envelope `{ dialect: 'cel', source }` — evaluated by the expression
113113
| Property | Type | Required | Description |
114114
| :--- | :--- | :--- | :--- |
115115
| **dialect** | `'cel'` || |
116-
| **source** | `string` | optional | |
116+
| **source** | `string` | | |
117117
| **ast** | `any` | optional | |
118118
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |
119119

content/docs/references/index.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules
44
---
55

66
{/* ⚠️ 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
2929
| [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
3030
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
3131
| [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. |
32-
| [Shared Protocol](/docs/references/shared) | 8 | 26 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
32+
| [Shared Protocol](/docs/references/shared) | 8 | 27 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3434
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3535
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36-
| **Total** | **200** | **1589** | 14 protocol modules |
36+
| **Total** | **200** | **1590** | 14 protocol modules |
3737

3838
---
3939

@@ -286,14 +286,14 @@ Permission sets, row-level security, sharing rules, tenancy posture.
286286

287287
## Shared Protocol
288288

289-
**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 26 schemas**
289+
**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 27 schemas**
290290

291291
Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums.
292292

293293
| File | Schemas |
294294
| :--- | :--- |
295295
| [`enums.zod.ts`](/docs/references/shared/enums) | `IsolationLevelEnum`, `MutationEventEnum`, `SortDirectionEnum`, `SortItem` |
296-
| [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` |
296+
| [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `EvaluatedExpression`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` |
297297
| [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpMethodSubset`, `HttpRequest`, `RateLimitConfig`, `StaticMount` |
298298
| [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `MetadataItemName`, `SnakeCaseIdentifier`, `SystemIdentifier` |
299299
| [`mapping.zod.ts`](/docs/references/shared/mapping) | `FieldMapping` |

content/docs/references/shared/expression.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ See also: content/docs/concepts/north-star.mdx §8 "No private expression DSL"
4444
## TypeScript Usage
4545

4646
```typescript
47-
import { CronExpressionInputSchema, ExpressionSchema, ExpressionDialect, ExpressionInputSchema, ExpressionMetaSchema, PredicateSchema, PredicateInputSchema, TemplateExpressionInputSchema } from '@objectstack/spec/shared';
48-
import type { CronExpressionInput, Expression, ExpressionDialect, ExpressionInput, ExpressionMeta, Predicate, PredicateInput, TemplateExpressionInput } from '@objectstack/spec/shared';
47+
import { CronExpressionInputSchema, EvaluatedExpressionSchema, ExpressionSchema, ExpressionDialect, ExpressionInputSchema, ExpressionMetaSchema, PredicateSchema, PredicateInputSchema, TemplateExpressionInputSchema } from '@objectstack/spec/shared';
48+
import type { CronExpressionInput, EvaluatedExpression, Expression, ExpressionDialect, ExpressionInput, ExpressionMeta, Predicate, PredicateInput, TemplateExpressionInput } from '@objectstack/spec/shared';
4949

5050
// Validate data
5151
const result = CronExpressionInputSchema.parse(data);
@@ -79,6 +79,20 @@ Type: `string`
7979
---
8080

8181

82+
---
83+
84+
## EvaluatedExpression
85+
86+
### Properties
87+
88+
| Property | Type | Required | Description |
89+
| :--- | :--- | :--- | :--- |
90+
| **dialect** | `Enum<'cel' \| 'cron' \| 'template'>` || |
91+
| **source** | `string` || |
92+
| **ast** | `any` | optional | |
93+
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |
94+
95+
8296
---
8397

8498
## Expression

packages/lint/src/validate-expressions.test.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest';
66
// argument for the allowlist, and a hand-copied list would go green on exactly
77
// the root the rule never saw.
88
import { SCOPE_ROOTS } from '@objectstack/formula';
9-
import { ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
9+
import { EVALUATED_EXPRESSION_SOURCE_REQUIRED, ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
1010
import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
1111
import { SharingRuleSchema } from '@objectstack/spec/security';
1212
// [#15137] The published refusal sentence a `value`-slot finding must lead
@@ -3733,6 +3733,9 @@ describe('assignment value envelope — located findings (#15137)', () => {
37333733
it.each([
37343734
['no `source` — the shape only the spec schema catches', { dialect: 'cel' }],
37353735
['an empty `source`', { dialect: 'cel', source: '' }],
3736+
// #15430 — an `ast`-only envelope is a valid `ExpressionSchema` that no
3737+
// engine evaluates; the spec's evaluated-slot rule refuses it at `source`.
3738+
['an `ast`-only envelope — no `source` the engine can evaluate', { dialect: 'cel', ast: { kind: 'const' } }],
37363739
['a non-`cel` dialect', { dialect: 'template', source: 'Hello {name}' }],
37373740
['CEL that does not parse', { dialect: 'cel', source: 'rows.map(r,' }],
37383741
['an unknown function', { dialect: 'cel', source: 'nosuchfn(rows)' }],
@@ -3759,16 +3762,24 @@ describe('assignment value envelope — located findings (#15137)', () => {
37593762
})).toHaveLength(0);
37603763
});
37613764

3762-
it('is silent on a whitespace-only `source` — the seam this pass cannot see (#15430)', () => {
3763-
// `ExpressionSchema.source` is `z.string().min(1)`, so `' '` passes the
3764-
// shape rule, and `validateExpression` trims it to empty and answers
3765-
// `ok: true` ("not authored"). Build says nothing; the CEL engine parses it
3766-
// untrimmed and the run faults loudly (pinned in `service-automation`'s
3767-
// `assignment-value-envelope.test.ts`). Pinned as the BOUND of the
3768-
// build/run agreement, not as desired behaviour — ⛔ do not close it with a
3769-
// trim rule invented here: that is a third notion of "malformed", which is
3770-
// the defect this arm exists to avoid. The fix belongs in the shape rule.
3771-
expect(valueIssues({ digest: { dialect: 'cel', source: ' ' } })).toHaveLength(0);
3765+
it('reports a whitespace-only `source` — the seam is now visible through the shape pass (#15430)', () => {
3766+
// FLIPPED. This used to pin the seam this pass could NOT see: `' '`
3767+
// passed `ExpressionSchema.source`'s `min(1)`, `validateExpression` trimmed
3768+
// it to empty and answered `ok: true` ("not authored"), and only the run
3769+
// faulted. The file said the fix belonged in the shape rule, not in a trim
3770+
// rule invented here — and it landed there: `AssignmentExpressionValueSchema`
3771+
// now composes the spec's `EvaluatedExpressionSchema`, so the object-level
3772+
// shape pass this arm already runs (`AssignmentValueSchema.safeParse`)
3773+
// refuses it, located at the author's variable, led by the published
3774+
// sentence and carrying the evaluated-slot rule's own — measured here, not
3775+
// assumed: nothing in this pass changed, the refusal arrives through the
3776+
// spec dependency.
3777+
const issues = valueIssues({ digest: { dialect: 'cel', source: ' ' } });
3778+
expect(issues).toHaveLength(1);
3779+
expect(issues[0]!.severity).toBe('error');
3780+
expect(issues[0]!.where).toContain('config.assignments.digest');
3781+
expect(issues[0]!.message.startsWith(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL)).toBe(true);
3782+
expect(issues[0]!.message).toContain('`source`: ' + EVALUATED_EXPRESSION_SOURCE_REQUIRED);
37723783
});
37733784

37743785
it('says nothing about the legacy array form — it is not a declared slot', () => {

0 commit comments

Comments
 (0)