Skip to content

Commit 6abcb72

Browse files
fix(objectql): a currency field written as an object is refused with the bare-number lesson (#27258)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB3XVq26dCwy6GNva763uff5)_ Fixes #27236 Ruling-ref: 6464202770 Claim: 6468731037 Clause-②: no Metadata-protocol: none The accept set does not move, only the message. Measured: the same four writes (an object, `'abc'`, `10`, an object on a `number` field) answer the same verdict and the same wire code before and after; only the first one's `message` changes. No authorable key, schema, type or export changes. ## 维护者速读 - **改了什么:** 货币字段的值写成对象 `{ value, currency }` 时,拒收码仍是 `invalid_number`,但提示改为说明:货币值是裸数字(ADR-0104),币种在字段的 `currencyConfig` 里声明,dynamic 模式下取组织默认币种。 - **为什么改:** #27182 退役了 `{ value, currency }` 这个旧形状,AI 作者和老代码仍会这样写;以前只看到「必须是数字」,不知道该怎么改。 - **风险与回滚:** 只换提示文字,错误码、错误字段、接受与拒收的集合都不变;回滚 = revert 本 PR。 - **先看:** `packages/objectql/src/validation/record-validator.ts`(数值分支那一行 `fail`)、`packages/spec/src/system/validation-message.ts`(四种语言的新句子)。 - **两分钟验证:** `pnpm --filter @objectstack/objectql exec vitest run src/validation/record-validator.test.ts -t 27236` → 7 个用例全绿。 - **需要您做的:** 无,席位落地。 ## What changes - **`@objectstack/objectql`, the record validator's numeric arm** (`record-validator.ts`, `isJudgedNumberType`). When the field is `currency` and the value is a plain object (an object literal or a null-prototype record; never an array, a `Date` or another class instance), the `invalid_number` refusal renders the catalog message `invalid_number_currency_object` instead of the plain `invalid_number` one. The code stays `invalid_number` and the error carries no new key, so no client contract moves. Everything else in the arm keeps the plain sentence. - **`@objectstack/spec`, the validation-message catalog** (`system/validation-message.ts`). It gains `invalid_number_currency_object` in en, zh-CN, ja-JP and es-ES. **One message source:** the catalog owns the sentence and the validator passes only its key, so there is no second hand-written copy. A deployment can override it under `validation.field.invalid_number_currency_object`, as with every built-in message. - **No default currency is named.** The sentence names no currency code and no symbol in any locale; it points at `currencyConfig` (the `fixed` mode's `defaultCurrency`) or, under `dynamic`, the tenant default (#27119 Q2 = X). A spec pin checks this in every locale. - **The spec's `valueSchemaFor('currency')` arm is not touched.** The order asked for the sentence there only if `os validate` seed data reaches it. Measured: it does not (see Verification). That arm also lives in `packages/spec/src/data/field-value.zod.ts`, which PR #27233 edits, so leaving it alone is also what the serial constraint requires. - **Serial constraint.** PR #27233 (#27182) is open on the currency surface. This PR edits none of its 26 files (file lists compared at #27233 head `c85e08be`). It lands after #27233. ## Tests - `packages/objectql/src/validation/record-validator.test.ts`, a new `[#27236]` block, 7 cases: - `{ value: 10, currency: 'USD' }` on a dynamic-mode and on a fixed-mode currency field, on insert and on update, is refused as exactly `[{ field, code: 'invalid_number', message: LESSON, label }]`, the same keys as before. - The lesson is not the plain sentence, starts with "Amount is a currency amount: write it as a bare number", and names `currencyConfig`. - A bare number passes. - CONTROL: `'abc'` on a currency field keeps the plain sentence. - CONTROL: `[10]` on a currency field keeps the plain sentence. - CONTROL: an object on a `number` field keeps the plain sentence, because the lesson is currency-only. - `packages/spec/src/system/validation-message.test.ts`, a new block: every locale names `{ value, currency }`, `currencyConfig`, `defaultCurrency`, `fixed`, `dynamic` and ADR-0104, and no locale spells a three-letter currency code or a currency symbol. The existing completeness pins (every locale defines every key, every template names `{{label}}`) cover the new key. ## Acceptance notes - `os validate` does not judge seed record values against their field type. A seed row giving a currency field `{ value: 10, currency: 'USD' }` passes validation (exit 0) and is only refused later, when the seed is written through the record validator. This is a separate finding, reported to the seat and not changed here. ## Verification All readings below are on head `6d7035fe` (branch off `origin/main` `d745bb5e`). <details><summary>The refusal before and after (objectql validator, field def with no label, so the label is the API name)</summary> Before, on `origin/main` `d745bb5e`: ```text currency { value: 10, currency: 'USD' } → invalid_number "amount must be a number" currency (fixed, EUR) same object → invalid_number "amount must be a number" currency 'abc' → invalid_number "amount must be a number" currency 10 → PASS number { value: 10, currency: 'USD' } → invalid_number "amount must be a number" ``` After, on `6d7035fe`: ```text currency { value: 10, currency: 'USD' } → invalid_number "amount is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode's defaultCurrency, or under the dynamic mode (the default) the tenant default currency" currency 'abc' → invalid_number "amount must be a number" currency 10 → PASS number { value: 10, currency: 'USD' } → invalid_number "amount must be a number" ``` The error object's keys are `field`, `code`, `message` and `label` in every row, before and after. </details> <details><summary>Does os validate seed data reach valueSchemaFor('currency')? No</summary> `examples/app-todo`, temporarily given `budget: Field.currency({ label: 'Budget' })` and a seed row with `budget: { value: 10, currency: 'USD' }`, then `node packages/cli/bin/run.js validate` on `origin/main` `d745bb5e` (cli closure built). Result: `✓ Validation passed`, exit 0. The only line naming the field is the `field-no-consumers` warning, which lists `data[0].records[4].budget` as a carrier site, so the seed row was read and its value was not judged. `SeedRecordValueSchema` (`data/seed.zod.ts`) is `z.unknown()` apart from the seed-file claim. The temporary edits were restored with `git checkout HEAD --` and `git diff HEAD` was empty afterwards. </details> <details><summary>Ablation</summary> With the fix committed, `node scripts/ablation-replace.mjs` replaced `t === 'currency' && isPlainObjectValue(value) ?` with `false /* ablated-27236 */ ?` (anchor 1 → 0, marker 0 → 1, blob `f47f64b9` → `845a0720`), then ran `record-validator.test.ts`: **2 failed | 131 passed**, the two `refuses { value, currency } … with invalid_number and the lesson` cases. The restore confirmed blob equals HEAD and `git diff HEAD` empty. The objectql test imports the validator from `src`, so no rebuild was needed for that leg. A first attempt was a no-op and is not counted: its replacement text `undefined` already appeared in the file, and the tool refused it before running anything. </details> <details><summary>Package tests and typechecks (under os-verify-lock, each exit captured separately)</summary> - `pnpm --filter @objectstack/spec test`: exit 0, Test Files 743 passed (743), Tests 22265 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/objectql test`: exit 0, Test Files 477 passed (477), Tests 8906 passed (8906). - `pnpm --filter @objectstack/objectql typecheck`: exit 0. - Dogfood battery: not run (no `!` in the title, and `Clause-②` has no `(narrowing)` arm). </details> <details><summary>Derived gates: 82 derived, 81 run with exit 0, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 82 families. Each was run serially and recorded as `CMD :: exit N`, and `--ran` reconciled: **82 accounted, 81 run, 1 NOT-MEASURED, 0 UNRUN**. - `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (it needs a full-repo build; only the cli and objectql closures were built). NOT MEASURED; CI runs it. - `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 3 on the first run (the shallow clone lacked its pinned fixture commit), exit 0 after `git fetch origin 621a487`. - Every other derived family, including `check-adr-0087-registration --base origin/main`, `check-changeset-no-major`, `check-empty-changeset`, `check:doc-authoring`, `check:cross-package-test-inputs` and the spec `check:*` set, exited 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzenS8UJFmncftEGHsd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc4c963 commit 6abcb72

5 files changed

Lines changed: 130 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(objectql): a currency field written as an object, `{ value, currency }`, is refused with a sentence that says a currency value is a bare number
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's numeric arm gains a second sentence: no authorable key, spelling or stored shape of metadata moves, and no schema, type or export changes. `packages/spec` gains one sentence in the built-in validation-message catalog (`invalid_number_currency_object`, a rendering variant of the existing `invalid_number` wire code, which is unchanged), and the accept set of the write door is the same before and after. A stored row keeps whatever it holds. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is a message, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
12+
13+
The `{ value, currency }` shape was retired by ADR-0104 D1. A record write that still sends it to a `currency` field was refused with only "Amount must be a number", which does not say what to write instead.
14+
15+
- **`@objectstack/objectql`**: when a `currency` field's value is a plain object, the `invalid_number` refusal now reads: "Amount is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode's defaultCurrency, or under the dynamic mode (the default) the tenant default currency". The wire code stays `invalid_number`, with no new key on the error, so no client contract moves. A non-number string, an array, and an object on a `number` field keep the plain sentence.
16+
- **`@objectstack/spec`**: the validation-message catalog carries that sentence as `invalid_number_currency_object`, in en, zh-CN, ja-JP and es-ES. A deployment can override it under `validation.field.invalid_number_currency_object`, like any other built-in message.
17+
18+
What to do: nothing. Writes that were accepted are still accepted, and writes that were refused are still refused with the same code.

‎packages/objectql/src/validation/record-validator.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,66 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (#
13611361
});
13621362
});
13631363

1364+
/**
1365+
* [#27236] A `currency` field written as an object, the retired
1366+
* `{ value, currency }` shape (ADR-0104 D1), is refused with the SAME wire code
1367+
* as before, `invalid_number` and nothing more, and a sentence that teaches the
1368+
* shape: a bare number, with the currency declared in `currencyConfig` or taken
1369+
* from the tenant default. The accept set does not move. A non-number string, an
1370+
* array, and an object on a `number` field keep the plain sentence (CONTROLS).
1371+
*/
1372+
describe('[#27236] validateRecord — a currency value written as an object names the bare-number shape', () => {
1373+
const refusalOf = (schema: Parameters<typeof validateRecord>[0], data: Record<string, unknown>, mode: 'insert' | 'update' = 'insert') => {
1374+
try {
1375+
validateRecord(schema, data, mode);
1376+
} catch (e) {
1377+
expect(e).toBeInstanceOf(ValidationError);
1378+
return (e as ValidationError).fields;
1379+
}
1380+
return null;
1381+
};
1382+
const lesson = renderValidationMessage({ messageKey: 'invalid_number_currency_object', label: 'Amount', field: 'amount' });
1383+
const plain = renderValidationMessage({ messageKey: 'invalid_number', label: 'Amount', field: 'amount' });
1384+
const dynamic = { fields: { amount: { type: 'currency', label: 'Amount' } } };
1385+
const fixed = { fields: { amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } } } };
1386+
1387+
for (const [name, schema] of [['dynamic', dynamic], ['fixed', fixed]] as const) {
1388+
it(`refuses { value, currency } on a ${name}-mode field with invalid_number and the lesson, on insert and update`, () => {
1389+
for (const mode of ['insert', 'update'] as const) {
1390+
expect(refusalOf(schema, { amount: { value: 10, currency: 'USD' } }, mode)).toEqual([
1391+
{ field: 'amount', code: 'invalid_number', message: lesson, label: 'Amount' },
1392+
]);
1393+
}
1394+
});
1395+
}
1396+
1397+
it('the lesson is a different sentence from the plain one and names currencyConfig', () => {
1398+
expect(lesson).not.toBe(plain);
1399+
expect(lesson.startsWith('Amount is a currency amount: write it as a bare number')).toBe(true);
1400+
expect(lesson).toContain('currencyConfig');
1401+
});
1402+
1403+
it('a bare number passes', () => {
1404+
expect(refusalOf(dynamic, { amount: 10 })).toBeNull();
1405+
expect(refusalOf(fixed, { amount: 10 }, 'update')).toBeNull();
1406+
});
1407+
1408+
it('CONTROL — a non-number string on a currency field keeps the plain sentence', () => {
1409+
expect(refusalOf(dynamic, { amount: 'abc' })).toEqual([{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }]);
1410+
});
1411+
1412+
it('CONTROL — an array on a currency field keeps the plain sentence', () => {
1413+
expect(refusalOf(dynamic, { amount: [10] })).toEqual([{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }]);
1414+
});
1415+
1416+
it('CONTROL — an object on a number field keeps the plain sentence (the lesson is currency-only)', () => {
1417+
const number = { fields: { amount: { type: 'number', label: 'Amount' } } };
1418+
expect(refusalOf(number, { amount: { value: 10, currency: 'USD' } })).toEqual([
1419+
{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' },
1420+
]);
1421+
});
1422+
});
1423+
13641424
/**
13651425
* [#20846] A `date` / `datetime` value the kind's rule reads, whose year falls
13661426
* outside the kind's supported years, is refused with the SAME wire code as

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,17 @@ function isJudgedNumberType(type: string): boolean {
776776
return NUMERIC_VALUE_TYPES.has(type) && !COMPUTED_VALUE_TYPES.has(type);
777777
}
778778

779+
/**
780+
* [#27236] A plain object literal (`{ … }` or a null-prototype record), never
781+
* an array, a `Date` or another class instance. It chooses the sentence of a
782+
* `currency` field's `invalid_number` refusal, never the verdict.
783+
*/
784+
function isPlainObjectValue(value: unknown): boolean {
785+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
786+
const proto = Object.getPrototypeOf(value);
787+
return proto === Object.prototype || proto === null;
788+
}
789+
779790
/**
780791
* [#20309] A STRING on a number-typed field that the platform's numeric grammar
781792
* reads is written as the NUMBER it denotes — so what the record validator's
@@ -1201,9 +1212,14 @@ function validateOne(
12011212
// the payload at the door, so the driver stores what was judged. `min`,
12021213
// `max`, `scale` and `precision` below read that number, as they read a
12031214
// number.
1215+
//
1216+
// [#27236] A plain object on a `currency` field is the retired
1217+
// `{ value, currency }` shape (ADR-0104 D1). It keeps the code, so no client
1218+
// contract moves; its sentence says the value is a bare number and where the
1219+
// currency is declared. The sentence is the spec catalog's, never a copy.
12041220
if (isJudgedNumberType(t)) {
12051221
if (typeof value !== 'number' && typeof value !== 'string') {
1206-
return fail('invalid_number');
1222+
return fail('invalid_number', undefined, t === 'currency' && isPlainObjectValue(value) ? 'invalid_number_currency_object' : undefined);
12071223
}
12081224
const n = typeof value === 'number' ? value : parseNumericString(value);
12091225
if (n === undefined || !Number.isFinite(n)) {

‎packages/spec/src/system/validation-message.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,30 @@ describe('invalid_date_range / invalid_datetime_range — the years are paramete
253253
).toBe('发生时间必须是 UTC 年份在 1000 年至 9999 年之间的日期时间');
254254
});
255255
});
256+
257+
/**
258+
* #27236 — `invalid_number`'s currency sentence: a `currency` field written as
259+
* an object, the retired `{ value, currency }` shape (ADR-0104 D1). Every
260+
* locale teaches the same three facts, and none names a currency code: the
261+
* field's `currencyConfig` or the tenant default decides the currency, never
262+
* the sentence (#27119 Q2 = X).
263+
*/
264+
describe('invalid_number_currency_object — the shape lesson, with no currency code of its own', () => {
265+
it('every locale names the retired shape, currencyConfig, both modes and ADR-0104', () => {
266+
for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) {
267+
const template = catalog.invalid_number_currency_object;
268+
expect(template, locale).toBeDefined();
269+
for (const word of ['{ value, currency }', 'currencyConfig', 'defaultCurrency', 'fixed', 'dynamic', 'ADR-0104']) {
270+
expect(template, `${locale}: ${word}`).toContain(word);
271+
}
272+
}
273+
});
274+
275+
it('no locale spells a currency code', () => {
276+
for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) {
277+
const prose = catalog.invalid_number_currency_object.replace('ADR-0104', '');
278+
expect(prose, locale).not.toMatch(/(?<![A-Za-z])[A-Z]{3}(?![A-Za-z])/);
279+
expect(prose, locale).not.toMatch(/[$¥€£]/);
280+
}
281+
});
282+
});

‎packages/spec/src/system/validation-message.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
112112
invalid_url: '{{label}} must be a valid URL (scheme://...)',
113113
invalid_phone: '{{label}} must be a valid phone number',
114114
invalid_number: '{{label}} must be a number',
115+
// `invalid_number`'s second sentence (#27236): a `currency` field written
116+
// as an object, the retired `{ value, currency }` shape (ADR-0104 D1). The
117+
// sentence names no currency code: the field's `currencyConfig` or the
118+
// tenant default decides it (#27119 Q2 = X).
119+
invalid_number_currency_object: '{{label}} is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode\'s defaultCurrency, or under the dynamic mode (the default) the tenant default currency',
115120
invalid_boolean: '{{label}} must be true or false',
116121
invalid_date: '{{label}} must be a valid date (ISO-8601)',
117122
invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)',
@@ -174,6 +179,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
174179
invalid_url: '{{label}}必须是有效的 URL(scheme://...)',
175180
invalid_phone: '{{label}}必须是有效的电话号码',
176181
invalid_number: '{{label}}必须是数字',
182+
invalid_number_currency_object: '{{label}}是货币金额:请直接写数字(ADR-0104),如 10,不要写成 { value, currency } 这样的对象。币种在字段的 currencyConfig 中声明:fixed 模式取 defaultCurrency,dynamic 模式(默认)取组织默认币种',
177183
invalid_boolean: '{{label}}必须是 true 或 false',
178184
invalid_date: '{{label}}必须是有效的日期(ISO-8601)',
179185
invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)',
@@ -219,6 +225,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
219225
invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください',
220226
invalid_phone: '{{label}}は有効な電話番号を入力してください',
221227
invalid_number: '{{label}}は数値で入力してください',
228+
invalid_number_currency_object: '{{label}}は通貨金額です。{ value, currency } のようなオブジェクトではなく、数値のみ(ADR-0104、例: 10)で入力してください。通貨はフィールドの currencyConfig で宣言します。fixed モードでは defaultCurrency、dynamic モード(既定)では組織の既定通貨です',
222229
invalid_boolean: '{{label}}は true または false で入力してください',
223230
invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください',
224231
invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください',
@@ -264,6 +271,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
264271
invalid_url: '{{label}} debe ser una URL válida (scheme://...)',
265272
invalid_phone: '{{label}} debe ser un número de teléfono válido',
266273
invalid_number: '{{label}} debe ser un número',
274+
invalid_number_currency_object: '{{label}} es un importe de moneda: escríbalo como un número sin más (ADR-0104), por ejemplo 10, no como un objeto del tipo { value, currency }. El campo declara su moneda en currencyConfig: el defaultCurrency del modo fixed o, en el modo dynamic (el predeterminado), la moneda predeterminada del inquilino',
267275
invalid_boolean: '{{label}} debe ser true o false',
268276
invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)',
269277
invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)',

0 commit comments

Comments
 (0)