Skip to content

Commit 954cb0b

Browse files
os-warrenclaude
andauthored
feat(service-automation): evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow (#15137) (#15432)
* feat(service-automation): evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow (#15137) The executor half of the maintainer's 2026-09-02 ruling on #14149, whose spec half landed in PR #15113. An `assignment` value that is an `{ dialect: 'cel', source }` envelope is now evaluated by the expression engine and the result assigned; before this it went to `interpolate()`, which recursed into it as a plain object and wrote it into the variable verbatim, so `notify` rendered `{"dialect":"cel","source":"…"}` as JSON and the declared CEL stdlib was unreachable from metadata. Three parts: - `AutomationEngine.evaluateValueEnvelope` evaluates a declared envelope in the CEL scope `evaluateCondition` already builds — extracted to one shared `celScope`, so a predicate and a value expression cannot disagree about what a variable means. A refusal throws with the source attached (ADR-0032 §1c/§1d): a value that failed to compute has no falsy default to hide behind. - `registerFlow` refuses a malformed envelope (throws) and `objectstack validate` reports it as a located finding — the severity split the `predicate` role already uses. Both compose the SAME two published primitives, in the same order: `AssignmentValueSchema` for shape (it alone catches the source-less `{ dialect: 'cel' }` that `validateExpression` reads as "not authored") and `validateExpression('value', …)` for CEL. One notion of malformed, derived once, so registration and evaluation cannot refuse different sets. - The behaviour is scoped to the slot the ledger declares, `assignments.*`. The legacy `assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }` config keep every meaning they had, envelope-shaped values included: `AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the array form, because refusing it would break flows that register today and that refusal is a ruling, not a lane's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * fix(service-automation): clear the ADR-0087 gate, correct the docs this landing falsified, and pin the bound of the reject-set property (#15137) The four items from the Clause-② contract review on PR #15432. The mechanism is untouched — no change to engine.ts, logic-nodes.ts or validate-expressions.ts. - **ADR-0087 gate (red, required).** The changeset declares BREAKING and carried no disposition marker, so `check-adr-0087-registration.mjs` failed. It now carries `not-required (no-migration-prescription)`: no authorable key is renamed, retired or re-typed, so `objectstack migrate meta` has nothing to rewrite. The gate was missed locally because `dispatch-gates.mjs` presents `--self-test` as the runnable member and files the real `--base` invocation as not-runnable, although the script's own usage line defaults `--base` to `origin/main`. - **Docs this PR made false.** `content/docs/automation/flows.mdx` still told authors the executor writes the envelope verbatim and prescribed a `script` node "for a digest body today". Replaced with what landed: where a malformed envelope is refused (three doors, one composition) and which two shapes fault at run time instead. The same stale sentence in `packages/spec/src/automation/flow-node-expression-paths.ts` is corrected as a COMMENT — no contract, schema, export or generated artifact moves. - **The property's bound, pinned.** `{ dialect: 'cel', source: ' ' }` registers and then faults: `source` is `z.string().min(1)` so whitespace passes the shape rule, `validateExpression` trims to empty and answers "not authored", and the CEL engine parses it untrimmed. Same seam class as the `ast`-only case. Pinned loud on both sides (engine faults, lint is silent) and added to #15430 — deliberately NOT closed here with a trim rule of the engine's own, which would be the third locally-invented notion of "malformed" this design exists to prevent. - **Accuracy.** "Exactly four things" was an under-count (`source: 5` and `meta: 'bad'` are refused too); the reject set is stated as the composition. The runtime publish gate is named as the third door — `validateStackExpressions` is registered `CLI_AND_RUNTIME` / `['flow']` (`authoring-rules.ts:431-441`), so Studio / REST / MCP flow writes get the same refusal at `severity: 'error'`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d3f60e commit 954cb0b

8 files changed

Lines changed: 830 additions & 51 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(service-automation): an `assignment` value may be a CEL envelope — evaluated at run time, validated at `registerFlow`, `objectstack validate` and the runtime publish gate (#15137, the executor half of #14149)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: the `assignments` map and every value form it accepted still parse. The only newly refused shape is a malformed CEL value envelope, a spelling declared one day earlier in #15113 and offered by no authoring surface before it, so `objectstack migrate meta` has nothing to rewrite and this changeset carries no rewrite instructions for a consumer to follow. -->
9+
10+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
11+
(the lockstep convention; the level also follows the 2026-09-04 bump ruling —
12+
this adds `AutomationEngine.evaluateValueEnvelope` to a published surface, and an
13+
additive widening is at least `minor`). No ADR-0087 conversion: no authorable key
14+
is renamed or retired, and the shape this refuses was never a shape any surface
15+
offered.
16+
17+
The maintainer's 2026-09-02 ruling on #14149 made an assignment value able to be
18+
a CEL **value** expression, so the declared stdlib (`joinNonEmpty`, `map`, `size`
19+
…) is finally reachable from metadata — until now CEL was only ever asked for a
20+
boolean. The spec half landed the contract (PR #15113); this is the half that
21+
makes it do something.
22+
23+
```yaml
24+
# before: written into the variable verbatim, and rendered by `notify` as
25+
# {"dialect":"cel","source":"joinNonEmpty(...)"}
26+
# now: evaluated — digest is "Renewal due\nInvoice overdue"
27+
assignments:
28+
digest: { dialect: cel, source: 'joinNonEmpty(rows.map(r, r.subject), "\n")' }
29+
```
30+
31+
- **Evaluated at run time.** The built-in `assignment` executor evaluates a
32+
`value`-role envelope with the expression engine and assigns the result, in the
33+
same CEL scope a flow predicate is evaluated in (one shared scope builder, so a
34+
predicate and a value expression cannot disagree about what `rows` means). A
35+
plain string keeps today's `{token}` interpolation, and every other literal is
36+
still assigned as data.
37+
- **Refused at three doors.** A malformed envelope now stops the flow registering
38+
(`registerFlow` throws, the severity a malformed predicate gets) and surfaces as
39+
a located `error` finding naming the node and the author's own variable —
40+
`config.assignments.digest` — both at `objectstack validate` and at the runtime
41+
publish gate a Studio / REST / MCP flow write goes through
42+
(`validateStackExpressions` is registered `CLI_AND_RUNTIME`, `runtimeTypes:
43+
['flow']`). Malformed is a composition, not a fixed list: whatever
44+
`AssignmentValueSchema` refuses in the envelope's shape — among them a missing,
45+
empty or non-string `source`, a dialect other than `cel`, a non-object `meta` —
46+
and then CEL that does not parse. All three doors derive that set from the same
47+
two published validators, so none refuses a shape the executor would have run,
48+
and a registered flow never faults for a shape those validators judge malformed.
49+
Two shapes sit outside what either validator can judge — an `ast`-only envelope
50+
and a whitespace-only `source` (it passes `min(1)` and reads as "not authored"
51+
to the validator, while the CEL engine parses it untrimmed) — and those fault
52+
loudly at run time rather than assigning a value. Both are pinned and tracked in
53+
#15430.
54+
- **Only the canonical map.** The ledger declares `assignment.assignments.*` and
55+
nothing else, so the two legacy shapes the executor still normalizes — the
56+
`assignments: [{ variable, value }]` array and the bare `{ <variable>: <value> }`
57+
config — keep every meaning they had, envelope-shaped values included.
58+
`AssignmentConfigSchema` is deliberately NOT wired into `parseNodeConfig` for the
59+
array form: refusing it would break flows that register today, and that refusal
60+
is a maintainer ruling rather than a lane's call (#15137 ask 3).
61+
62+
**What changes silently, and how far it reaches.** A flow that today authors an
63+
envelope-shaped object *as data* in the canonical `assignments` map now evaluates
64+
it — no error on either side, a different value. The discriminator is the spec's
65+
own `isExpressionEnvelopeShaped`: a plain object naming a **string** `dialect`,
66+
in the declared map only. Data that names no `dialect`, names a non-string one,
67+
nests the envelope one level down, or sits in either legacy shape is untouched
68+
and byte-identical. The remaining overlap — a well-formed
69+
`{ dialect: 'cel', source: … }` written as data in the canonical map — is exactly
70+
the spelling the ruling reinterprets; every near-miss the two validators can
71+
judge now refuses loudly at registration instead of changing value in silence.

content/docs/automation/flows.mdx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,21 +199,30 @@ or missing-`required` violation (#4277). A node type that publishes no
199199
A value's **shape** selects its form — there is no mode key. A plain string is
200200
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
201201
CEL); an object that names a `dialect` is an expression envelope and must be a
202-
valid `cel` one — a missing or empty `source`, or a `template` / `cron`
203-
dialect, is refused at the variable's path. Numbers, booleans, arrays and plain
204-
objects are assigned as literals. A later `notify` node renders the variable as
205-
any other: `message: '{digest}'`.
206-
207-
<Callout type="warn" title="The contract landed first; the executor half follows">
208-
209-
The envelope form is what `@objectstack/spec` declares — the expression
210-
ledger's `value` entry and `AssignmentConfigSchema`, the half ruled in
211-
[#14149]. Until the matching `@objectstack/service-automation` change lands,
212-
the built-in `assignment` executor still writes an envelope object into the
213-
variable verbatim and `notify` renders it as JSON. For a digest body today,
214-
call a registered function from a `script` node.
215-
216-
[#14149]: https://github.com/objectstack-ai/objectstack/issues/14149
202+
valid `cel` one — a missing, empty or non-string `source`, or a `template` /
203+
`cron` dialect, is refused at the variable's path. Numbers, booleans, arrays
204+
and plain objects are assigned as literals. A later `notify` node renders the
205+
variable as any other: `message: '{digest}'`, and it renders the **evaluated**
206+
value.
207+
208+
<Callout type="info" title="Where a malformed envelope is refused">
209+
210+
A malformed envelope never reaches run time silently: the same refusal runs at
211+
`objectstack validate` (a located finding naming the variable), at the runtime
212+
publish gate a Studio / REST / MCP flow write goes through, and at
213+
`registerFlow`, which refuses to register the flow. All three ask the same two
214+
questions in the same order — is it a valid `cel` envelope
215+
(`AssignmentValueSchema`), and does its source parse as CEL
216+
(`validateExpression`) — so a flow that registers is a flow whose envelopes
217+
those two validators accept.
218+
219+
Two shapes sit outside what either validator can judge and fault loudly at run
220+
time instead of assigning a value: an `ast`-only envelope (no `source` — the
221+
CEL engine evaluates `source`), and a whitespace-only `source`, which passes
222+
`min(1)` and reads as "not authored" to the validator while the engine parses
223+
it untrimmed. Both are tracked in [#15430].
224+
225+
[#15430]: https://github.com/objectstack-ai/objectstack/issues/15430
217226

218227
</Callout>
219228

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { SCOPE_ROOTS } from '@objectstack/formula';
99
import { ExpressionInputSchema, ObjectStackSchema } from '@objectstack/spec';
1010
import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec/data';
1111
import { SharingRuleSchema } from '@objectstack/spec/security';
12+
// [#15137] The published refusal sentence a `value`-slot finding must lead
13+
// with — asserted from the spec's own export, never re-spelled in a test.
14+
import { ASSIGNMENT_VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation';
1215

1316
import {
1417
validateStackExpressions,
@@ -2842,6 +2845,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
28422845
// here would have been excused into masking a genuine
28432846
// `validations[].message` read.
28442847
'verdict', 'diagnostic',
2848+
// [#15137] The Zod `safeParse` result for a `value`-slot envelope. Its
2849+
// keys are `success` / `error`, SafeParseResult's own — never metadata
2850+
// keys; the metadata keys it judges are `AssignmentValueSchema`'s, read
2851+
// by the schema and not by name here.
2852+
'shape',
28452853
// [#14089] NOT a receiver at all — the tail of the `'./flow-variable-scope.js'`
28462854
// import specifier, which this scan cannot tell from `scope.j…`. The two
28472855
// entries above it in this set (`fields`, `guards`) are the same artefact
@@ -3621,3 +3629,99 @@ describe('cross-site unprovisioned-anchor convergence (#8405)', () => {
36213629
}
36223630
});
36233631
});
3632+
3633+
3634+
/**
3635+
* ── `assignment` value envelopes (#15137) ───────────────────────────────────
3636+
*
3637+
* The `objectstack validate` half of the executor card: the ledger's `value`
3638+
* role (`assignment.assignments.*`, #14149) reaches this pass as a LOCATED
3639+
* finding at `error` severity, matching what `registerFlow` throws on. Build
3640+
* and run time must agree about which flows are acceptable — a build that
3641+
* passes what registration refuses is the worst of both.
3642+
*
3643+
* The refusal notion is not re-derived here: `checkDeclaredValue` composes the
3644+
* same two published primitives the engine does, in the same order.
3645+
*/
3646+
describe('assignment value envelope — located findings (#15137)', () => {
3647+
const assignmentFlow = (assignments: unknown) => ({
3648+
flows: [{
3649+
name: 'nightly_digest',
3650+
nodes: [
3651+
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
3652+
{ id: 'set_1', type: 'assignment', config: { assignments } },
3653+
],
3654+
edges: [],
3655+
}],
3656+
});
3657+
const valueIssues = (assignments: unknown) =>
3658+
validateStackExpressions(assignmentFlow(assignments)).filter((i) => i.where.includes('assignment value'));
3659+
3660+
it.each([
3661+
['no `source` — the shape only the spec schema catches', { dialect: 'cel' }],
3662+
['an empty `source`', { dialect: 'cel', source: '' }],
3663+
['a non-`cel` dialect', { dialect: 'template', source: 'Hello {name}' }],
3664+
['CEL that does not parse', { dialect: 'cel', source: 'rows.map(r,' }],
3665+
['an unknown function', { dialect: 'cel', source: 'nosuchfn(rows)' }],
3666+
] as const)('reports %s, located at the author\'s own variable name', (_label, envelope) => {
3667+
const issues = valueIssues({ digest: envelope });
3668+
expect(issues).toHaveLength(1);
3669+
expect(issues[0]!.severity).toBe('error');
3670+
expect(issues[0]!.where).toContain("flow 'nightly_digest'");
3671+
expect(issues[0]!.where).toContain("node 'set_1'");
3672+
// The `*` wildcard resolves to the variable the author named, so the
3673+
// finding points at the assignment, not at "somewhere in this node".
3674+
expect(issues[0]!.where).toContain('config.assignments.digest');
3675+
expect(issues[0]!.message).toContain(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL);
3676+
});
3677+
3678+
it('passes a well-formed envelope, and every shape that is not an envelope', () => {
3679+
expect(valueIssues({
3680+
digest: { dialect: 'cel', source: 'joinNonEmpty(rows.map(r, r.subject), "\\n")' },
3681+
greeting: 'Hello {name}',
3682+
count: 3,
3683+
flags: { enabled: true },
3684+
// Envelope-SHAPED only by a non-string dialect — data, not an expression.
3685+
decoy: { dialect: 7, source: 'x' },
3686+
})).toHaveLength(0);
3687+
});
3688+
3689+
it('is silent on a whitespace-only `source` — the seam this pass cannot see (#15430)', () => {
3690+
// `ExpressionSchema.source` is `z.string().min(1)`, so `' '` passes the
3691+
// shape rule, and `validateExpression` trims it to empty and answers
3692+
// `ok: true` ("not authored"). Build says nothing; the CEL engine parses it
3693+
// untrimmed and the run faults loudly (pinned in `service-automation`'s
3694+
// `assignment-value-envelope.test.ts`). Pinned as the BOUND of the
3695+
// build/run agreement, not as desired behaviour — ⛔ do not close it with a
3696+
// trim rule invented here: that is a third notion of "malformed", which is
3697+
// the defect this arm exists to avoid. The fix belongs in the shape rule.
3698+
expect(valueIssues({ digest: { dialect: 'cel', source: ' ' } })).toHaveLength(0);
3699+
});
3700+
3701+
it('says nothing about the legacy array form — it is not a declared slot', () => {
3702+
// The seat's ask-3 disposition, from the lint side: refusing the array form
3703+
// would fail builds that pass today, and that refusal is a ruling rather
3704+
// than a lane's call. The ledger's `*` walk returns early on an array, so
3705+
// there is no wiring to leave out.
3706+
expect(valueIssues([{ variable: 'digest', value: { dialect: 'cel' } }])).toHaveLength(0);
3707+
});
3708+
3709+
it('a value envelope is not shadow-checked — that diagnostic is about field reads', () => {
3710+
// `warnShadowedFieldReads` stays on `predicate` slots: a value envelope is
3711+
// evaluated in the flow-variable scope by design, so there is no field
3712+
// reading for a variable to displace.
3713+
const issues = validateStackExpressions({
3714+
objects: [{ name: 'crm_lead', fields: { status: { type: 'text' } } }],
3715+
flows: [{
3716+
name: 'nightly_digest',
3717+
variables: [{ name: 'status', type: 'text' }],
3718+
nodes: [
3719+
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
3720+
{ id: 'set_1', type: 'assignment', config: { assignments: { digest: { dialect: 'cel', source: 'status' } } } },
3721+
],
3722+
edges: [],
3723+
}],
3724+
});
3725+
expect(issues.filter((i) => i.where.includes('assignment value'))).toHaveLength(0);
3726+
});
3727+
});

packages/lint/src/validate-expressions.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@
8080

8181
import { validateExpression, collectCelRootIdentifiers, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula';
8282
import { collectFlowGraphs, resolveFlowNodeExpressions } from '@objectstack/spec/automation';
83+
// [#15137] The `value`-role half. Same two published primitives the engine
84+
// composes at `registerFlow` (`AutomationEngine.valueEnvelopeRefusals`), in the
85+
// same order: the SHAPE rule lives in the spec's `AssignmentValueSchema` (it
86+
// refuses a non-`cel` dialect and the source-less `{ dialect: 'cel' }` that
87+
// `validateExpression` reads as "not authored"), the CEL rule in
88+
// `validateExpression('value', …)`. Neither refusal string is spelled here.
89+
import { AssignmentValueSchema, ASSIGNMENT_VALUE_ENVELOPE_REFUSAL } from '@objectstack/spec/automation';
8390
import type { FlowNodeParsed } from '@objectstack/spec/automation';
8491

8592
import { collectFlowVariableNames, shadowedFieldReads, shadowedFieldMessage } from './flow-variable-scope.js';
@@ -1065,6 +1072,37 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
10651072
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
10661073
};
10671074

1075+
/**
1076+
* A declared `value` slot (#15137) — today the `assignment` node's
1077+
* `assignments.*`, the one slot whose job is to compute a value into a
1078+
* variable.
1079+
*
1080+
* The resolver emits ONLY envelope-shaped objects for this role, so anything
1081+
* that arrives is an author declaring an expression. `error`, matching the
1082+
* engine's `registerFlow` throw: build and run time must agree about what
1083+
* registers, and a malformed envelope used to be stored verbatim and rendered
1084+
* by `notify` as JSON with nothing said at any layer.
1085+
*/
1086+
const checkDeclaredValue = (where: string, raw: unknown): void => {
1087+
if (raw == null) return;
1088+
// `celSourceOf` — not a second read of `.source`: the same helper every
1089+
// other slot in this file locates its finding with.
1090+
const source = celSourceOf(raw) ?? '';
1091+
const shape = AssignmentValueSchema.safeParse(raw);
1092+
if (!shape.success) {
1093+
// Already prefixed by the spec's own refinement — do not say it twice.
1094+
for (const issue of shape.error.issues) issues.push({ where, message: issue.message, source, severity: 'error' });
1095+
return;
1096+
}
1097+
const res = validateExpression('value', raw as { dialect?: string; source?: string });
1098+
for (const e of res.errors) {
1099+
issues.push({ where, message: `${ASSIGNMENT_VALUE_ENVELOPE_REFUSAL} ${e.message}`, source: e.source, severity: 'error' });
1100+
}
1101+
for (const w of res.warnings) {
1102+
issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
1103+
}
1104+
};
1105+
10681106
// ── Flows ──────────────────────────────────────────────────────────
10691107
for (const flow of asArray(stack.flows)) {
10701108
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
@@ -1135,8 +1173,17 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
11351173
// reconciliation ratchet still sees the marker.
11361174
const nodeType = typeof node.type === 'string' ? node.type : '';
11371175
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
1138-
if (found.entry.role !== 'predicate') continue;
11391176
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
1177+
// [#15137] `value` slots are checkable too, by their own rule — see
1178+
// `checkDeclaredValue`. Not shadowing-checked: the shadow diagnostic is
1179+
// about a bare identifier resolving to a variable instead of a field,
1180+
// and a value envelope is evaluated in the flow-variable scope by
1181+
// design — there is no field reading to displace.
1182+
if (found.entry.role === 'value') {
1183+
checkDeclaredValue(slotWhere, found.value);
1184+
continue;
1185+
}
1186+
if (found.entry.role !== 'predicate') continue;
11401187
checkDeclaredPredicate(slotWhere, found.value);
11411188
// [#14288] The shadowing warning is about the SCOPE an expression is
11421189
// evaluated in, not about which key it was authored under — so it

0 commit comments

Comments
 (0)