ADR-0058: The Expression & Predicate Surface — One Authoring Language, Two Backends, and the Pushdown-Compiler Reconciliation (#1887)
Status: Accepted (2026-06-21) — implemented: canonical CEL→Filter compiler (formula/src/cel-to-filter.ts), RLS + sharing cutover (rls-compiler.ts, bootstrap-declared-sharing-rules.ts), check enforced on writes, expression-surface conformance ledger CI-gated (dogfood/test/expression-conformance.ledger.ts).
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0049 (enforce-or-remove), ADR-0054 (runtime proof), ADR-0055 (RLS reuses pre-resolved membership IN-form; no compiler subquery), ADR-0056 (permission-model landing), ADR-0057 (ERP authz core)
Consumers: @objectstack/formula, @objectstack/objectql, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/service-analytics, @objectstack/service-automation, @objectstack/spec, @objectstack/verify
Closes / supersedes: issue #1887 (SharingRuleSchema disconnected from the live engine). Reconciles ADR-0056 D4/D5 (RLS no-silent-drop / sharing spec↔runtime) and ADR-0057 D6 (declared-rule seeding, deferred CEL compiler).
ObjectStack exposes ~50 authorable declarations that hold an expression — formulas, visibility/required/readonly predicates, validation rules, hook conditions, flow/edge conditions, sharing-rule conditions, RLS using/check, action/view/app visibility, notification/ETL/export/sync/connector conditions — and they all funnel through one authoring primitive (ExpressionInputSchema → { dialect: 'cel', source }, helpers cel/F/P). The authoring surface is already unified and clean.
The runtime is not. There are two evaluation backends:
- Interpret-against-a-record — one rich, correct CEL interpreter (
@objectstack/formulacel-engine.ts, wrapping@marcbachmann/cel-js: full operators/functions/macros/temporal). ~40 surfaces use it and are honestly enforced. - Compile-to-a-query-filter (pushdown) — fragmented into three divergent, hand-rolled front-ends that do NOT share the interpreter's AST: a 4-form regex (
rls-compiler.ts), a field-equality-only translator (celToFilterin sharing seeding), and a richFilterCondition→SQL backend (read-scope-sql.ts) that the front-ends barely reach.
The fragmentation is the root of the platform's predicate-honesty debt: the spec's CEL sharing-rule condition is never compiled (#1887 — "authoring a rule does NOT grant access"), the RLS check clause is declared but unenforced, and identical CEL means different things depending on which surface evaluates it. On top of that, the silent-fail policy is inconsistent across surfaces (formula → silent null unlogged; hook → false; validation → skip; flow → throw; RLS → drop+warn/deny; sharing → silent empty).
This ADR is the whole-surface audit + consolidation (the expression-layer analogue of ADR-0056's permission-model landing). It decides: one canonical CEL→FilterCondition pushdown compiler built on the interpreter's AST (retiring the regex + celToFilter), the supported pushdown subset (reaching the existing rich SQL backend; no subqueries per ADR-0055), the reconciliation of sharing condition (#1887) and RLS check, a single fail-policy matrix (compile-error at authoring / fail-closed for security / fail-soft-but-logged for non-security), and a durable Expression Surface Conformance ledger so the surface stays landed. Non-expression experimental subsystems (encryption/masking/compliance/policy/audit/runAs/transfer) remain under ADR-0056 D8 — referenced, not re-decided.
┌──────────────────────────────────────────────┐
AUTHORING │ ExpressionInputSchema → { dialect:'cel', source } │ one language (CEL),
(~50 decls) │ helpers: cel / F(ormula) / P(redicate) / tmpl / cron │ ~50 declarations
└──────────────────────────────────────────────┘
│
┌──────────────────────┴───────────────────────┐
▼ ▼
① INTERPRET per record ② COMPILE to query FILTER (pushdown)
@objectstack/formula cel-engine.ts (fragmented — the problem)
(@marcbachmann/cel-js, FULL CEL) ├─ rls-compiler.ts (regex, 4 forms)
~40 surfaces, honestly enforced ├─ celToFilter (field-equality only)
formula / visibility / validation / └─ read-scope-sql.ts (rich FilterCondition→SQL
hook / flow / notification / UI / ETL … BACKEND — under-reached by the front-ends)
│ │
▼ ▼
native JS value (boolean / value) driver WHERE / SQL (no subquery, ADR-0055)
The authoring side and backend ① are good. The debt is entirely in backend ② being three disconnected front-ends that don't reuse the parser/AST of ①.
| Compiler | File | Grammar it accepts | Uncompilable → |
|---|---|---|---|
| RLS compiler | plugin-security/src/rls-compiler.ts |
4 regex forms only: 1=1, f = current_user.x, f = 'lit', f IN (current_user.arr) |
drop + WARN (ADR-0056 D4); deny if it was the only policy |
Sharing celToFilter |
plugin-sharing/src/bootstrap-declared-sharing-rules.ts |
field-equality only record.f == literal |
skip rule (logged [experimental]) |
| Read-scope SQL | service-analytics/src/read-scope-sql.ts |
rich: $eq/$ne/$gt/$lt/$gte/$lte/$in/$nin/$between/$contains/…/$and/$or/$not |
throw (fail-closed) |
Critical: the analytics path proves a rich FilterCondition→SQL backend already exists — the missing piece is a CEL→FilterCondition front-end that reaches it. The RLS regex and celToFilter are the under-powered front-ends. They share no code with each other or with the interpreter.
Two declared-but-unenforced gaps fall out:
- Sharing-rule
condition(#1887) — specCriteriaSharingRuleSchema.condition: ExpressionInputSchema(CEL), but the runtime matchessys_sharing_rule.criteria_json(a JSONFilterCondition); the CEL is never compiled except the field-equality subset ADR-0057 D6 added at seeding. Spec self-flags⚠️ EXPERIMENTAL — NOT ENFORCED. - RLS
checkclause —rls.zod.tsdeclarescheckfor INSERT/UPDATE validation; zero runtime consumers read it (onlyusingis compiled). Declared-but-unenforced (ADR-0049).
One interpreter: @objectstack/formula cel-engine.ts (@marcbachmann/cel-js), full CEL with bounded execution. Its ~40 consumers are honestly enforced — but each invents its own fail policy:
| Surface | File | Fail-on-unparseable |
|---|---|---|
| Flow / edge / decision condition | service-automation/engine.ts |
THROW (loud) |
Hook lifecycle condition |
objectql/hook-wrappers.ts |
→ false (logged) |
Validation (script/cross_field/when/requiredWhen/readonlyWhen) |
objectql/validation/rule-validator.ts |
skip rule (logged) |
Formula field (Field.expression) |
objectql/engine.ts applyFormulaPlan |
→ null, NOT logged |
| Seed dynamic value | objectql/seed-loader.ts |
error, drop record (loud) |
The interpreter and the RLS compiler share no grammar or AST — confirmed. CEL record.amount > 1000 works in a hook/flow/formula but is silently inexpressible in an RLS using or a sharing condition.
ExpressionInputSchema (spec/src/shared/expression.zod.ts): a bare string .transform(s => ({ dialect:'cel', source:s })); the canonical envelope is { dialect, source, ast?, meta? }, dialect ∈ {cel, js, cron, template}. Helpers cel/F/P all emit CEL; tmpl → Mustache; cron → cron. ~50 declarations consume it (formula, visibleWhen/readonlyWhen/requiredWhen, validation, hook, flow edge, action visible/disabled, view/app/page visibility, notification condition/recipients, ETL/export/sync/connector/graphql conditions, feature flags, cache invalidation, …).
Of these, the expression-surface experimental/divergent set is exactly two: sharing condition (#1887) and RLS check. The rest of the EXPERIMENTAL — not enforced markers (PolicySchema password/network/session/audit, EncryptionConfig, MaskingRule, GDPR/HIPAA/PCI, SecurityContext, RLSAuditEvent, RLSConfig, flow runAs, allowTransfer/Restore/Purge) are whole subsystems with no runtime consumer — already governed by ADR-0056 D8 / ADR-0049 and out of scope here (they are not predicate-compiler problems).
Governing rules: ADR-0049 (enforced / experimental / removed), ADR-0054 (proof per enforced high-risk surface), ADR-0055 (pushdown is pre-resolved IN-form, never compiler subqueries). One decision per gap.
Build a single pushdown compiler in @objectstack/formula (next to the interpreter) that takes the same parsed @marcbachmann/cel-js AST the interpreter uses and lowers the pushdown-able subset to a FilterCondition. It replaces both plugin-security/rls-compiler.ts's 4-form regex and plugin-sharing's celToFilter. There is then exactly one CEL parser and one CEL→filter lowering, feeding the existing rich FilterCondition→SQL backend (read-scope-sql.ts).
- No more bespoke regex grammars; no more "this surface understands a different CEL subset than that one."
- The compiler is pure (AST →
FilterCondition), driver-agnostic, and unit-testable without a kernel.
The compiler supports the subset the FilterCondition→SQL backend already handles: ==, !=, >, <, >=, <=, in/IN, not in, &&/AND, ||/OR, !/NOT, null/exists checks, and string ops (contains/startsWith/endsWith). Operands: a record field on one side; on the other a literal, a current_user.* scalar, or a pre-resolved current_user.<key> set (rlsMembership, ADR-0055).
- No subqueries / no cross-object traversal (ADR-0055 stands) — set membership comes from pre-resolved
current_user.*keys. - A predicate on a compile surface (RLS
using/check, sharingcondition, analytics read-scope) that the compiler cannot lower is an authoring-time compile error (objectstack compile/defineStack), never a silent drop or "matches nothing" (ADR-0056 D4 generalized to all pushdown surfaces). The error names the unsupported node and, where applicable, suggests the pre-resolved-INrewrite.
With D1/D2, the spec sharing-rule condition (CEL) is compiled to criteria_json via the canonical compiler at seed/define time — no longer field-equality-only. The sys_sharing_rule runtime shape stays canonical (storage), but it becomes a faithful lowering of the authored CEL, not a divergent hand-write. owner-type rules and dynamic recipients (role_and_subordinates→unit_and_subordinates, ADR-0057 D5) reconcile to the recipient model; truly non-static cases (e.g. ownership that depends on live role membership) resolve via the pre-resolved current_user.* membership form, not a stored static filter. The spec's ⚠️ EXPERIMENTAL — NOT ENFORCED block on SharingRuleSchema is removed once the compiler lands; remaining unmappable recipients (group/guest) are explicitly [experimental] or removed per ADR-0049 — no third "looks-authorable-but-isn't" state.
Compile the RLS check clause (defaulting to using when omitted) with the same canonical compiler and enforce it on the write pre-image path that already exists for by-id writes (ADR-0056/#1994) and on the AST-injected bulk path. A check that the compiler cannot lower is a compile error (D2). This closes the declared-but-unenforced check gap (ADR-0049).
Today each surface invents its own behavior. Standardize on three tiers keyed by (when) × (security-relevance):
| When / what | Policy |
|---|---|
| Authoring (any surface) — parse error, unknown function/var, type error | compile error (objectstack compile / defineStack) — never ships |
| Authoring (compile surface) — valid CEL but not pushdown-able | compile error (D2) — never silently degrades |
Runtime, SECURITY predicate (RLS using/check, sharing) |
fail CLOSED — deny / empty-visible, never over-share; logged WARN with the policy name |
| Runtime, NON-security predicate (formula, validation, hook, visibility, flow) | fail soft, ALWAYS logged — formula → null + log (fixes today's silent-null), validation → skip + log, hook → false + log, flow → throw (author bug) |
This keeps the deliberate "a broken non-security rule must not brick CRUD" posture, but removes the two honesty holes: the formula's unlogged null and the sharing-rule's silent empty on a criteria query failure both become logged, and security predicates are uniformly fail-closed.
A declaration's evaluation mode is fixed by its surface, not guessed: RLS using/check, sharing condition, and analytics read-scope are compile (pushdown) surfaces; everything else is interpret (per-record). Both backends consume the same parsed AST. There is no silent fallback from compile to interpret — a compile-surface predicate that needs interpretation (non-pushdownable) is a D2 compile error, surfaced to the author, not silently evaluated row-by-row (which would defeat pushdown / leak via N+1).
Extend the ADR-0056 D10 conformance concept and the ADR-0054 proof registry to the expression surface: one row per expression-holding declaration, carrying { field-path, dialect, mode (interpret|compile), evaluator site (file:line), state (enforced|experimental|removed), fail-policy, proof-ref? }. CI asserts: every ExpressionInputSchema declaration is classified; every compile-mode declaration is reachable by the canonical compiler (no orphan pushdown surface); every enforced security expression carries a dogfood proof. "The expression surface is landed" becomes a green check; a new declared-but-unenforced predicate (the #1887 class) breaks the build. This is the durable deliverable — the audit, encoded.
This ADR governs the expression / predicate / formula surface only. The non-expression [EXPERIMENTAL — not enforced] subsystems (PolicySchema, Encryption, Masking, Compliance, SecurityContext, RLSAuditEvent, RLSConfig, flow runAs, allowTransfer/Restore/Purge) remain under ADR-0056 D8 / ADR-0049 and their own tracking issues (#1882/#1883/#1888). They are referenced here for completeness, not re-decided.
Positive.
- One CEL means one thing everywhere — a predicate that works in a hook/formula compiles to the same semantics in an RLS/sharing filter (within the pushdown subset), so authors (and the AI) stop hitting "silently does nothing here" surprises.
- #1887 closes honestly — sharing
conditionis enforced as authored, not a divergent hand-write; RLScheckis enforced. - No fragmented grammars — the regex front-ends die; one tested compiler feeds the already-rich SQL backend.
- AI-safe — every pushdown predicate either compiles or errors at authoring time; no silent fail-open, formula failures are observable, security predicates fail closed.
- Self-verifying (D7) — a new unenforced predicate breaks CI, not production.
Negative / costs.
- The canonical compiler is security-critical (a wrong lowering = wrong enforcement) — it must land with adversarial dogfood proofs (ADR-0054) and a thorough operator/variable test matrix.
- Making non-pushdownable compile surfaces a hard error is mildly behavior-changing for any existing config that authored an un-lowerable RLS/sharing predicate and silently got "nothing" — those now fail the build (intended; that is the #1887 disease surfacing). Provide a clear error + the pre-resolved-
INrewrite path. - Standardizing fail-policy touches several runtimes (formula/validation/hook/sharing) — small but cross-cutting.
Neutral / open.
- Whether the canonical compiler lives in
@objectstack/formula(next to the interpreter, sharing the AST — recommended) or a thin@objectstack/formula/compilesubpath. - Whether to keep
read-scope-sql.tsas the soleFilterCondition→SQL lowering or fold it into the driver layer — out of scope.
- Not replacing the CEL interpreter or the source language — CEL +
ExpressionInputSchemastay; this unifies the compile path to match the interpret path. - Not adding subqueries / cross-object joins to RLS (ADR-0055 stands; pushdown uses pre-resolved
IN). - Not designing the non-expression governance subsystems (D8 scopes them out).
- Not a new dialect —
js/cron/templatedialects are unaffected.
- (a) Leave three front-ends, just extend each. Rejected — perpetuates "different CEL subset per surface", the exact root of #1887; triples the test surface; no shared AST.
- (b) Interpret everything per-record (drop pushdown). Rejected — N+1 / full-scan for RLS/sharing/analytics; defeats driver pushdown; ADR-0055's whole point is set-membership pushdown.
- (c) Make sharing
conditiona stored JSON filter (drop CEL there). Rejected — splits the authoring language (CEL everywhere except sharing); the unifiedExpressionInputsurface is a strength to preserve. - (d, chosen) One AST, two backends; one canonical pushdown compiler + conformance ledger.
- P1 — Canonical compiler (no behavior change). Build the CEL→
FilterConditioncompiler on the cel-js AST; cover the D2 subset; unit-test the operator/variable matrix. Land behind the existing call sites (parity with the 4-form regex first). - P2 — Cut over + fail-policy. Replace
rls-compiler.tsregex andcelToFilterwith the compiler; D5 fail-policy (compile-error on non-pushdownable; formula/sharing logging fixes). Dogfood: RLSusingwith>/AND/ORnow enforces; uncompilable predicate is a compile error. - P3 — Close #1887 + RLS
check. D3 sharingconditioncompiled end-to-end; D4checkenforced on the write path. Remove theEXPERIMENTALmarkers; dogfood proofs (sharing rule with a real>/INcondition grants/denies;checkblocks an invalid write). - P4 — D7 conformance ledger. Expression Surface Conformance rows + CI gate; bind the enforced security-expression proofs.
- ADRs: 0049, 0054, 0055, 0056, 0057. Issues: #1887 (sharing CEL divergence), #1888 (flow runAs), #1882 (policy), #1883 (transfer/restore/purge).
- Authoring primitive:
packages/spec/src/shared/expression.zod.ts(ExpressionInputSchema,cel/F/P/tmpl/cron). - Interpreter:
packages/formula/src/cel-engine.ts(@marcbachmann/cel-js). - Compile-to-filter front-ends:
packages/plugins/plugin-security/src/rls-compiler.ts,packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts(celToFilter); rich backend:packages/services/service-analytics/src/read-scope-sql.ts. - Divergent declarations:
packages/spec/src/security/sharing.zod.ts(condition, EXPERIMENTAL block),packages/spec/src/security/rls.zod.ts(using/check). - Silent-fail sites:
objectql/src/engine.ts(formula null),objectql/src/hook-wrappers.ts(hook false),objectql/src/validation/rule-validator.ts(skip),plugin-sharing/src/sharing-rule-service.ts(silent empty),service-automation/src/engine.ts(throw).