Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

feat(spec)!: an evaluated expression slot requires a non-blank `source` — `EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430)

<!-- 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. -->

**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.<name>.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.

**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
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.
2 changes: 1 addition & 1 deletion content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |

Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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` |
Expand Down
18 changes: 16 additions & 2 deletions content/docs/references/shared/expression.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
33 changes: 22 additions & 11 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)' }],
Expand All @@ -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', () => {
Expand Down
Loading
Loading