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
32 changes: 32 additions & 0 deletions .changeset/adr-0032-expression-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@objectstack/formula": minor
"@objectstack/service-automation": minor
"@objectstack/service-ai": minor
"@objectstack/cli": minor
"@objectstack/spec": patch
---

ADR-0032 (phase 1): validate-by-default expression layer — no silent failure.

Kills the #1491 class where a malformed predicate (e.g. the `{record.x}`
template-brace-in-CEL mistake) silently evaluated to `false` and made a flow
"fire" with no effect:

- **service-automation**: flow `evaluateCondition` no longer swallows CEL
failures to `false` — it throws an attributed, corrective error; and
`registerFlow` now parse-validates every predicate (start/decision/edge
condition) at registration, failing loudly with the offending location +
source + the fix.
- **formula**: new shared validator — `validateExpression(role, src, schema?)`,
`introspectScope`, `CEL_STDLIB_FUNCTIONS` — with schema-aware field-existence
+ did-you-mean. The `{{ }}` template engine gains a formatter whitelist
(`currency`/`number`/`percent`/`date`/`datetime`/`truncate`/`upper`/`lower`/
`default`/…) with defined value→string semantics; arbitrary logic in holes is
rejected. Plain `{{ path }}` stays back-compatible.
- **cli**: `objectstack compile` validates every flow / validation-rule /
field-formula predicate against the resolved object schema and fails the
build with located, corrective messages.
- **service-ai**: new agent-callable `validate_expression` tool so authoring
agents self-correct before committing.
- **spec**: fix the `FlowSchema` JSDoc example that taught the bad
`condition: "{amount} < 500"` single-brace form.
2 changes: 1 addition & 1 deletion examples/app-crm/src/flows/convert-lead.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export const ConvertLeadScreenFlow = defineFlow({
{ id: 'e1', source: 'start', target: 'get_lead', type: 'default' },
{ id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' },
// guard branches
{ id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "{lead_record.status} == 'converted'", label: 'Yes' },
{ id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' },
{ id: 'e3b', source: 'check_converted', target: 'screen_qualify', type: 'default', label: 'No' },
{ id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' },
// main path
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@objectstack/driver-mongodb": "workspace:^",
"@objectstack/driver-sql": "workspace:^",
"@objectstack/driver-sqlite-wasm": "workspace:^",
"@objectstack/formula": "workspace:*",
"@objectstack/objectql": "workspace:^",
"@objectstack/observability": "workspace:^",
"@objectstack/platform-objects": "workspace:*",
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ZodError } from 'zod';
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '../utils/validate-expressions.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
import {
printHeader,
Expand Down Expand Up @@ -123,6 +124,27 @@ export default class Compile extends Command {
this.exit(1);
}

// 3b. Validate expressions against the resolved schema (ADR-0032 §1a/1b).
// The whole normalized stack is in hand here, so flow/validation
// predicates are checked for CEL syntax AND that `record.<field>`
// references exist on the target object — failing the build with a
// located, corrective message instead of a silent runtime `false`.
if (!flags.json) printStep('Validating expressions (ADR-0032)...');
const exprIssues = validateStackExpressions(result.data as Record<string, unknown>);
if (exprIssues.length > 0) {
if (flags.json) {
console.log(JSON.stringify({ success: false, error: 'expression validation failed', issues: exprIssues }));
this.exit(1);
}
console.log('');
printError(`Expression validation failed (${exprIssues.length} issue${exprIssues.length > 1 ? 's' : ''})`);
for (const i of exprIssues.slice(0, 50)) {
console.log(` • ${i.where}: ${i.message}`);
console.log(` source: \`${i.source}\``);
}
this.exit(1);
}

// 4. Generate Artifact
if (!flags.json) printStep('Writing artifact...');
const output = flags.output!;
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/utils/validate-expressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { validateStackExpressions } from './validate-expressions.js';

describe('validateStackExpressions (ADR-0032 build-time)', () => {
const objects = [
{ name: 'crm_lead', fields: { rating: { type: 'number' }, status: { type: 'text' } } },
];

it('passes a clean stack', () => {
const issues = validateStackExpressions({
objects,
flows: [{
name: 'lead_flow',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
{ id: 'check', type: 'decision', config: { condition: 'record.rating >= 4' } },
],
edges: [{ id: 'e1', source: 'check', target: 'end', condition: 'record.rating < 4' }],
}],
});
expect(issues).toHaveLength(0);
});

it('flags a brace-in-CEL condition with location + corrective message', () => {
const issues = validateStackExpressions({
objects,
flows: [{
name: 'lead_flow',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
{ id: 'check', type: 'decision', config: { condition: '{record.rating} >= 4' } },
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].where).toContain("flow 'lead_flow'");
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].message).toMatch(/map literal|bare reference/);
expect(issues[0].source).toBe('{record.rating} >= 4');
});

it('flags an unknown record field against the resolved schema (did-you-mean)', () => {
const issues = validateStackExpressions({
objects,
flows: [{
name: 'lead_flow',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'crm_lead' } },
{ id: 'check', type: 'decision', config: { condition: 'record.raitng >= 4' } },
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].message).toMatch(/unknown field `raitng`/);
expect(issues[0].message).toMatch(/did you mean `rating`/);
});

it('validates object validation-rule predicates too', () => {
const issues = validateStackExpressions({
objects: [
{ name: 'crm_lead', fields: { rating: {} }, validations: [{ name: 'r1', expression: '{record.rating} > 0' }] },
],
});
expect(issues).toHaveLength(1);
expect(issues[0].where).toContain("validation 'r1'");
});
});
114 changes: 114 additions & 0 deletions packages/cli/src/utils/validate-expressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Build-time expression validation (ADR-0032 §Decision 1a + 1b).
*
* Runs at `objectstack compile`, where the whole normalized stack is in hand —
* so flow conditions can be checked against the *resolved* object schema
* (field existence) in addition to CEL syntax. Uses the one shared validator
* from `@objectstack/formula`, so the verdict matches `registerFlow` and the
* agent `validate_expression` tool exactly.
*
* Scope (v1): flow predicates (start/decision `config.condition` + edge
* `condition`) and object validation-rule / formula predicates. Each error is
* located (flow/object + node/edge/field) with a corrective message.
*/

import { validateExpression } from '@objectstack/formula';

export interface ExprIssue {
where: string;
message: string;
source: string;
}

type AnyRec = Record<string, unknown>;

/** Coerce an `objects` collection (array or name-keyed map) to an array. */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}

/** object name → set of its field names, for schema-aware field checks. */
function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
const idx = new Map<string, string[]>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const fields = obj.fields;
let names: string[] = [];
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
idx.set(name, names);
}
return idx;
}

/**
* Validate every predicate in the stack. Returns the list of issues (empty =
* clean). Caller decides how to surface / whether to fail the build.
*/
export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const issues: ExprIssue[] = [];
const objects = asArray(stack.objects);
const fieldIndex = buildFieldIndex(objects);

const check = (where: string, raw: unknown, objectName?: string): void => {
if (raw == null) return;
const fields = objectName ? fieldIndex.get(objectName) : undefined;
const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string },
objectName ? { objectName, fields } : undefined);
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source });
};

// ── Flows ──────────────────────────────────────────────────────────
for (const flow of asArray(stack.flows)) {
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : [];
// The record-change target object — `record.*` refs resolve against it.
const startNode = nodes.find(n => n.type === 'start');
const startCfg = (startNode?.config ?? {}) as AnyRec;
const objectName = typeof startCfg.objectName === 'string' ? startCfg.objectName : undefined;

for (const node of nodes) {
const cfg = (node.config ?? {}) as AnyRec;
check(`flow '${flowName}' · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
}
for (const edge of edges) {
check(`flow '${flowName}' · edge '${edge.id}' (${edge.source}→${edge.target}) condition`, edge.condition, objectName);
}
}

// ── Object validation-rule + formula predicates ────────────────────
for (const obj of objects) {
const objectName = typeof obj.name === 'string' ? obj.name : undefined;
const validations = obj.validations ?? obj.validationRules;
for (const rule of asArray(validations)) {
const where = `object '${objectName}' · validation '${(rule.name as string) ?? '?'}'`;
// Common predicate keys across rule shapes.
check(where, rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula, objectName);
}
// Field-level formulas (computed fields) reference the same object.
const fields = obj.fields;
const fieldList = Array.isArray(fields)
? (fields as AnyRec[])
: (fields && typeof fields === 'object' ? Object.values(fields as AnyRec) as AnyRec[] : []);
for (const f of fieldList) {
if (f && typeof f === 'object' && f.formula) {
// formulas are `value` role (any return type), still CEL.
const res = validateExpression('value', f.formula as string | { dialect?: string; source?: string },
objectName ? { objectName, fields: fieldIndex.get(objectName) } : undefined);
for (const e of res.errors) {
issues.push({ where: `object '${objectName}' · field '${(f.name as string) ?? '?'}' formula`, message: e.message, source: e.source });
}
}
}
}

return issues;
}
6 changes: 5 additions & 1 deletion packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
export { ExpressionEngine, getEngine, hasDialect, register } from './registry';
export { celEngine, DEFAULT_LIMITS } from './cel-engine';
export { cronEngine } from './cron-engine';
export { templateEngine } from './template-engine';
export { templateEngine, TEMPLATE_FORMATTERS } from './template-engine';
export { registerStdLib, buildScope } from './stdlib';
export { resolveSeed, resolveSeedRecord } from './seed-eval';
export { normalizeExpression, normalizeExpressionTree } from './normalize';
// ADR-0032 — shared validator + introspection (one validator for build,
// registration, and the agent-callable validate_expression tool).
export { validateExpression, introspectScope, expectedDialect, CEL_STDLIB_FUNCTIONS } from './validate';
export type { FieldRole, ExprSchemaHint, ExprValidationError, ExprValidationResult } from './validate';
export type { SeedValue, SeedPrimitive } from './seed-eval';
export type { DialectEngine, EvalContext, EvalResult, EvalError } from './types';
Loading