Skip to content

Date-typed formula fields always evaluate to null — objectql applyFormulaPlan binds raw string values + silently swallows the CEL type fault (ADR-0032 §1c) #1530

@xuyushun441-sys

Description

@xuyushun441-sys

Summary

Every formula field whose expression does a date comparison/arithmetic against the CEL date stdlib (today(), daysFromNow(), daysAgo(), now()) evaluates to null at runtime. Numeric/string formula fields are unaffected.

Two distinct defects compound here, both inside @objectstack/objectql's formula-field projection:

  1. Functional (root cause): no schema-aware value hydration. applyFormulaPlan binds the raw driver row into the CEL activation. date/datetime field values come back from the driver as strings ("2026-06-20" / ISO), but the CEL stdlib date helpers return google.protobuf.Timestamp. CEL has no string <=> Timestamp overload, so the comparison raises no such overload and the whole formula faults.

  2. Observability: silent swallow. The fault is then collapsed to null by rec[fp.name] = r.ok ? r.value : null — no log, no attribution. This is exactly the "engine's formula projection (silent null)" failure mode that ADR-0032 §Decision 1c already commits to removing (and that the ADR's "Inconsistent failure policy" inventory explicitly lists).

Net effect: the formula looks correct, objectstack build (ADR-0032 §1a validator) passes it, and it silently produces null forever.

Affected code

packages/objectql/src/engine.tsapplyFormulaPlan (≈L87):

function applyFormulaPlan(plan: FormulaPlanEntry[], records: any[]): void {
  if (!plan.length) return;
  for (const rec of records) {
    if (rec == null) continue;
    for (const fp of plan) {
      const r = ExpressionEngine.evaluate(fp.expression, { record: rec }); // ← rec has raw string dates
      rec[fp.name] = r.ok ? r.value : null;                                // ← fault swallowed to null
    }
  }
}

planFormulaProjection (same file, ≈L36) already holds the full schema.fields with each def.type, so the field types needed to hydrate date/datetimeDate are available at this layer — they just aren't used.

Supporting pieces (not bugs themselves, shown for the type story):

  • packages/formula/src/stdlib.tstoday()/daysFromNow(int)/daysAgo(int)/now() are all registered as …: google.protobuf.Timestamp.
  • packages/formula/src/cel-engine.tscoerce() deliberately passes non-Date values through unchanged; buildScope() forwards ctx.record verbatim. Neither is schema-aware, by design — so date hydration has to happen at the objectql layer that does know the schema.

Minimal reproduction

Run against @objectstack/formula@7.6.0 (pinned now, no DB needed):

import { celEngine } from '@objectstack/formula';
const now  = new Date('2026-06-02T08:00:00Z');
const expr = { dialect: 'cel',
  source: 'record.end_date != null && record.status == "active" && record.end_date <= daysFromNow(60) && record.end_date >= today()' };

celEngine.evaluate(expr, { now, record: { status:'active', end_date: '2026-06-20' } });                 // STRING
celEngine.evaluate(expr, { now, record: { status:'active', end_date: '2026-06-20T08:15:35.244Z' } });   // ISO STRING
celEngine.evaluate(expr, { now, record: { status:'active', end_date: new Date('2026-06-20') } });        // Date

Output:

date field as STRING "2026-06-20"         -> ERROR runtime: no such overload: dyn<string> >= google.protobuf.Timestamp
date field as ISO string "...T08:15:35Z"  -> ERROR runtime: no such overload: dyn<string> >= google.protobuf.Timestamp
date field as Date object                 -> ok value=true
numeric control (>= 50000)                -> ok value=true

A timestamp(record.end_date) <= daysFromNow(60) cast also returns true — confirming the engine itself is correct and the gap is purely the type of the value bound in.

End-to-end symptom (full runtime, ../templates on 7.6.0)

Via REST against the all template environment, real seeded records:

contracts_contract: approval_required (total_value >= 50000) = true/false  ✅   (numeric)
contracts_contract: is_expiring_soon / is_auto_renewing_soon              = null ❌ (date)
todo_task:          is_overdue                                             = null ❌ (date)
hr_employee:        tenure_years  (floor((today() - hire_date)/365))       = null ❌ (date arithmetic)

Query-driven dashboard widgets that filter on the same dates (e.g. contracts "expiring in 60 days", helpdesk SLA KPIs) render correctly, because they go through ObjectQL/SQL filtering, not the CEL formula-field path. So the defect is isolated to formula-field projection.

Affected formula fields across the official templates (all idiomatic, all pass objectstack build): todo_task.is_overdue, contracts_contract.{is_expiring_soon,is_auto_renewing_soon}, contracts_obligation.is_overdue, hr_employee.tenure_years, hr_document.{is_expiring_soon,is_expired}, compliance_*, expense_*, procurement_order.*, content_piece.is_overdue.

Why this is a platform issue, not a template/metadata issue

  • The templates author the documented, validator-blessed form: bare record.<dateField> <= daysFromNow(n) (the shape objectstack-formula / the spec stdlib teach). Requiring authors to wrap every date field in timestamp(...) would be a workaround that masks the platform gap — and is hostile to the ADR-0032 design center (AI authors writing the natural form).
  • The objectql layer is the one place that has both the raw value and the schema type, so hydration belongs there.

Suggested fix

  1. Hydrate by field type before evaluation in applyFormulaPlan (use the schema.fields[...].type already available in planFormulaProjection): convert date/datetime/time field values to Date for the activation, so the CEL stdlib's Timestamp overloads match. (Other typed coercions — e.g. numeric strings — can follow the same schema-aware path.)
  2. Stop swallowing to null — land ADR-0032 §1c here: a runtime fault in a formula field should be a logged, attributed failure (object/field/expression + the no such overload message), not a silent null. Note that fixing (2) without (1) just turns every date formula from silently-null into loudly-failing — both halves are needed.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions