Skip to content

Commit e029ea7

Browse files
committed
test(lint), docs: the stack gate refuses bare date subtraction; D3 entry, changeset and the formulas page row
Claude-Session: https://claude.ai/code/session_01FEkQ5nXr5BHHskLQ7qUH5x Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 99ff16c commit e029ea7

4 files changed

Lines changed: 98 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(formula)!: the formula gate refuses bare date subtraction, which reads null or `{}` at runtime
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: narrowed
10+
11+
<!-- adr-0087: registered formula-date-subtraction-refused -->
12+
13+
**BREAKING** accept-set narrowing at the authoring type-soundness gate (`@objectstack/formula` `validateExpression`), shipped as `minor`. The gate declared a `date` / `datetime` field a CEL Timestamp, so `record.end_date - record.start_date` checked as a legal Duration. The runtime never holds a Timestamp there: every read door serves a date field as its `YYYY-MM-DD` text and a datetime field as its ISO-8601 instant text. So the subtraction faulted and the formula read `null` on every record, and `today() - record.start_date` evaluated to a Duration, which a field serialises as `{}`. Both shapes now get the same verdict at every door that runs the gate: `os validate`, `os build`, `os lint`, and the object save door with the other doors of the active authoring gate (`runRuntimeAuthoringRules`). Every door asks one judge.
14+
15+
- **Before.** A formula `record.end_date - record.start_date` or `today() - record.start_date` built green (exit 0), and `PUT /api/v1/meta/object/NAME` answered 200. A read then returned `null` (with a server WARN) or `{}`. The same was true for two datetime fields, and for `now() - record.start_at`.
16+
- **Now.** The same formula is refused at build (exit 1) and at the save door (422 `INVALID_METADATA`, rule `expression-invalid`), located at the field's `expression`. There are two new refusal codes. `date-text-arithmetic` covers arithmetic on a date field's text that the evaluator faults on, in any role, so a predicate `record.end_date - record.start_date > duration("72h")` is refused too. `date-duration-value` covers a formula whose value is a `google.protobuf.Duration`, whether or not a date field is named (`today() - daysAgo(3)` too). Each message names the runtime outcome and prescribes `daysBetween(a, b)`.
17+
- **Unchanged.** `(record.end_date - record.start_date) + 1` and `record.d + 1` keep their `date-arithmetic` refusal word for word, because the existing check still answers first. Date comparisons (`<`, `==`, against a literal, against `today()`), concatenation, `daysBetween` / `addDays`, number arithmetic, and a predicate that compares a duration (`today() - record.start_date > duration("72h")`) all stay accepted.
18+
- **Who sees it.** An author whose formula subtracts dates. That formula already reads `null` or `{}` for every record. No shipped formula in this repository's `packages/` or `examples/` has this shape.
19+
20+
## FROM → TO
21+
22+
```ts
23+
// before: built green, read null (end minus start) or {} (since a date)
24+
span: Field.formula({ expression: 'record.end_date - record.start_date' }),
25+
age: Field.formula({ expression: 'today() - record.start_date' }),
26+
27+
// after: the span in whole days
28+
span: Field.formula({ expression: 'daysBetween(record.start_date, record.end_date)' }),
29+
age: Field.formula({ expression: 'daysBetween(record.start_date, today())' }),
30+
```
31+
32+
The upgrade guide entry is `formula-date-subtraction-refused`. There is no automatic rewrite, because which span the author meant (days, inclusive or not, or hours) is a judgement about the field.
33+
34+
The closed refusal-code set (`EXPRESSION_REFUSAL_CODES`) gains `date-text-arithmetic` (`params: { operands, reference? }`) and `date-duration-value` (`params: { reference? }`).

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
244244
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
245245
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime and evaluates to `null`. Store it as a number field, or drop the arithmetic. | `record.title * 2` | warning |
246246
| **Date arithmetic** — a `date` / `datetime` field used with `+` / `-` against a number (a `YYYY-MM-DD` value is a string at runtime, so this always faulted to `null`). Use the date helpers instead. | `record.end_date - record.start_date + 1` → `daysBetween(record.start_date, record.end_date) + 1`; `today() + 30` → `daysFromNow(30)`; `record.date + n` → `addDays(record.date, n)` | error (#3306) |
247+
| **Date subtraction** — the runtime holds a `date` / `datetime` field as its text (`YYYY-MM-DD`, or an ISO-8601 instant), so subtracting two of them faults and the formula reads `null`; a timestamp minus one computes a duration, which a field reads as `{}`. Use `daysBetween`. A condition may still compare a duration (`today() - record.start_date > duration("72h")`). | `record.end_date - record.start_date` → `daysBetween(record.start_date, record.end_date)`; `today() - record.start_date` → `daysBetween(record.start_date, today())` | error |
247248

248249
> **Integer literals in arithmetic are fine.** `record.amount / 100`,
249250
> `record.price * 2`, `record.total - fee` all work — the engine handles mixed

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,34 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
302302
expect(issues[0].message).toMatch(/daysBetween/);
303303
});
304304

305+
// #28944 — the bare subtraction, and a timestamp minus a date field: the
306+
// runtime holds a date field as its text, so the first reads null and the
307+
// second a Duration (`{}`). Both are refused by the stack gate, located at
308+
// the formula's expression; the daysBetween form beside them stays clean.
309+
it('flags bare date subtraction and a timestamp minus a date field in a formula field', () => {
310+
const issues = validateStackExpressions({
311+
objects: [{
312+
name: 'qa_doctor_time_off',
313+
fields: {
314+
start_date: { type: 'date' },
315+
end_date: { type: 'date' },
316+
qa_span_bare: { type: 'formula', name: 'qa_span_bare', expression: 'record.end_date - record.start_date' },
317+
qa_span_daysbetween: {
318+
type: 'formula', name: 'qa_span_daysbetween', expression: 'daysBetween(record.start_date, record.end_date)',
319+
},
320+
qa_since_today: { type: 'formula', name: 'qa_since_today', expression: 'today() - record.start_date' },
321+
},
322+
}],
323+
});
324+
expect(issues.map((i) => [i.where, i.severity])).toEqual([
325+
["object 'qa_doctor_time_off' · field 'qa_span_bare' expression", 'error'],
326+
["object 'qa_doctor_time_off' · field 'qa_since_today' expression", 'error'],
327+
]);
328+
expect(issues[0].message).toMatch(/evaluates to null/);
329+
expect(issues[1].message).toMatch(/reads `\{\}`/);
330+
for (const issue of issues) expect(issue.message).toMatch(/daysBetween\(a, b\)/);
331+
});
332+
305333
it('accepts the daysBetween rewrite of that formula', () => {
306334
const issues = validateStackExpressions({
307335
objects: [{
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'formula-date-subtraction-refused',
7+
surface:
8+
'data.Field.expression of a formula field (and any CEL predicate judged with the object\'s field types: '
9+
+ 'validation conditions, flow conditions) — a date or datetime field subtracted from another '
10+
+ '(record.end_date - record.start_date), or arithmetic on one with a duration '
11+
+ '(record.start_date - duration("24h")); and, as a formula value, an expression that computes a '
12+
+ 'duration (today() - record.start_date, now() - record.start_at)',
13+
replacement:
14+
'daysBetween(a, b) for the span in whole days: daysBetween(record.start_date, record.end_date) for end '
15+
+ 'minus start, daysBetween(record.start_date, today()) for the days since a date. addDays(d, n) / '
16+
+ 'addMonths(d, n) / daysFromNow(n) to shift a date. A predicate may still compare a duration, '
17+
+ 'today() - record.start_date > duration("72h")',
18+
reason:
19+
'The authoring type-soundness gate declared a date or datetime field a CEL Timestamp, so the '
20+
+ 'subtraction of two of them checked as a legal Duration and every door passed it: os validate, '
21+
+ 'os build, os lint and the object save door. The runtime never holds a Timestamp there: every read '
22+
+ 'door hands a date field out as its YYYY-MM-DD text and a datetime field as its ISO-8601 instant '
23+
+ 'text, so the evaluator faulted on the subtraction and the field read null for every record, with '
24+
+ 'a server WARN as the only trace; a timestamp minus a date field evaluated to a Duration, which a '
25+
+ 'field serialises as {}. The gate now re-checks what the Timestamp typing passes with the field '
26+
+ 'declared as that text, and refuses both shapes (date-text-arithmetic, and date-duration-value for a '
27+
+ 'formula). The (record.end_date - record.start_date) + 1 shape keeps its existing date-arithmetic '
28+
+ 'refusal and words. There is no mechanical rewrite: which span the author meant (the days between, '
29+
+ 'inclusive or not, or hours) is a judgement about the field, and a stored formula of the refused '
30+
+ 'shape read null or {} for every record, so no stored value depends on it.',
31+
acceptanceCriteria:
32+
'os validate and os build report no date-text-arithmetic or date-duration-value finding; each '
33+
+ 'rewritten formula field reads a number on GET /api/v1/data/OBJECT/ID where it read null or {} '
34+
+ 'before; no stored sys_metadata object row reports either finding in its _diagnostics.',
35+
};

0 commit comments

Comments
 (0)