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. 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.
-
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.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() 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
- 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.)
- 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
Summary
Every formula field whose expression does a date comparison/arithmetic against the CEL date stdlib (
today(),daysFromNow(),daysAgo(),now()) evaluates tonullat 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 producesnullforever.Affected code
packages/objectql/src/engine.ts—applyFormulaPlan(≈L87):planFormulaProjection(same file, ≈L36) already holds the fullschema.fieldswith eachdef.type, so the field types needed to hydratedate/datetime→Dateare 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(pinnednow, no DB needed):Output:
A
timestamp(record.end_date) <= daysFromNow(60)cast also returnstrue— confirming the engine itself is correct and the gap is purely the type of the value bound in.End-to-end symptom (full runtime,
../templateson 7.6.0)Via REST against the
alltemplate environment, real seeded records: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
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).Suggested fix
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.)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
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)".objectstack-ai/templatesto 7.6.0 (templates issue feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14).