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
Activity
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:daysBetween(record.hire_date, today()) => OK = 2318 (hire_date = "2020-03-15") daysBetween(record.hire_date, today()) / 365 => OK = 6So 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:record.hire_date != null ? daysBetween(record.hire_date, today()) : null => ERR[type] int vs null record.hire_date != null ? daysBetween(date(record.hire_date), today()) : null => ERR[type] int vs null record.hire_date != null ? daysBetween(record.hire_date, today()) / 365 : null => ERR[type] int vs null daysBetween(record.hire_date, today()) => OK = 2318 ← unguarded… ? dyn(daysBetween(...)) : nullworks, which confirms it's the type-unification, nothing to do withdaysBetweenor the driver round-trip.Repro was run against the current framework HEAD engine (cel-js@8,
stdlib.tsunchanged since #2697). All three gaps are inherent to cel-js + the current stdlib catalog, so they're equally present on 15.1.1 — which matches every observation in the report above.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:Formula build compileruntime evaluatefloor(...),today() + 30ERR (caught) null end_date - start_date + 1,date + n*msOK ✅ null ❌ catalog-correct guard ? num : nullERR (RC3) null So
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
- Confirm end-to-end + reship —
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. - Fail loudly, don't null — agreed, and the gate hole is precise: date/timestamp arithmetic compiles clean because
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.) - Template regression coverage — agreed; the sharper prevention is wiring
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.
- ① RC3 — fix
guard ? number : null(compile + eval) via AST rewrite. (genuine engine bug — a formula field is inherently nullable) - ② RC1 — register
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) - ③ RC2 — reject + redirect, not make-work.
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)
tenure_years: record.hire_date == null ? null : daysBetween(record.hire_date, today()) / 365 days: record.start_date == null || record.end_date == null ? null : daysBetween(record.start_date, record.end_date) + 1→ 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).- Confirm end-to-end + reship —
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 arithmetic (
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 todaysBetween(...)(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.- ①
- added a commit that references this issue
on Aug 17, 2026
Summary
While converting the
templatesrepo's roll-ups (framework#1867 follow-up) I boot-verified several storedField.formulafields that silently evaluate tonullon the pinned runtime (@objectstack/formula@15.1.1). No parse error, no runtime error, noobjectstack buildwarning — the field just returnsnull. 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
hrtemplate:hr_time_off_request.days(record.end_date - record.start_date) + 1hr_employee.tenure_yearsfloor((today() - record.hire_date) / 365)hr_time_off_request.daysis the leave-duration in the core time-off workflow — every seeded request shows a blank "Calendar Days".Verified repro
Fresh
objectstack dev --freshonhr(@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 nullBy contrast,
today(),daysFromNow(n),addDays(date, n),round,abs, and date ordering comparisons all work in 15.1.1 (I useddaysFromNow/addDaysto fix two sibling formulas — see below).What appears to be going on
daysBetweenis correct in framework HEAD — implemented inpackages/formula/src/stdlib.ts:176, listed inCEL_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 workingdaysBetweenstored-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 afloorbuiltin — 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 (silentdateField == 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
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.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.What I already shipped template-side (using functions that DO work in 15.1.1)
Same root class, fixable without
daysBetween, so done intemplates: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.daysandtenure_yearsare left for this framework fix — they need a workingdaysBetween(orTimestampsubtraction), which 15.1.1 doesn't provide in stored formulas.