Skip to content

Latest commit

 

History

History
147 lines (98 loc) · 20.4 KB

File metadata and controls

147 lines (98 loc) · 20.4 KB

ADR-0032: Expression layer for an AI-authored platform — validate-by-default, schema-aware, one CEL language, no silent failure

Status: Accepted — implemented; designer builders pending (objectui) (proposed 2026-06-02 · calibrated 2026-06-12) Deciders: ObjectStack Protocol Architects Builds on: ADR-0010 + ADR-0011 (AI authoring of metadata — the design center), ADR-0018 (open action registry), ADR-0031 (structured, statically-analyzable constructs for AI) Consumers: @objectstack/formula (CEL engine + stdlib + template interpolation + validator), @objectstack/spec (expression-field types + introspection: every condition/guard/value/template field across data / automation / ui / security; the coercion in shared/expression.zod.ts), @objectstack/services/service-automation (engine.ts evaluateCondition, builtin/template.ts, builtin node executors), @objectstack/cli (compile-time validation in objectstack build), the agent tool layer (an validate_expression / schema-introspection tool surfaced to authoring agents), ../objectui (flow designer condition/template builders)

Premise: the platform is pre-launch — no production artifacts, no external authors, no back-compat debt. This ADR specifies the target end-state directly, with no deprecation path. This window closes at launch; an expression layer is effectively unchangeable once metadata is in the wild.

Design center: the long-term author of every expression is an AI. That single assumption reorders everything below. You cannot make a generative author incapable of the first mistake — it pattern-matches, over-generalizes a syntax it saw work elsewhere, and emits plausible-but-wrong text with full confidence. So the design goal is not "a syntax an AI can't get wrong"; it is "no mistake survives to runtime, and every mistake comes back to the author as a precise, fixable error." The spine is a validate-by-default loop, not a choice of brackets.


TL;DR

Today the platform exposes three look-alike syntaxes for referencing data — bare CEL (record.amount > 100000) for predicates, single-brace {record.name} for flow node string fields, double-brace {{record.name}} for titleFormat/notifications — and when they are mixed up it fails silently: a condition is typed string, any string is coerced to CEL, and a CEL parse/eval error is swallowed to false. The flow "fires" with success:true and does nothing (issue #1491).

For an AI-authored platform the fix is not "pick the right brackets." It is, in priority order:

  1. Validate by default, never fail silently, and feed the failure back to the author — parse + schema-check every expression at build (and on demand via an agent-callable validator); a malformed expression is a build error with a fixable message, never a runtime no-op. This is the spine.
  2. Two structurally un-confusable field shapes, with the field's type naming the dialect — predicates and computed values are whole-field CEL with no delimiters; only genuine text uses templates. The #1491 pattern ({…} in a condition) then has nowhere to live.
  3. Templates: {{ }} holes restricted to field-paths + whitelisted formatters, with defined value→string semantics; single { } deleted.
  4. Correctness is training data — remove every anti-pattern from the spec's own examples/skills, ship a golden example set, make the contract self-describing.
  5. One canonical IR, emitted identically by GUI, SDK, and AI, behind one validator.

Syntax hygiene ({{ }}, typed fields) lowers the rate of first-pass errors; the validation loop drives the rate of shipped errors to ~zero. Both matter; the loop matters more.

Context — current state (verified 2026-06-02)

This came out of a real incident — issue #1491, "record-change trigger plugin loads but flows never fire on data writes" (reported against 7.4.1, repro app hotcrm, flow lead_assignment). The reporter observed that POST /api/v1/data/crm_lead never stamped next_followup_date and inferred the flow "never runs" — but they explicitly noted they could not see any logs (the CLI forces the kernel logger to level:'silent'), so "never runs" was a hypothesis, not a verified fact.

What the incident actually was — established by reproducing it end-to-end against hotcrm's own 7.4.1 packages and artifact:

  • The trigger fires. With logging restored, the record-change trigger registers and binds all four record_change flows, and a write to crm_lead calls automation.execute('lead_assignment')success:true. The reporter's own evidence corroborates this: object-level L2 hooks (beforeInsert) fire on the same REST writes, and the trigger's afterInsert hook rides the same triggerHooks pipeline. The flow runs.
  • But it produces zero effect, because lead_assignment's decision/edge conditions were authored as '{record.rating} >= 4'. The single brace makes this invalid CEL — CEL reads { … } as a map literal, so it is a parse error (Expected COLON, got RBRACE; confirmed against @objectstack/formula). evaluateCondition returns false on a non-ok/throwing result (verified in 7.4.1 service-automation/dist: if (!result.ok) return false; … catch { return false }), so neither branch is taken, update_record never runs, and the SLA field is never stamped — exactly the reported symptom.

Not the cause (a related but distinct bug): the throwing-__require stub — where an ESM CommonJS require('@objectstack/formula') compiled to tsup's throwing stub and made every CEL eval throw → catch → false → all conditions skip — was a different issue (#1429, fixed by a6d4cbb6f, a static top-level import) and is already shipped in 7.4.1. So #1491 on 7.4.1 is the brace-in-CEL failure above, not the stub.

The shared villain behind both is the same and is the deeper point: a CEL parse/eval failure is silently swallowed to false. A malformed predicate and an unreachable engine are indistinguishable from "condition not met." For an AI author this is the worst possible property: the agent gets no signal, believes it succeeded, and ships a dead flow.

Supporting facts (verified in source):

  • Loose contract + coercion. ExpressionInputSchema (spec/shared/expression.zod.ts:84) transforms any bare string into {dialect:'cel', source}; flow.zod.ts only consumes it. A condition: string is silently treated as CEL with no opportunity to reject a bad form.
  • The spec teaches the bad form. automation/flow.zod.ts:212-214's own FlowSchema JSDoc example uses condition: "{amount} < 500" / "{amount} >= 500" — the exact single-brace-in-CEL pattern that silently fails. This is the concrete answer to "why did the AI write it wrong": not a missing skill — an actively wrong authoritative example the model faithfully copied.
  • Three syntaxes coexist, at scale. Across ../templates (10 packages, 30 flows): only 6 flow conditions, but 191 single-brace {…} template usages and 40+ double-brace {{…}} (titleFormat/notification). So by volume the dominant expression surface is interpolation, not predicates — and the single-brace delimiter is the one that collides with CEL. (Date helpers also split: template TODAY()/NOW() vs CEL today()/daysFromNow(int).)
  • Inconsistent failure policy. The same evaluation-failure decision is made five different ways: seed-loader (loud fail), hook-wrappers (warn + false), rule-validator (warn + skip → null), the engine's formula projection (silent null), flow evaluateCondition (silent false). No single declared policy.

The reframing — for an AI author, the loop is the product

It is tempting to "document the difference" or "pick safer brackets." Both treat the symptom. The defects, ranked by how badly they hurt a generative author:

  1. Silent failure is catastrophic for AI. A human notices "nothing happened" and investigates; an agent records success and moves on. Errors must be loud, located, and returned to the author.
  2. The contract is too loose and mode-ambiguous. condition?: string lets a syntactically illegal predicate serialize, and three look-alike syntaxes invite the over-generalization that is an LLM's single most common failure (it saw {x} work in notify.title, so it used it in a condition).
  3. The canon is wrong. Whatever the spec/skills show, the model emits. Anti-patterns in examples are training data.
  4. The author can't ask. An agent that can introspect "what dialect does this field expect, what fields/functions are in scope?" and validate before committing will self-correct; one that must guess will not.

A low-code expression layer for AI earns its keep by making mistakes impossible to ship silently and making the rule discoverable and checkable at authoring time. Today it does neither.

Decision

1. Validate by default; never fail silently; built for the agent loop. (The spine.)

  • 1a — Build-time parse. objectstack build / registerFlow / metadata registration parses every expression; a parse error is a build failure with file:line and the offending source. (#1491's {record.rating} >= 4 fails here instead of becoming a runtime false.)
  • 1b — Schema-aware. Expressions are parsed against the target object's field/type environment. v1: field existence + predicate-returns-bool (record.raitng"crm_lead has no field raitng — did you mean rating?"). v2: full type inference (number-vs-string compares, function overloads).
  • 1c — No silent runtime fallback. Remove every "error → false/null" swallow. One declared EvalResult policy replaces today's five: parse failure → build error (1a); runtime fault → logged, attributed failure (never a swallowed false).
  • 1d — Errors written for self-correction. The message must state what is wrong and the correct form: e.g. "conditions are bare CEL, not templates — you wrote {record.rating} >= 4; use record.rating >= 4." This message contract is tested, not incidental — it is the interface the agent repairs against.
  • 1e — Author-accessible validation + introspection. The same validator is exposed as an agent-callable tool (validate_expression(fieldRole, source, objectName)), and each field's expected dialect + in-scope fields/functions are introspectable. The object schema is fed into the authoring context. So the agent checks while writing, not only at build — collapsing the correction loop and removing guesswork.

2. Two structurally un-confusable field shapes; the field type names the dialect.

  • Expression fields (predicate, computed value) = whole-field CEL, no delimiters. condition: Predicate, dueDate: Expr<Date>. Never a raw string.
  • Text fields = Template (Decision 3).

Rationale (AI-first): the #1 LLM error is mode over-generalization — copying {x} across fields with different rules. Make the modes look nothing alike (no-braces predicate vs {{ }} template) and let the TS type name the dialect — an in-context signal the model reads directly off the field type. Because predicates contain no braces by construction, the #1491 pattern has nowhere to live; and because the field is typed Predicate not string, the wrong shape is a type error. (Note the division of labor: the type rejects wrong shape; Decision 1 rejects wrong content. Neither alone is sufficient — together they close both.)

3. Text templates: {{ }} holes, restricted to paths + formatters, with defined value→string semantics.

  • One delimiter, {{ }}; single { } deleted. { } collides with CEL map literals (the physical #1491 trap); {{ }} does not, carries a universal mustache prior (low LLM error), and — decisively for .ts authoring — does not collide with TypeScript tagged-template ${…} interception, whereas ${…} would. A {{…}} accidentally pasted into a predicate still fails to parse and is caught loudly by Decision 1.
  • Holes are a restricted CEL subset: field/variable paths plus a whitelisted formatter set (format, datetime, currency, number, …) — not arbitrary CEL logic. Rationale: a smaller hole grammar is a smaller error surface for AI, lets the GUI offer a field picker, and keeps display strings declarative — real logic is forced back into validated Predicate/Expr fields where it is visible and checked.
  • Defines value→string semantics (numbers, dates, money, null, locale) that arbitrary-CEL holes left undefined — formatting is explicit ({{ currency(record.amount) }}), not implicit coercion.

4. Correctness is training data.

  • Fix the canon first. Remove every anti-pattern from the spec's own JSDoc (flow.zod.ts:212-214's {amount} < 500), skills, and guides before shipping the contract — the model emits what it is shown.
  • Ship a golden example set per field role (predicate / template / computed value), copy-pasteable and correct, that authoring agents are pointed at.
  • Make the contract self-describing (Decision 1e) so the agent discovers the rule rather than inferring it from priors.

5. One canonical IR, three front-ends.

GUI condition/template builders, .ts SDK tagged templates (cel`…` / tpl`…`), and AI generation all emit the same canonical Expression IR ({ dialect, source, ast? }) and run through the same validator. The surfaces cannot drift; every author — human, dev, LLM — is held to one contract.

Design judgment (resolving the forks)

  • Looped vs one-shot authoring → design for both. The validation loop (Decision 1) is the spine and serves agents that can build/iterate; the structural first-pass reductions (Decisions 2–4) keep one-shot authoring (NL→flow with no compile loop) safe too. Do not rely on the loop alone (fragile when there is none) and do not force structured-only authoring (verbose, fights model priors).
  • Predicates stay free-text CEL, not mandatory structured AST. Models are strong at CEL, and schema-aware validation (1b) makes free text safe. A structured {field, op, value} AST remains available as an alternate serialization the GUI/one-shot generators may emit into the same IR — offered, not required.
  • Schema-aware depth in v1 = field-existence + bool-return (highest ROI, needs only the resolved object schema at build). Full type inference is v2.

Representation / contract summary

Field role Author writes Serialized IR Validated as
Predicate (condition, guard, validation, sharing, visibility) cel`record.amount > 1e5` (no braces) {dialect:'cel', source, ast?} parses · returns bool · fields exist
Computed value (dueDate, filter values) cel`daysFromNow(3)` (no braces) {dialect:'cel', …} parses · result type fits field
Text template (notify.title/body, titleFormat, email/notification) tpl`Hot lead: {{ record.full_name }}` {dialect:'template', source, ast?} each {{…}} is a path/formatter · fields exist

Out of scope (separate surfaces, intentionally not unified): query-filter operators ({ field: { $lte: … } }, MongoDB-style) and cron schedules. "One expression language" means predicates + computed values + template holes resolve to CEL semantics — not literally one syntax for everything.

Consequences

Positive

  • Silent failure — the property that is catastrophic for an AI author — is gone: parse errors are build failures, runtime faults are loud, and the author gets a fixable message (1a/1c/1d).
  • The #1491 mode-confusion pattern is structurally impossible (predicates have no braces) and would also be a type error (Decision 2) and a build error (Decision 1).
  • The agent can validate and introspect before committing (1e) — the correction loop runs at authoring time, not in production.
  • Schema-aware checks raise quality across all CEL surfaces (flows, validations, sharing, formula fields), not just flows.

Costs / risks

  • Schema-aware validation is real engineering: the resolved object schema must be projected into a CEL type environment fed to the compiler. Pre-launch is when this is affordable; retrofitting is not.
  • Restricted template holes + a formatter whitelist is more design work than "interpolate anything," and must cover the real formatting needs (dates/money/percent/locale) or authors hit walls.
  • Typed expression fields are more verbose than raw strings (mitigated: cel `` is terse, and verbosity buys "wrong = compile error").
  • The change touches spec / formula / service-automation / CLI / agent-tooling / designer together — must be sequenced as one coherent pre-launch change.

Sequencing (roadmap, ordered by AI-safety ROI)

  1. Stop the silent failure + fix the canon (ship first, decisive). One EvalResult policy: parse → build error, runtime fault → loud attributed failure; delete every error→false/null swallow (evaluateCondition + the other four). Simultaneously remove the anti-pattern examples from spec JSDoc/skills (flow.zod.ts:212-214). This alone kills the #1491/#1429 class.
  2. Build-time parse validation + error-message contract. CLI/registration parses every expression; failures carry file:line, source, and the corrective message (1d).
  3. Contract/types. spec: expression fields become typed (Predicate / Template / Expr<T>); remove bare-string acceptance + the shared/expression.zod.ts:84 coercion. Land cel / tpl builders. Two shapes, no single brace.
  4. Schema-aware validation (v1) — project resolved object schema into the CEL type env; field-existence + bool-return. (v2: full type inference.)
  5. Agent tooling — expose validate_expression + field-dialect/scope introspection; feed schema into the authoring context.
  6. Template engine{{ }} holes (paths + formatter whitelist) with defined value→string semantics; delete the single-brace resolver; unify date helpers under CEL stdlib.
  7. Designer — GUI condition/template builders emit the canonical IR through the shared validator.

Non-goals / deferred

  • Mandatory structured/AST authoring for predicates: rejected — verbose, fights model priors; free-text CEL + schema validation is safe and AST stays available as an option.
  • Arbitrary CEL inside template holes: rejected — larger error surface, un-pickable in GUI, invites logic-in-display-strings; holes are paths + whitelisted formatters.
  • Replacing CEL with JS/Power Fx: rejected — CEL is sandboxed, statically checkable, non-Turing-complete, and AI-legible.
  • Unifying query-filter operators / cron into the expression language: out of scope.

Open questions (need a decision)

  1. Resolved schema at build time. 1b's depth depends on whether the build pipeline can obtain the fully resolved object schema (post overlay/extension, ADR-0005/0010) at compile time. If only partial schema is available pre-deploy, v1 schema checks may need to run at registration rather than build. Confirm what schema the build has.
  2. Formatter whitelist surface. Which formatters ship in v1 (currency/datetime/number/percent/truncate/…) and how locale is threaded into them — needs a short catalog before the template engine work (step 6).

Already shipped / observed on this line of work

  • #1429 (a6d4cbb6f) fixed the throwing-__require stub by switching service-automation to a static import { ExpressionEngine } from '@objectstack/formula', and populated hookContext.previous for update hooks. Shipped in 7.4.1. It removed the engine-unreachable runtime fault but left the underlying catch → false swallow in place — which is why #1491 (a malformed predicate on the same swallow) still presents identically. Decision 1 closes the swallow itself.
  • @objectstack/formula envelope routing exists ({dialect, source, ast}), CEL stdlib (now()/today()/daysFromNow(int)/isBlank/coalesce), and the legacy single-brace template resolver (service-automation/builtin/template.ts). This ADR converges these onto one language + one delimiter and, more importantly, adds the validate-by-default loop, schema-awareness, corrective errors, and agent introspection the current seam lacks.