Repository navigation
fix(formula)!: the formula gate refuses bare date subtraction, which reads null or {} at runtime - #29058
Conversation
… the text the runtime holds
A date or datetime field reaches the evaluator as its text, so subtracting two
of them faults (the formula reads null) and a timestamp minus one computes a
Duration, which a field serialises as {}. The gate declared the field a
Timestamp and passed both. What that typing passes is now re-checked with the
field declared as its text (DateText), refused as date-text-arithmetic or, for
a formula value, date-duration-value. The + 1 shape keeps its existing words.
Claude-Session: https://claude.ai/code/session_01FEkQ5nXr5BHHskLQ7qUH5x
Co-Authored-By: Claude <noreply@anthropic.com>
…try, changeset and the formulas page row Claude-Session: https://claude.ai/code/session_01FEkQ5nXr5BHHskLQ7qUH5x Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 169 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 91d2d53f86c7b64a4f9aefd6183e1a5de4ea2970 && git checkout 91d2d53f86c7b64a4f9aefd6183e1a5de4ea2970
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0be247ce8b91ba3b9a3ce7235491b28a986b039a e029ea772b1555ea33b2946e0978ff4175dbdd3e && git checkout -B drift-repro 0be247ce8b91ba3b9a3ce7235491b28a986b039a && git merge --no-ff e029ea772b1555ea33b2946e0978ff4175dbdd3e
node scripts/docs-audit/affected-docs.mjs --json 0be247ce8b91ba3b9a3ce7235491b28a986b039a
|
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo Contract reviewServed-tier: Reviewed against merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo 席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt Landing record · PR #29058
Generated by Claude Code |
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo
Requested by Leehom · project thread
Fixes #28944
Clause-②: yes (narrowing)
Metadata-protocol: narrowed
Skills-docs: follow-up #29061 —
skills/objectstack-formula/SKILL.md(the "No date arithmetic" callout saysend - start"does not fault — it yields adurationstored as{}"; after this change it is refused at build, and it read null at runtime),skills/objectstack-ui/evals/analytics-inline-vs-dataset.jsonid 6 (its prompt describes a storedend_date - start_dateformula).skills/**is a Tier H surface outside this claim's file surface, so it is not edited here.content/docs/data-modeling/formulas.mdxis updated here.维护者速读
record.end_date - record.start_date),或用当前时间减日期字段(today() - record.start_date),现在在os validate/os build/os lint和保存对象时都会报错(保存为 422expression-invalid),错误信息说明运行时会读成 null 或{},并给出daysBetween(a, b)的改法。datetime 字段同样处理。YYYY-MM-DD或 ISO 时间),不是时间戳;两个文本相减会报错,字段每条记录都读成 null;时间戳减日期得到一个时长,字段读成{}。以前的检查把日期字段当时间戳,所以放行了这两种写法,构建全绿、线上空值。Metadata-protocol: narrowed),formula 与 spec 升 minor。被拒的公式本来每条记录都是 null 或{},没有能用的写法会被拒;本仓库 packages、examples 和 hotcrm 里没有这种公式。(end - start) + 1原有的报错一字不变。回滚即 revert 本 PR。packages/formula/src/cel-engine.ts(firstDateTextVerdict与registerDateTextOverloads:日期字段按运行时文本声明的类型和它被允许的运算)、packages/formula/src/validate.ts(dateTextIssue:两种拒绝及其文案)、packages/formula/src/validate-date-text-arithmetic.test.ts(每一行同时问检查和运行时)。pnpm --filter @objectstack/formula exec vitest run src/validate-date-text-arithmetic.test.ts,预期 27 个用例全绿。What changes
@objectstack/formulafirstDateTextVerdict(source, fieldCelTypes, scope)incel-engine.ts(package-internal, not on the entry). It type-checks the expression with everydate/datetimefield declared asDateText, the text the runtime actually holds, instead of the TimestampfirstTypeMismatchdeclares.registerDateTextOverloadsdeclares, for that check-only env, exactly the overloads the evaluator answers on that text, each measured throughcelEngine.evaluate: ordering and equality against another date field, a text literal or a Timestamp (bool),Timestamp - DateTextandDateText - Timestamp(Duration, since the evaluator coerces the text against a Timestamp), and+with text (concatenation). Everything else on the text faults the check exactly where it faults the evaluator:DateText - DateText,DateText + int,DateText - Duration,Timestamp + DateText. Nothing is matched in the source text. The verdict falls out of the typing.validateExpressionasksfirstTypeMismatchfirst, unchanged. Only when that passes doesdateTextIssueaskfirstDateTextVerdict. So every shape the gate already refused keeps its code, params and words. That includes the+ 1shape (date-arithmetic,google.protobuf.Duration + int).date-text-arithmetic(params: { operands, reference? }): arithmetic on a date field's text that the evaluator faults on, in any role.record.end_date - record.start_datereads null, and so does a predicaterecord.end_date - record.start_date > duration("72h").date-duration-value(params: { reference? }): a formula (valuerole) whose computed type isgoogle.protobuf.Duration, which a field reads as{}.today() - record.start_date,now() - record.start_at, and alsotoday() - daysAgo(3): the verdict reads the computed type, not whether a date field is named. A predicate that compares a duration (today() - record.start_date > duration("72h")) evaluates and is untouched.fieldTypes(as before) and there is something to find: a date field to retype, or avaluewhose type is the question.@objectstack/spec: ADR-0087 D3 entryformula-date-subtraction-refused(migrations/entries/semantic/18.formula-date-subtraction-refused.ts), with the FROM → TO and why there is no mechanical rewrite.content/docs/data-modeling/formulas.mdx: a "Date subtraction" row in the type-soundness table.@objectstack/lintsource. Its formula pass (validateExpression('value', …, { fieldTypes, scope: 'record' })) and every object-bound predicate site already hand the validator the field types. Soos validate,os build,os lintand the runtime authoring gate behind the object save door (runRuntimeAuthoringRules) all answer from the one judge. A lint test pins the stack face.Measured before building
packages/**,examples/**,skills/**,content/**at135054eb; hotcrmorigin/mainatd4ffe99): a grep for a date field,today()ornow()on either side of a minus finds no formula that subtracts dates. The only hits are number subtraction (record.budget - …,record.fee - record.tax) and doc prose. hotcrm's 15 formula fields subtract no date (one usesdaysBetween(record.stage_entry_date, today())), and its seeds'now() - duration('Nh')are seed values, not gate input. No shipped formula is refused.os dev --freshonexamples/app-todowith a probe object, main135054eb, sqlite-wasm):record.end_at - record.start_atreadsnullwith the same WARN,no such overload: dyn‹string› - dyn‹string›(the SQL driver serves a datetime as ISO text on every dialect).now() - record.start_atreads{}. So datetime is included, per the ruling.Tests
packages/formula/src/validate-date-text-arithmetic.test.ts(new, 27). A table where each row is judged twice: bycelEngine.evaluateover the row a read door serves (dates as text), and byvalidateExpression. It pins that the gate refuses exactly what readsnullor{}and nothing the evaluator answers. Refused rows: the card's two shapes, both datetime shapes,record.start_date - today(), the subtraction inside a comparison, andrecord.start_date - duration("24h"). Controls:daysBetween(both forms), comparing a duration,(today() - record.start_date).getHours(), date ordering against a date,today(),now()and a literal, equality, the null guard, concatenation,addDays, and number subtraction. Further tests: the+ 1shape keepsdate-arithmeticwithgoogle.protobuf.Duration + int; the flattened (flow) scope;date-duration-valueisvalue-role only; nofieldTypesmeans no check.packages/formula/src/expression-refusal-codes.test.ts: pins for both codes (code, params, full message), and two sweep sources, so both arms are provoked and their params are checked against the message. The existingdate-arithmeticpins pass unchanged.packages/formula/src/validate.test.ts:record.end_date - record.start_dateleaves the "runtime-tolerated" list, where it was pinned as clean ("→ Duration, no further arith"). That pin is the behaviour this PR reverses.packages/lint/src/validate-expressions.test.ts:validateStackExpressionsrefusesqa_span_bareandqa_since_todayat theirexpression, as errors, and leavesqa_span_daysbetweenclean.Red first on base (the three formula source files restored to 135054e, tests kept)
git restore --source=135054eboncel-engine.ts,validate.ts,expression-refusal.ts; formula readssrc/.validate-date-text-arithmetic.test.ts: 10 failed, 17 passed. The failures are the seven refused rows, the naming test, the flattened test and the role test. Every control row and the+ 1test pass on base.git restore --source=HEAD --staged --worktree;git diff HEADempty.Live red, same base, CLI face:
os validate,os lintandos buildall exit 0 on the probe object, and REST answersqa_span_bare: null,qa_span_daysbetween: 4,qa_since_today: {},qa_dt_span: null,qa_dt_since_now: {}.Ablation: each half reds only its own cases
Both through
node scripts/ablation-replace.mjs(anchor hit 1 → 0, blob restored == HEAD,git diff HEADempty), formula read fromsrc/, three suites (validate-date-text-arithmetic,expression-refusal-codes,validate).cel-engine.ts,temporalAsText && t === 'timestamp' ? DATE_TEXT_CEL_TYPE→String(temporalAsText) === 'ablated' ? DATE_TEXT_CEL_TYPE, blobd48b48384db5→0157c4c635ea): 8 failed, 180 passed. These are the fourdate-text-arithmeticrows, the naming and flattened tests, thedate-text-arithmeticpin and the sweep. Everydate-duration-valuerow stays green, because the Timestamp typing also computesDurationfor those, and the controls and+ 1stay green.validate.ts,if (role !== 'value' || verdict.type !== 'google.protobuf.Duration') return null;→if (String(role) !== 'ablated') return null;, blob787a1532d20b→b6de2a9964e0): 7 failed, 181 passed. These are the threedate-duration-valuerows, the naming and role tests, thedate-duration-valuepin and the sweep. The controls stay green.Acceptance notes
+ 1case keeps its words by ordering, not by special-casing. The Timestamp typing answers first and is unchanged. Under the text typing alone,(end - start) + 1would fault first at the inner subtraction.date-duration-valueis wider than the two named shapes. Any formula whose computed type is a Duration is refused,today() - daysAgo(3)included, because that is what the typing answers and every such value reads{}. No shipped formula has that shape either.DateTexton a side. Any other first fault, such asisoDate(record.d)(which the evaluator refuses on text too), returns no verdict, so it can only miss a later fault in the same expression. It never invents one.referenceis the first one the object declares that appears in the source, the same best-effort attributionfirstTypeMismatchuses.@objectstack/restbundles a copy of@objectstack/formulainto itsdist(found while rebuilding for the live probe). The copy follows on its next build. This was noted, not changed, and is not filed.origin/mainbefore opening: the 16 commits since135054ebtouch no file underpackages/formula,packages/lintorpackages/spec/src/migrations, and GitHub reports the merge. The merge queue tests the merged tree.Verification
All readings at head
e029ea77unless noted (repoobjectstack-ai/objectstack).Package suites and typecheck
pnpm --filter @objectstack/formula test && … typecheck: Test Files 64 passed (64), Tests 1628 passed (1628), VERDICT command-exit 0.pnpm --filter @objectstack/lint test && … typecheck: Test Files 189 passed (189), Tests 7830 passed (7830), VERDICT command-exit 0.packages/specvitest run src/migrations(after a spec build): Test Files 5 passed (5), Tests 194 passed (194).Live green (os dev on examples/app-todo with the probe object, this branch)
os validate/os lint/os build: exit 1, fourexpression-invaliderrors:qa_span_bareandqa_dt_span(date-text-arithmetic),qa_since_todayandqa_dt_since_now(date-duration-value).qa_span_daysbetweenis not refused.PUT /api/v1/meta/object/NAME?package=app.probe28944on a writable package):record.end_date - record.start_dategets 422INVALID_METADATA, ruleexpression-invalid, atfield 'span' expression.today() - record.start_dategets 422, same rule.daysBetween(record.start_date, record.end_date)gets 200.dist/were removed and rebuilt after the probe; the tree is clean.Derived gate families
node scripts/pm/dispatch-gates.mjs --commands: 106 commands, all run, all exit 0.check-plugin-teardown-shape.mjs --self-testfirst answered exit 3 on the shallow clone, then exit 0 aftergit fetch --depth=1 origin 621a487607881c66b2899b7e3477115229a156b4.--ranreconciliation: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN. CI families: see this PR's check-runs.Full dogfood (Clause-② narrowing arm)
pnpm --filter @objectstack/dogfood testunder the verify lock ate029ea77: VERDICT command-exit 0, Test Files 383 passed and 3 skipped (386), Tests 3163 passed and 28 skipped (3191). No failure.Overlap: the open PRs re-read before opening (titles, at the time of opening) touch neither
packages/formula/src/nor the lint formula gates.Generated by Claude Code