Skip to content

formula: retire the js expression dialect — redundant with L2 ScriptBody; hasDialect also mis-reports the stub as real #3278

Description

@os-zhuang

Summary

Reframed from the original report. This is not "an engine is missing — ship it." It is: the js expression dialect should be retired, because js already has a correct, real home as the L2 ScriptBody escape hatch, and a second js surface at the L1 expression layer is redundant and works against the platform's AI-safety model.

Separately, the original title's premise is inverted — hasDialect('js') returns true, not false — because the stub-detection in hasDialect is dead code. That is a real latent bug worth fixing regardless of the js decision.

Correction to the original report

  • hasDialect('js') returns true, not false (verified empirically).
  • Root cause: hasDialect detects stubs via dialect.startsWith('stub:'). But stubs are registered under their real name — makeStub('js') produces { dialect: 'js', … }, and register keys the map on engine.dialect, so a stub must carry the real name to be found by registry.get('js'). No engine is ever registered with a stub:-prefixed dialect, so the check is dead code and hasDialect collapses to registry.has(). It reports the js stub as a real engine.
  • False-green test: hasDialect distinguishes real engines from stubs only asserts hasDialect('cel') === true; it never asserts the stub case. If it asserted hasDialect('js') === false it would fail today. The test name promises a guarantee it does not check.
  • Stale doc comment: registry.ts:8 calls cron a stub, but cron is a real engine (registry.ts:47). Only js is a stub.

Two js surfaces — do not conflate

Slot Status Nature
js expression dialect (this issue) L1 expression envelope — the same slot as CEL: formula fields, validation/visibility/sharing predicates, flow conditions stub, unshipped procedural js smuggled into the declarative slot
ScriptBody language: 'js' L2 hook/action body{ language:'js', capabilities:[…] }, sandboxed + capability-gated (hook.zod.ts:99, action.zod.ts:335) real, keep the deliberate procedural escape hatch

The platform already ships a clean two-tier model: L1 = CEL (declarative predicates/formulas) and L2 = sandboxed js (procedural bodies). "Given CEL exists, why js?" — because you want an escape hatch, and that escape hatch already exists at L2. The js expression dialect is a third thing that belongs to neither tier.

Why retire rather than ship

  1. Redundant with L2. Procedural js already has a home. A js-as-formula layer just overlaps it.
  2. Corrosive to the AI-safety model. CEL is non-Turing-complete, side-effect-free, and statically analyzable → it can be linted, explained, diffed, and reviewed in the draft-gating flow (ADR-0033), and rejected at author time by the validate gate. AI-authored CEL is verifiable before it runs. Arbitrary js is not — you can only know it is correct by running it. ADR-0096 explicitly warns on "handing AI a trusted body" and steers execution surfaces toward a bounded posture; a new unbounded js expression surface reverses that trajectory and undercuts the "structured, lintable/explainable/diffable" AI-native thesis.
  3. Ownership cost. Shipping @objectstack/plugin-js-vm means owning a JS sandbox (sandbox-escape is an entire CVE class) — for a capability that duplicates L2.
  4. Declared ≠ enforced drift. The ExpressionDialect enum advertises a capability that does not exist, inviting authors and the AI build agent to try dialect: 'js'. This is the same declared-but-unenforced drift Audit: several event/subscription/connector enums are schema-only (declared, no runtime consumer) #3197 / docs(spec): annotate schema-only event/subscription/connector enums as not-yet-enforced (#3197) #3212 keep cleaning up.

Recommendation

Primary — remove it. Drop js from ExpressionDialect and delete the stub (registry.ts:51). Precondition: confirm there are no dialect: 'js' consumers (open-core = 0 today; if cloud/EE has any, go through a deprecation window). Keep js strictly at L2 ScriptBody.

Fallback — only if there is real inline-js-formula demand (e.g. migrating Salesforce formula fields that exceed CEL): do not default it into open-core. Ship it as an explicit opt-in enterprise plugin, keep CEL the strong default, and require explicit registration plus a load-time lint warn. This keeps the AI build agent emitting CEL only, with js as a loud human-only opt-in — consistent with ADR-0096's bounded posture.

Regardless of the js decision, fix the hygiene bugs now (independent of the strategy above):

  • Mark stubs explicitly (e.g. a stub: true field on the engine) and make hasDialect honor it, so it returns false for stubs — the behavior its name and test title already claim.
  • Add the missing assertion expect(hasDialect('js')).toBe(false) (prove the gate can go red before trusting it).
  • Fix the stale registry.ts:8 comment (cron is real, not a stub).

Provenance

Found during the #1880 docs implementation-accuracy audit (PR #3243); deepened into an architecture question during evaluation. Supersedes the original "ship the plugin or annotate the enum" framing — annotation preserves both the drift and the authoring invitation; removal (or hard opt-in gating) eliminates them.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions