Repository navigation
Shipped template formula fields silently evaluate to null on @objectstack 15.1.1 — daysBetween / Timestamp−Timestamp / floor in stored formulas (hr tenure_years, time_off days) #3306
Description
Summary
While converting the templates repo's roll-ups (framework#1867 follow-up) I boot-verified several stored Field.formula fields that silently evaluate to null on the pinned runtime (@objectstack/formula@15.1.1). No parse error, no runtime error, no objectstack build warning — the field just returns null. This is the exact silent-miss class ADR-0053 / ADR-0032 exist to kill, but for function calls / arithmetic, not the == today() equality case that #3183 just fixed.
Two of these are real, user-facing fields in the shipped hr template:
| Field | Expression (origin/main) | Runtime value (15.1.1) |
|---|---|---|
hr_time_off_request.days |
(record.end_date - record.start_date) + 1 |
null for every request |
hr_employee.tenure_years |
floor((today() - record.hire_date) / 365) |
null for every employee |
hr_time_off_request.days is the leave-duration in the core time-off workflow — every seeded request shows a blank "Calendar Days".
Verified repro
Fresh objectstack dev --fresh on hr (@objectstack 15.1.1), signed in, GET /api/v1/data/...:
hr_time_off_request.days→null(all 6 seeded rows).hr_employee.tenure_years→null(all 7 rows), and I could not get a non-null result with any of:floor((today() - hire_date) / 365)(original)daysBetween(record.hire_date, today())← bare, still nulldaysBetween(date(record.hire_date), today())← explicit coercion, still nulldaysBetween(...) / 365,daysBetween(...) / round(365)← still null
By contrast, today(), daysFromNow(n), addDays(date, n), round, abs, and date ordering comparisons all work in 15.1.1 (I used daysFromNow/addDays to fix two sibling formulas — see below).
What appears to be going on
daysBetween is correct in framework HEAD — implemented in packages/formula/src/stdlib.ts:176, listed in CEL_STDLIB_FUNCTIONS (validate.ts:446), and engine-tested (cel-engine.test.ts:449,456, including a date-string field arg). But calling it from a stored formula field on the published 15.1.1 returns null, and templates pin 15.1.1 (the latest published — there is nothing newer to bump to). So either the working daysBetween stored-formula path never shipped in 15.1.1, or the end-to-end stored-formula path has a gap the engine-level unit tests don't cover. Two related operators have no support at all and also null out silently: Timestamp − Timestamp (→ a number) and a floor builtin — both things template authors reach for naturally.
Related prior work: #1302 (original "missing daysBetween/date-arithmetic", closed not_planned then implemented via ADR-0053), #1979 (thread ctx into applyFormulaPlan), #3183 (silent dateField == today(), fixed 2026-07-18). This is the same silent-null family, one layer over: function/arith calls in stored formulas rather than equality.
Ask
- Confirm end-to-end + release: add a test that a stored formula field (via
engine.find()→applyFormulaPlan, real driverYYYY-MM-DDround-trip) usingdaysBetweenreturns the right value, and make sure it ships in the next published@objectstack/formula. Then templates can restoretime_off.days/tenure_years. - Fail loudly, don't null: an unresolved function / unsupported operator in a formula should be a build-time error (like Bare
dateField == today()silently returns false — fix via AST temporal-comparison rewrite in the CEL engine #3183's philosophy —cel-to-filteralready fails closed), not a silent runtimenull. Silent null is the trap here. - Template regression coverage: nothing currently catches that a shipped template's formula field evaluates to null on the pinned release — a boot-and-assert-non-null smoke over template formula fields would have caught all of these.
What I already shipped template-side (using functions that DO work in 15.1.1)
Same root class, fixable without daysBetween, so done in templates:
compliance_control.is_overdue_for_review:last_assessed_at + freq*86400000→addDays(last_assessed_at, freq) < today()(templates#92, merged).hr_document.is_expiring_soon/expiry_status:today() + 30/60→daysFromNow(30/60)(templates#93).
time_off.days and tenure_years are left for this framework fix — they need a working daysBetween (or Timestamp subtraction), which 15.1.1 doesn't provide in stored formulas.
Activity
os-zhuang commentedon Jul 20, 2026
Root cause found — it's not a
daysBetween/ stored-formula-path gapRan the real
@objectstack/formulaengine (celEngine.compile+celEngine.evaluate) against every one of these expressions.daysBetweenis not broken — it evaluates correctly through the stored-formula path:So the hypothesis "the working
daysBetweennever shipped in 15.1.1 / the stored path has a gap" is a red herring — and importantly, re-shippingdaysBetweenwould not have fixedtenure_years. There are actually three independent CEL-engine gaps, and the reason everydaysBetweenrewrite you tried still nulled is #3 below (the? … : nullguard), notdaysBetweenitself.The three gaps (all verified)
RC1 —
floor/ceilare not registered.stdlib.tsregistersabs/round/min/maxonly.floor(...)→found no matching overload for 'floor(dyn)'.RC2 —
Timestamp − Timestampreturns aDuration, not a number. cel-js does have the overload (GPT '-' GPT → Duration), soend_date - start_dateyields aDurationobject (coerces to{}), and there is noDuration + int/Timestamp + intoverload — so(end - start) + 1andtoday() + 30fault. (today() - hire_datefirst faultsTimestamp − string, gets hydrated toTimestamp − Timestamp, becomes aDuration, then faults on/ 365.)RC3 — the blessed
guard ? <number> : nullidiom is rejected. cel-js's ternary unifier requires both branches to share a type, andint/doubledo not unify withnull. Eventrue ? 5 : nullfails: "Ternary branches must have the same type, got 'int' and 'null'." This is why all yourtenure_yearsattempts nulled — they were all insiderecord.hire_date != null ? <X> : null:… ? dyn(daysBetween(...)) : nullworks, which confirms it's the type-unification, nothing to do withdaysBetweenor the driver round-trip.The "silent" half is a real gate hole
There's already a build gate that compile-checks formula fields (
validate-expressions.ts:201). It's asymmetric:compileevaluatefloor(...),today() + 30end_date - start_date + 1,date + n*msguard ? num : nullSo
date−datearithmetic compiles clean and nulls at runtime = the silent hole. And thefloor/today()+30ones that the gate does catch shipped anyway → thetemplatesrepo isn't runningvalidateStackExpressionsin CI. Both halves need closing.Answers to the three Asks
daysBetweenalready works end-to-end; no reship needed. The missing piece fortenure_yearsis RC3. Fix: an AST rewrite makingguard ? <typed> : nullunify (wrap the non-null branch indyn(...)), mirroring the merged BaredateField == today()silently returns false — fix via AST temporal-comparison rewrite in the CEL engine #3183rewriteTemporalEquality. Thenrecord.hire_date != null ? daysBetween(record.hire_date, today()) / 365 : nullcompiles and evaluates to6.record.<field>isdynat check time. Fix: typedate/datetimefields asgoogle.protobuf.Timestampin the soundness checker soTimestamp − Timestamp→Duration, andDuration ± number→ build error with "date arithmetic isn't supported — usedaysBetween(a,b)for spans,daysFromNow(n)/addDays/addMonthsto shift." (Comparisons<=/==keep theirTimestamp×Timestampoverloads → stay green; must be proven red-on-broken / green-on-existing before landing.)validateStackExpressionsintotemplatesCI (it already catchesfloor/today()+30) plus the RC2 gate-hole fix above (sodate−datealso turns red).Proposed fix
Guiding principle (this repo's existing #1928 design law): narrow, unambiguous catalog + everything off it fails loud at build. Make-work only where the construct is unambiguous and universally expected; reject-and-redirect where it's ambiguous or diverges from CEL.
guard ? number : null(compile + eval) via AST rewrite. (genuine engine bug — a formula field is inherently nullable)floor/ceil+ add to the catalog (skill + docs). (unambiguous, universally expected; note int-division floors toward zero, not −∞, so it's not a safe substitute)date − date/today() + Nhave ambiguous units (day vs instant field) and diverge from CEL'sDurationsemantics; making them "work" trades silent-null for silent-wrong, which is worse for an AI author that only self-corrects on red build gates. So: gate turns red + points todaysBetween/daysFromNow/addDays/addMonths(which cover 100% of real date math — zero capability loss).Template rewrites (the two left for framework, once ① lands)
→ evaluate to
6and5(inclusive) respectively.Two PRs: framework (①②③ + red→green tests + changeset; repo is on
16.0.0-rc.0) and templates (rewritetime_off.days+hr_employee.tenure_years, wirevalidateStackExpressionsinto CI).os-zhuang commentedon Jul 20, 2026
Fix up — two PRs
framework → #3348 (
fix(formula), ready)Closes all three engine gaps:
cond ? <value> : nullnow compiles + evaluates (AST wraps the non-null branch indyn(...), mirroring BaredateField == today()silently returns false — fix via AST temporal-comparison rewrite in the CEL engine #3183). This is what made everydaysBetweenrewrite above still null.floor/ceilregistered + added to the catalog (round toward −∞/+∞, so not substitutable by integer division).date − date + 1,today() + 30) is now a build error (os buildexits 1) pointing atdaysBetween/daysFromNow/addDays— closing the compile-clean-but-runtime-null hole. Sound: ordering/equality/concat of a date field stay clean; a!= nullguard no longer masks the inner fault.templates → objectstack-ai/templates#94 (
fix(hr), draft)Rewrites the two remaining fields to
daysBetween(...)(verified:days = 5,tenure_years = 6). Blocked on #3348 publishing — on 15.1.1 the null-guard form still nulls, so it merges after@objectstack16.x ships and thehrdeps bump.On Ask #3 (regression coverage): no new CI step is needed — templates CI already runs
objectstack build, and #3348 makes that gate turn red on date arithmetic in a formula, so this class can't ship green again once deps bump.fix(formula): null-guarded numeric formulas + floor/ceil + reject dat…
docs(releases): supplement v16 with changes landed since rc.0 (backen…
feat(lint,docs): `has(x)` 不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (objectstack-ai…
docs(releases): record the 16.0.0 GA cut and the closed v16 line (obj…