Repository navigation
Commit 0cd578f
fix(spec)!: a fixed currency config needs its code — currencyConfig.defaultCurrency loses its CNY default (#27218)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBTNfKk4ntUHExRLQXMgHcte)_
Fixes #27157
Ruling-ref: 6464202770
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` teaches `defaultCurrency` in two places
(`skills/objectstack-data/rules/field-types.md` lines 55 and 174-187,
`skills/objectstack-ui/rules/dashboards.md` lines 303-304), and both
already teach it only as an explicit code under `currencyMode: 'fixed'`;
no `skills/**` text teaches the `'CNY'` default (`git grep -n CNY --
skills` → 0 hits). Not Tier H.
## 维护者速读
- 改了什么:`currencyConfig.defaultCurrency` 去掉 `'CNY'` 默认值;`currencyMode:
'fixed'` 不写币种代码时在解析处拒收(货币字段和返回 currency 的公式字段同一规则),提示「写 ISO 4217 代码,或改用
dynamic(组织默认币种)」;`dynamic` 配置解析后不再带 `defaultCurrency`。
- 为什么改:裁决 X(#27119 Q2):币种未知的金额取组织默认币种,任何地方都不猜。之前不写代码的 fixed
字段静默变成人民币字段,dynamic 配置也每次被物化出一个没人读的 CNY。
- 风险与回滚:收窄(BREAKING,minor)。仓库调查 11 条 fixed 配置、0 条缺代码。已存的 `sys_metadata`
行按原样保存,所以以 `{ currencyMode: 'fixed' }` 存下的行没有代码,转换不补(补 CNY
正是裁决禁止的):它照常加载注册,启动计为 invalid 并打印 `[metadata_spec_invalid]`,下次保存 422
拒收直到写上代码;显示不变(之前就落到组织默认币种)。dynamic 行上的 CNY 由 ADR-0087 转换
`currency-config-dynamic-default-currency-removed` 在回读时剥掉,显示不变。回滚即
revert 本 PR。
- 先看:`packages/spec/src/data/field.zod.ts`(`CurrencyConfigSchema` 与
`refuseFixedCurrencyWithoutCode`)、`packages/spec/src/conversions/registry.ts`(新转换)、`packages/formula/src/formula-return.ts`
的 `currencyOf`。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/data/currency-config-fixed-code-required.test.ts
src/conversions/currency-config-dynamic-default-currency-removed.test.ts`,预期
2 个文件 12 个用例全绿:fixed 无代码在 `defaultCurrency` 处拒收,dynamic 输出无代码,已存 dynamic
行剥掉 CNY、带代码的 fixed 行保留,已存的无代码 fixed 行不被转换且仍被拒收。
- 需要你做的:无,席位落地。
## What changes
- **Spec (`@objectstack/spec`, minor, BREAKING).**
`CurrencyConfigSchema.defaultCurrency` is `.optional()` instead of
`.default('CNY')`, and the schema gains one `superRefine`:
`currencyMode: 'fixed'` with no `defaultCurrency` answers one `custom`
issue at `defaultCurrency` (`currencyConfig.defaultCurrency` on a
field), first sentence "`currencyConfig.currencyMode: 'fixed'` needs
`defaultCurrency`, the field's one currency: give its ISO 4217 code
(e.g. `defaultCurrency: 'USD'`), or use `currencyMode: 'dynamic'` to
display amounts in the tenant default currency (the
`localization.currency` setting)." `FieldSchema.currencyConfig` is this
one schema for a currency field and for a formula with `returnType:
'currency'` (#22727), so both take the same refusal. A `dynamic` (or
empty) config parses to `{ currencyMode: 'dynamic' }`; an authored code
under `dynamic` stays legal and unread. `.describe()`, the field form's
help text, and the hand-written docs tables
(`content/docs/data-modeling/field-types.mdx`, `validation-rules.mdx`)
no longer say "Defaults to CNY".
- **ADR-0087: registered.** D2 conversion
`currency-config-dynamic-default-currency-removed` (toMajor 18,
`retiredFromLoadPath`, `retiredAfter: '18.2.0'`, order 78): strips
`defaultCurrency` from a config whose mode is `dynamic` or absent, on
objects and object extensions; a `fixed` config is never touched, and no
code is written into one. D3 semantic entry
`currency-config-fixed-default-currency-required` (step 18,
`conversionIds` = the D2 id) and a `STEP18_RATIONALE` fragment.
`DEFAULT_CHANGES_BY_MAJOR[18]` declares
`data/CurrencyConfig:defaultCurrency: "CNY" → (none)`. The
`currency-config-precision-removed` fixture's dynamic row drops its
baked `CNY` so the two fixtures stay disjoint.
- **Stored rows and built artifacts, measured.** A built artifact holds
the parsed stack, so its `fixed` configs carry the code the old parse
filled in and keep it. A `sys_metadata` object row is stored as authored
(`saveMetaItem` persists the payload verbatim), so a row saved as `{
currencyMode: 'fixed' }` (Studio's Fixed mode with the code left blank,
or a `/meta` write) has no code, and the conversion writes none in.
Measured on this head with a probe through `loadMetaFromDb` and
`saveMetaItem`:
- Load: `{ loaded: 1, errors: 0, invalid: 1 }`; the row registers
unchanged and boot prints `[metadata_spec_invalid] stored
object/crm_invoice fails the current spec schema even after conversion
(fields.amount.currencyConfig.defaultCurrency: …the prescription…)`. A
dynamic `CNY` row in the same probe: `invalid: 0`, registered as `{
currencyMode: 'dynamic' }`.
- Save: the next save of that object answers 422 `INVALID_METADATA`, one
`custom` issue at `fields.amount.currencyConfig.defaultCurrency`, until
a code is written.
- Display: unchanged. The registry already held the row as stored, and
`resolveFieldCurrency` reads a `fixed` code only when one is present, so
the field showed the tenant default before and still does (its test
"fixed with no code: falls through to the tenant default" pins it).
- Formula: a formula over that field is read as an unproven currency
instead of fixed CNY (the `currencyOf` row below).
- The new conversion test pins the row: no notice, no change, and
`ObjectSchema.safeParse` refuses it at
`fields.amount.currencyConfig.defaultCurrency` with the prescription.
- **Generated.** `dropped-refinements.baseline.json` gains
`data/CurrencyConfig` plus the `currencyConfig` site in the 12 schemas
that embed it (totals 252 schemas / 900 sites);
`authorable-defaults/data.json` loses the `CNY` row;
`content/docs/references/data/field.mdx` regenerated. Liveness
`field.json` `currencyConfig` row: a dated note, verdict unchanged
(live).
- **Readers, each measured.**
- `ui/packages/i18n/src/currency.ts#resolveFieldCurrency` (the display
rule at :34-36 and :60): unchanged. It reads the code only under
`fixed`, so a dynamic config without `CNY` displays the same. Its test
"parsed-empty" now asserts the spec's empty-config output carries no
code (`toBeUndefined()`) and still resolves to the tenant `USD`; the
docblock says the CNY now lives only in rows not yet stripped, and that
a stored `fixed` row saved without a code still loads with none and
shows the tenant default.
- `packages/formula/src/formula-return.ts#currencyOf`: the one reader
that changes. It parses through `CurrencyConfigSchema`, so a `fixed`
config without a code now fails to parse and reads as an unproven
currency (no `returnType` inferred) instead of fixed CNY. New row
`fixed_no_code` pins it; the `defaultCurrency === undefined` guard only
narrows the type.
- `packages/services/service-analytics/src/plugin.ts` (the
field-currency relay, :1447): reads the raw config's code only under
`fixed`; unchanged.
- `packages/types/src/share-link-resolve-answer.ts` (:120): copies
`defaultCurrency` only when it is a string, so a dynamic field's
projection loses the `CNY` key; the console reads it only under `fixed`.
No display changes.
- Every other `defaultCurrency` read under `ui/` goes through
`resolveFieldCurrency` or reads the legacy flat key.
- **Survey (metadata in the repository).** `examples/**`,
`packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures
(CHANGELOGs excluded), 1,034 files: **11** `currencyMode: 'fixed'`
configs, **0** without a code. The same detector over the whole tree
flags the CHANGELOG prose that names `fixed` without a code, so it is
not blind. A deployed database's `sys_metadata` rows cannot be surveyed
from here; any fixed row without a code there behaves as measured above.
## Tests
- `packages/spec/src/data/currency-config-fixed-code-required.test.ts`
(new): fixed without a code refused at `defaultCurrency` with the
prescription; the same on a currency field and on a formula returning
currency (`currencyConfig.defaultCurrency`); fixed with a code passes
unchanged; dynamic output carries no code; `parse(parse(x))` equals
`parse(x)` for every accepted shape, on the config and on `FieldSchema`.
-
`packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts`
(new): registered for 18, retired from the load path, linked from the D3
entry; a stored object row loses the code on dynamic configs (currency
field and formula), keeps both fixed ones by reference, parses to
itself, replays idempotently; the authoring load path does not run it; a
stored `{ currencyMode: 'fixed' }` row with no code gets no notice and
no change, and the current schema refuses it at
`fields.amount.currencyConfig.defaultCurrency` with the prescription.
- Updated: `field.test.ts`, `currency-precision-iso4217.test.ts`,
`formula-currency-result.test.ts` (the `fixed`-without-code row moves to
the refused list), `retired-key-removal-version.pin.test.ts` (JOIN row),
`packages/formula/src/formula-return.test.ts`,
`ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts`.
## Acceptance notes
- The refusal no longer cites ADR-0049 (the narrowing is ruling-driven);
it names `@objectstack/spec 18` because
`retired-key-removal-version.pin.test.ts` requires every entry retired
after the newest stable to name the release it derives.
- An authored `defaultCurrency` under `dynamic` is not refused (the
order asked for no such narrowing); the conversion strips it only from
stored rows and artifacts.
- `ui/packages/i18n/src/currency.ts` changes a comment only, so no
`@object-ui/i18n` changeset line.
- The formula reader stops inferring a fixed CNY currency for a source
field the schema now refuses; that field is itself refused at publish.
## Verification
All readings on base `main` `6866e2e7c` on a shared container; the
lock's VERDICT lines are quoted. Round 1: code head `5f876858f`, head
`c172c878e` (a changeset wording commit only: the survey count). **Round
2 head `a7683e16f`** (contract review 6467521422): wording at the
stored-row sites, the ADR-0049 citation dropped from the refusal, one
new conversion test; readings below marked *(r2)* were taken on it.
<details><summary>Round 2 (a7683e1)</summary>
- Stored-row probe through `loadMetaFromDb` / `saveMetaItem` (deleted
after use), as quoted under **Stored rows and built artifacts,
measured**.
- `pnpm --filter @objectstack/spec gen:migration-registry && build`,
then the conversions, migrations, field, currency and retirement-pin
tests → Test Files 31 passed (31), Tests 1274 passed. VERDICT
command-exit 0.
- `pnpm --filter @objectstack/spec check:generated` → current. Spec
vitest `--project local` → Test Files 742 passed (742), Tests 22244
passed | 1 todo (one more than round 1: the new pin). `typecheck` → exit
0. `ui` vitest `packages/i18n/` → Test Files 89 passed (89), Tests 1373
passed | 13 skipped. VERDICT command-exit 0.
- Gates re-run on `a7683e16f`: the 34 `--commands` families that read
the changeset, the spec package, lint or i18n (including
`check-adr-0087-registration --base origin/main`, the changeset
families, `check:nul-bytes`) → 34/34 exit 0, VERDICT command-exit 0. The
`--commands` list is identical (113 families). The other 79 read nothing
this commit touches (formula, platform-objects and the dogfood battery
included: no source there changed) and keep their round 1 readings.
</details>
<details><summary>Measure first (origin/main 6866e2e)</summary>
- `CurrencyConfigSchema.safeParse({ currencyMode: 'fixed' })` →
`{"currencyMode":"fixed","defaultCurrency":"CNY"}`.
- `{ currencyMode: 'dynamic' }` →
`{"currencyMode":"dynamic","defaultCurrency":"CNY"}`; `{}` → the same.
- A formula field with `returnType: 'currency'` and `{ currencyMode:
'fixed' }` → `{"currencyMode":"fixed","defaultCurrency":"CNY"}`.
</details>
<details><summary>Tests</summary>
- `pnpm --filter @objectstack/spec` vitest `--project local` (after
`pnpm --filter @objectstack/spec build`, VERDICT command-exit 0) → Test
Files 742 passed (742), Tests 22243 passed | 1 todo. `pnpm --filter
@objectstack/spec typecheck` → exit 0 (tsc, scripts-typecheck,
test-typecheck OK).
- `pnpm --filter @objectstack/formula test` → Test Files 56 passed (56),
Tests 1484 passed; `typecheck` → exit 0.
- `@objectstack/platform-objects` vitest (after its closure build) →
Test Files 96 passed (96), Tests 1361 passed. VERDICT command-exit 0.
- `ui` vitest `packages/i18n/` (from the ui root) → Test Files 89 passed
(89), Tests 1373 passed | 13 skipped. VERDICT command-exit 0.
- `pnpm --filter @objectstack/spec check:generated --fix` → 14 artifacts
current after `gen:docs` (re-check green).
- Dogfood battery, full (`pnpm --filter @objectstack/dogfood test`,
closure built, head `5f876858f`) → Test Files 348 passed | 3 skipped
(351), Tests 2804 passed | 20 skipped (2824). VERDICT command-exit 0 ·
held the lock 1733s.
</details>
<details><summary>Gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 113 derived families; the list is identical
on `c172c878e`.
- 110 exited 0 on the code head's tree. Three were re-run: `pnpm --dir
ui exec vitest run scripts/` (first run: one 15 s timeout in
`check-vi-mock-inherit.test.ts` while the dogfood held the box; that
file alone then 83/83, and the whole gate exit 0 on `c172c878e`), `spec
check:skill-examples` and `check:dual-build-cjs-loads` (exit 3, built
output missing; exit 0 after building client-react, studio and the other
named packages).
- The changeset-reading families re-ran on `c172c878e`, all exit 0:
`check-adr-0087-registration` (→ `registered
currency-config-fixed-default-currency-required,
currency-config-dynamic-default-currency-removed (new here: both)`),
`check-changeset-no-major`, `check-empty-changeset`,
`check-closing-keyword-parity`, `check:changeset-gate-self-tests`,
`check:pm-changeset-deadline-census`.
- `--ran` → `113 derived famil(ies) accounted for — 113 run, 0
NOT-MEASURED`.
- `node scripts/check-nul-bytes.mjs` → exit 0.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4d3a077 commit 0cd578f
27 files changed
Lines changed: 575 additions & 44 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references/data
- packages
- formula/src
- platform-objects/src/apps/translations
- spec
- authorable-defaults
- liveness
- scripts/lib
- src
- conversions
- data
- migrations
- entries/semantic
- ui/packages/i18n/src
- __tests__
| 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 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
218 | | - | |
| 218 | + | |
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
153 | 153 | | |
154 | 154 | | |
155 | 155 | | |
156 | | - | |
| 156 | + | |
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| |||
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | | - | |
| 252 | + | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| 41 | + | |
| 42 | + | |
41 | 43 | | |
42 | 44 | | |
43 | 45 | | |
| |||
127 | 129 | | |
128 | 130 | | |
129 | 131 | | |
| 132 | + | |
| 133 | + | |
130 | 134 | | |
131 | 135 | | |
132 | 136 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
132 | | - | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
133 | 137 | | |
134 | 138 | | |
135 | 139 | | |
136 | | - | |
137 | | - | |
138 | | - | |
| 140 | + | |
| 141 | + | |
139 | 142 | | |
140 | 143 | | |
141 | 144 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
621 | | - | |
| 621 | + | |
622 | 622 | | |
623 | 623 | | |
624 | 624 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
621 | | - | |
| 621 | + | |
622 | 622 | | |
623 | 623 | | |
624 | 624 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
621 | | - | |
| 621 | + | |
622 | 622 | | |
623 | 623 | | |
624 | 624 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
621 | | - | |
| 621 | + | |
622 | 622 | | |
623 | 623 | | |
624 | 624 | | |
| |||
0 commit comments