Repository navigation
Date-typed formula fields always evaluate to null — objectql applyFormulaPlan binds raw string values + silently swallows the CEL type fault (ADR-0032 §1c) #1530
Description
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:
-
Functional (root cause): no schema-aware value hydration.
applyFormulaPlanbinds the raw driver row into the CEL activation.date/datetimefield values come back from the driver as strings ("2026-06-20"/ ISO), but the CEL stdlib date helpers returngoogle.protobuf.Timestamp. CEL has nostring <=> Timestampoverload, so the comparison raisesno such overloadand the whole formula faults. -
Observability: silent swallow. The fault is then collapsed to
nullbyrec[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.ts — applyFormulaPlan (≈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/datetime → Date are available at this layer — they just aren't used.
Supporting pieces (not bugs themselves, shown for the type story):
packages/formula/src/stdlib.ts—today()/daysFromNow(int)/daysAgo(int)/now()are all registered as…: google.protobuf.Timestamp.packages/formula/src/cel-engine.ts—coerce()deliberately passes non-Date values through unchanged;buildScope()forwardsctx.recordverbatim. 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') } }); // DateOutput:
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 shapeobjectstack-formula/ the spec stdlib teach). Requiring authors to wrap every date field intimestamp(...)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
- Hydrate by field type before evaluation in
applyFormulaPlan(use theschema.fields[...].typealready available inplanFormulaProjection): convertdate/datetime/timefield values toDatefor the activation, so the CEL stdlib'sTimestampoverloads match. (Other typed coercions — e.g. numeric strings — can follow the same schema-aware path.) - 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 + theno such overloadmessage), not a silentnull. Note that fixing (2) without (1) just turns every date formula from silently-null into loudly-failing — both halves are needed.
Refs
- ADR-0032 (
docs/adr/0032-unified-expression-layer.md) — §Decision 1c "No silent runtime fallback"; the "Inconsistent failure policy" inventory naming "the engine's formula projection (silent null)". - Sibling of Record-change trigger plugin loads but flows never fire on data writes (7.4.1) #1491 (same silent-swallow family, predicate side); this is the formula-field projection side of the same root policy gap, plus the date-hydration root cause.
- Surfaced while upgrading
objectstack-ai/templatesto 7.6.0 (templates issue feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14).
Activity
xuyushun441-sys commentedon Jun 3, 2026
Still reproduces on
@objectstack 7.7.0(latest). Re-tested via REST against theobjectstack-ai/templatesallaggregate on a fresh DB:contracts_contract.is_expiring_soon— created with a clean date-onlyend_date: '2026-06-20',status: 'active', today2026-06-02(well within the 60-day window) →null(expectedtrue).todo_task.is_overdue(pastdue_date, not done) →null.hr_employee.tenure_years(hire_dateset) →null.So it's not seed-timestamp noise — even a clean date-only value yields
null. Numeric formulas evaluate fine; only the date-comparison / date-arithmetic ones fault. Unchanged from 7.6.0.chore: add changeset for #1530 formula date fix