Repository navigation
Commit e865300
feat(spec,formula): a formula field declares a currency result — returnType 'currency' with its own currencyConfig; inferFormulaReturn stamps it when provably money (#22733)
Fixes #22727
Clause-②: yes (widening)
Parked on #22744: the merge queue is walled for every lane.
`render-projection-diff`'s base archive lacks the generated
`registry.ts` since #22706, so `Type Check · source gates` fails on
every merge-group run based at or after `ed1de8c2db`. This PR's own head
`1e9d5ba927` is green, and it is re-queued once #22744 lands.
## What changed
- **`@objectstack/spec`: a formula declares a currency result.**
`FieldSchema.returnType` is `number | text | boolean | date | currency`.
A `currency` result carries its currency the way a currency field does:
in the formula's OWN `currencyConfig`, the same `CurrencyConfigSchema`,
the same defaults (absent means dynamic, the tenant default currency)
and the same refusals (the `precision` tombstone, the three-character
code, the `currencyMode` enum). One shape, not a second one.
- **Every exhaustive reader of a formula's `returnType` answers
`currency` by name**, never by falling into a default arm (census
below).
- **Both designer forms move with the enum.** `field.form.ts` and
`object.form.ts` offer Currency (same list, same order, which
`form-return-type-options.test.ts` derives from the enum). The field
designer's Currency Config row now shows for a formula whose return type
is Currency (`visibleWhen` widened with `data.returnType ==
'currency'`). Help text and the four locale bundles regenerated or
re-translated (`en` regenerated; `zh-CN` / `ja-JP` / `es-ES`
re-translated by hand for the two changed help texts).
- **`@objectstack/formula`: `inferFormulaReturn(expression, fields)`**,
the producer behind "authoring stamps it when the inferred type is
provably money". It takes the host object's declared field map and
returns the declaration to stamp: `{ returnType: 'currency' }`, plus
`currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when the
source amounts share one fixed currency. Everything it cannot prove to
be money answers exactly what `inferExpressionType` answers, which is
unchanged.
- Hand-written docs (`formulas.mdx`, `field-types.mdx`,
`validation-rules.mdx`) list the new member.
## Decision: "taken from the source field" is an authoring-time stamp,
not a read-time derivation
Measured first: `currencyConfig` is ALREADY admitted on a `formula`
field today (a full `FieldSchema` parse of a formula carrying
`currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }`
succeeds and keeps it; it is equally admitted, and unread, on a `text`
field). So "the field's own config" is an existing key gaining a reader,
not a new admission. "From the source field" is realized by
`inferFormulaReturn` copying the source currency into that declaration
at authoring time. Nothing reads a referenced field's currency at
display time.
- **Real business need.** HotCRM finding B-15 (formulas over money
render as bare numbers). In this repo the two example money formulas —
app-crm `opportunity.expected_revenue` and app-showcase
`project.budget_remaining` — are both proven money by the new inference
(pinned in `formula-return.test.ts`).
- **Long-term soundness.** Contract-first: the currency is a declaration
the save door checks with the currency field's own schema. A read-time
fallback from a formula to "some referenced field" is a consumer-side
`??` (AGENTS.md Prime Directive 12): unchecked, and two consumers could
pick two different sources.
- **Harder for AI to get wrong.** The declaration is visible and refused
loudly when malformed; the producer never claims money it cannot prove
(two different currencies, money times money, money plus a plain
quantity, an unreadable source currency, a roll-up whose unit is not on
this object), and in every such case falls through to today's answer.
The stamped pair is complete — an absent member means "clear it", so a
formula edited from money to a count loses its stale currency.
- **Startup focus.** Currency only: no `percent` or `datetime` member.
No new gate, no new refusal (a `currencyConfig` on a non-currency
formula stays admitted-and-unread, exactly as on every other
non-currency type). No MCP surface change.
## Where the stamp lives (mechanism assumption 3, measured)
- The act of writing `returnType` lives only in objectui's designer:
`packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx:1103-1108`,
fed by `celAuthoring.ts:614-627`, which calls `@objectstack/formula`'s
`inferExpressionType` and coerces any value outside its local
`CEL_VALUE_TYPES` (`:600-602`) to `unknown`. Not edited here (follow-up
named in the report).
- The inference it reads is in this repo, so the producer half —
`inferFormulaReturn` — lands here.
- `@objectstack/mcp`'s `validate_expression` reports `inferredType` from
`inferExpressionType` (unchanged, still never `currency`); its
`describeObject` projection carries no `currencyConfig`, so it could not
name the currency anyway. Noted below, not changed.
## Reader census
Instrument: `git grep -n "returnType"` over `packages/**` (non-test,
non-dist), control: it hits the known reader
`filter-boolean-comparand-declared-type.ts:318`.
| reader | answer for a `currency` formula |
|:---|:---|
| `FORMULA_RETURN_TYPE_AS_FIELD_TYPE`
(`filter-text-operator-declared-type.ts`), shared by the text, number
and boolean doors | `currency` → judged as the `currency` field: text
operator refused (`INVALID_FILTER`), number door `judged`, boolean door
`not-judged` — each pinned |
| door fixtures (`NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS`,
`TEXT_OPERATOR_DOOR_FIXTURE_FIELDS`) | gain `f_formula_currency`; their
`returnType` type is now derived from `Field['returnType']` instead of a
four-literal union |
| `display-name.ts` `isTitleEligible` | not title-eligible (only `text`
is) — comment updated |
| `aggregate-field-type-compatibility.ts` | `formula` refused for
sum/avg/min/max whatever it returns — unchanged |
| `ui/component.zod.ts` inline form field `returnType` (by reference) |
widened with the enum; describe updated |
| `service-analytics` `native-sql-strategy.ts` /
`measure-result-type.ts` | a formula reaches with no `returnType` and is
deferred; the `formula-return-type-measure` walk now holds `currency` to
the same `undefined` |
| objectql doors, plugin-security RLS compiler | relay the string to the
spec verdicts above; no table of their own |
## Tests
Package tests on `20d64a3102`; the merge commit after it (`de9f741f33`)
brought only `packages/cloud-connection` and its changeset from main.
- `@objectstack/spec`: `vitest run --project local` — 643 files, 19205
passed, 1 todo. New `formula-currency-result.test.ts` (a currency
formula parses; its `currencyConfig` parses identically to a currency
field's and is refused with identical code, path and message; CONTROL
`number` unchanged; the enum is exactly the five).
`form-return-type-options.test.ts` moved with the enum: `currency`
offered by both forms and parses; `datetime` still refused and
unoffered.
- `@objectstack/formula`: 47 files, 1326 passed. New
`formula-return.test.ts`, 11 cases — the card's two pins (a formula over
a currency field declares the currency result; CONTROL: a numeric
formula stays `number`), the two example-app formulas, every non-money
case asserted equal to the plain `inferExpressionType` answer, and every
stamped declaration parsed by the rebuilt spec `FieldSchema`.
- `@objectstack/objectql` door suites (3 files, 93 passed),
`@objectstack/service-analytics` `formula-return-type-measure` (10
passed), `@objectstack/platform-objects` (69 files, 1082 passed).
- Typecheck: spec, formula, platform-objects, service-analytics,
objectql — all exit 0.
## Gates
- **Derived union, run at `de9f741f33`.** `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 120 families (the same set it
derived at `20d64a3102`). All 120 ran at `de9f741f33` and every one
exited 0; the `--ran` reconciliation over the recorded exit codes reads
"120 run, 0 NOT-MEASURED (a DERIVED zero — all 120 recorded an exit code
and none of them is 3)". The set includes `check:generated`,
`check:docs`, `check:api-surface`, `check:authorable-surface`,
`check:adr-0087-registration` (a widening carries no disposition marker;
green), `check:changeset-no-major`, `check:i18n`,
`check:i18n-stale-fill`, `check:skills-token-ratchet`,
`check:skill-examples`, `check:cross-package-test-inputs`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:nul-bytes`.
- **First round, for the record.** At `20d64a3102` two gates answered
PREREQUISITE NOT MET (exit 3, nothing measured): `check:skill-examples`
(client-react unbuilt) and `check:dual-build-cjs-loads` (nine package
dists absent). Both re-ran green after the builds, and the second round
above measured them green directly.
- **Generated artifacts.** `check:generated` named one stale artifact
after the enum moved, `content/docs/references/**`, regenerated with
`gen:docs`; `authorable-surface` and `api-surface` did not move (an enum
member is not a key, and no spec export was added).
- **Lint, narrowed (CI owns the repo-wide `pnpm lint`).** Population:
the 20 lintable files in this diff against the merge base, all under
`eslint.config.mjs`'s global `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` object; `--no-inline-config
--format json` counted 20 files, 0 errors, 0 warnings. Invariance: the
config enables no type-aware linting (no `parserOptions.project`, stated
in its own header at lines 327-328), so this diff cannot move a verdict
on a file it does not touch.
## Governed surface
The skill line `skills/objectstack-data/rules/field-types.md:253` ('(no
'currency')') goes false when this lands. The seat carries it to the
skills lane as a card blocked on #22727.
## Acceptance notes
- **The renderer is the next half.** objectui at the `.objectui-sha` pin
draws an undeclared `returnType` through the text face
(`packages/fields/src/widgets/FormulaField.tsx` final branch,
`packages/fields/src/index.tsx:2703-2704` cell), so a formula
hand-declared `currency` renders as monospace text until objectui
formats it. The card names that follow-up (filed beside it, on hold
until a spec release carries the member). Nothing in this repo stamps
`currency` automatically, and the docs here state the declaration, not a
display.
- **The designer stamp** (objectui `ObjectFieldInspector.tsx:1103-1108`,
`celAuthoring.ts:600-627`) must call `inferFormulaReturn(source,
draft.fields)` and write both members; until then it keeps stamping
`number` for a money formula, as today.
- **`validate_expression` (MCP)** keeps answering `inferredType` from
`inferExpressionType`; reporting a currency would need `describeObject`
(`packages/mcp/src/stdio-data-bridge.ts:662-680`,
`packages/runtime/src/domains/mcp.ts:553`) to project `currencyConfig`.
Not changed: no measured pull, and the answer it gives (`number`) is
still honoured.
- **`currencyConfig` on a formula whose `returnType` is not `currency`**
is admitted and unread, as on every non-currency type (`FieldSchema`
accepts each per-type key on any type; the forms gate visibility). No
refusal added: it would be a narrowing and a new gate.
- The docs' "Financial Calculations" examples still declare money
formulas `number`; moving them to `currency` belongs with the objectui
rendering.
- `object.form.ts` was a stop boundary while #22713 was in flight;
#22713 merged at 2026-10-10T20:27:42Z, `origin/main` was merged first,
and only this file's `returnType` row moved.
## 维护者速读(草稿)
### 改了什么
公式字段现在可以声明"货币"结果:`returnType` 新增 `currency`,货币写在公式自己的 `currencyConfig`
里,与货币字段同一套结构和默认值(不写即 dynamic,用租户默认货币)。新增
`inferFormulaReturn`:作者编辑公式时,能证明结果是钱的就给出 `currency`
和源字段的货币,证明不了就和以前一样。两个设计器表单都提供"货币"选项。
### 为什么改
HotCRM 实测:引用金额字段的公式只能显示成裸数字。货币作为公式自身的声明,在保存时用货币字段同一套规则校验,不在渲染端回退去猜源字段。
### 风险与代价(含回滚)
纯加宽,以前合法的都仍合法。objectui 跟进之前,手工声明 `currency` 的公式会以文本样式显示(比数字样式差),所以本 PR
不自动给任何现有公式盖章。回滚:还原本 PR 即可,没有数据迁移。
### 席位意见
### 你要做的
本 PR 不改 `skills/**`,不触及受管面。那一行 skill 注释由席位另立 skills 卡(阻塞于 #22727),单独请你审。
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7aa8d51 commit e865300
29 files changed
Lines changed: 715 additions & 60 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references
- data
- system
- ui
- packages
- formula/src
- lint/src
- platform-objects/src/apps/translations
- services/service-analytics/src/__tests__
- spec/src
- data
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
409 | 409 | | |
410 | 410 | | |
411 | 411 | | |
412 | | - | |
| 412 | + | |
413 | 413 | | |
414 | 414 | | |
415 | 415 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
124 | 124 | | |
125 | 125 | | |
126 | 126 | | |
127 | | - | |
| 127 | + | |
128 | 128 | | |
129 | 129 | | |
130 | | - | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
131 | 135 | | |
132 | 136 | | |
133 | 137 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
352 | 352 | | |
353 | 353 | | |
354 | 354 | | |
355 | | - | |
| 355 | + | |
356 | 356 | | |
357 | 357 | | |
358 | 358 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
| 98 | + | |
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
260 | 260 | | |
261 | 261 | | |
262 | 262 | | |
263 | | - | |
| 263 | + | |
264 | 264 | | |
265 | 265 | | |
266 | 266 | | |
267 | | - | |
| 267 | + | |
268 | 268 | | |
269 | 269 | | |
270 | 270 | | |
| |||
601 | 601 | | |
602 | 602 | | |
603 | 603 | | |
604 | | - | |
| 604 | + | |
605 | 605 | | |
606 | 606 | | |
607 | 607 | | |
608 | | - | |
| 608 | + | |
609 | 609 | | |
610 | 610 | | |
611 | 611 | | |
| |||
0 commit comments