From 6d7035fec0ae4ba11b21e63485280e6b5964092f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:36:11 +0000 Subject: [PATCH 1/2] fix(objectql): a currency field written as an object is refused with the bare-number lesson The record validator's numeric arm refused `{ value, currency }` on a currency field with the plain `invalid_number` sentence. It now picks the spec catalog's `invalid_number_currency_object` sentence for a plain object on a currency field: a currency value is a bare number, and its currency is declared in `currencyConfig` or is the tenant default (ADR-0104). The wire code and the accept set are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzenS8UJFmncftEGHsd --- .../27236-currency-object-value-lesson.md | 18 ++++++ .../src/validation/record-validator.test.ts | 60 +++++++++++++++++++ .../src/validation/record-validator.ts | 18 +++++- .../src/system/validation-message.test.ts | 27 +++++++++ .../spec/src/system/validation-message.ts | 8 +++ 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 .changeset/27236-currency-object-value-lesson.md diff --git a/.changeset/27236-currency-object-value-lesson.md b/.changeset/27236-currency-object-value-lesson.md new file mode 100644 index 00000000000..ce0b8c6d085 --- /dev/null +++ b/.changeset/27236-currency-object-value-lesson.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/objectql': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + + + +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. + +- **`@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 (e.g. 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 (ADR-0104)". 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. +- **`@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. + +What to do: nothing. Writes that were accepted are still accepted, and writes that were refused are still refused with the same code. diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 864ab991759..65b289236a9 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -1361,6 +1361,66 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (# }); }); +/** + * [#27236] A `currency` field written as an object, the retired + * `{ value, currency }` shape (ADR-0104 D1), is refused with the SAME wire code + * as before, `invalid_number` and nothing more, and a sentence that teaches the + * shape: a bare number, with the currency declared in `currencyConfig` or taken + * from the tenant default. The accept set does not move. A non-number string, an + * array, and an object on a `number` field keep the plain sentence (CONTROLS). + */ +describe('[#27236] validateRecord — a currency value written as an object names the bare-number shape', () => { + const refusalOf = (schema: Parameters[0], data: Record, mode: 'insert' | 'update' = 'insert') => { + try { + validateRecord(schema, data, mode); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + return (e as ValidationError).fields; + } + return null; + }; + const lesson = renderValidationMessage({ messageKey: 'invalid_number_currency_object', label: 'Amount', field: 'amount' }); + const plain = renderValidationMessage({ messageKey: 'invalid_number', label: 'Amount', field: 'amount' }); + const dynamic = { fields: { amount: { type: 'currency', label: 'Amount' } } }; + const fixed = { fields: { amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } } } }; + + for (const [name, schema] of [['dynamic', dynamic], ['fixed', fixed]] as const) { + it(`refuses { value, currency } on a ${name}-mode field with invalid_number and the lesson, on insert and update`, () => { + for (const mode of ['insert', 'update'] as const) { + expect(refusalOf(schema, { amount: { value: 10, currency: 'USD' } }, mode)).toEqual([ + { field: 'amount', code: 'invalid_number', message: lesson, label: 'Amount' }, + ]); + } + }); + } + + it('the lesson is a different sentence from the plain one and names currencyConfig', () => { + expect(lesson).not.toBe(plain); + expect(lesson.startsWith('Amount is a currency amount: write it as a bare number')).toBe(true); + expect(lesson).toContain('currencyConfig'); + }); + + it('a bare number passes', () => { + expect(refusalOf(dynamic, { amount: 10 })).toBeNull(); + expect(refusalOf(fixed, { amount: 10 }, 'update')).toBeNull(); + }); + + it('CONTROL — a non-number string on a currency field keeps the plain sentence', () => { + expect(refusalOf(dynamic, { amount: 'abc' })).toEqual([{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }]); + }); + + it('CONTROL — an array on a currency field keeps the plain sentence', () => { + expect(refusalOf(dynamic, { amount: [10] })).toEqual([{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }]); + }); + + it('CONTROL — an object on a number field keeps the plain sentence (the lesson is currency-only)', () => { + const number = { fields: { amount: { type: 'number', label: 'Amount' } } }; + expect(refusalOf(number, { amount: { value: 10, currency: 'USD' } })).toEqual([ + { field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }, + ]); + }); +}); + /** * [#20846] A `date` / `datetime` value the kind's rule reads, whose year falls * outside the kind's supported years, is refused with the SAME wire code as diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 1bf983da76e..f47f64b9f0e 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -776,6 +776,17 @@ function isJudgedNumberType(type: string): boolean { return NUMERIC_VALUE_TYPES.has(type) && !COMPUTED_VALUE_TYPES.has(type); } +/** + * [#27236] A plain object literal (`{ … }` or a null-prototype record), never + * an array, a `Date` or another class instance. It chooses the sentence of a + * `currency` field's `invalid_number` refusal, never the verdict. + */ +function isPlainObjectValue(value: unknown): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + /** * [#20309] A STRING on a number-typed field that the platform's numeric grammar * reads is written as the NUMBER it denotes — so what the record validator's @@ -1201,9 +1212,14 @@ function validateOne( // the payload at the door, so the driver stores what was judged. `min`, // `max`, `scale` and `precision` below read that number, as they read a // number. + // + // [#27236] A plain object on a `currency` field is the retired + // `{ value, currency }` shape (ADR-0104 D1). It keeps the code, so no client + // contract moves; its sentence says the value is a bare number and where the + // currency is declared. The sentence is the spec catalog's, never a copy. if (isJudgedNumberType(t)) { if (typeof value !== 'number' && typeof value !== 'string') { - return fail('invalid_number'); + return fail('invalid_number', undefined, t === 'currency' && isPlainObjectValue(value) ? 'invalid_number_currency_object' : undefined); } const n = typeof value === 'number' ? value : parseNumericString(value); if (n === undefined || !Number.isFinite(n)) { diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts index ee502adf085..c95db1df68b 100644 --- a/packages/spec/src/system/validation-message.test.ts +++ b/packages/spec/src/system/validation-message.test.ts @@ -253,3 +253,30 @@ describe('invalid_date_range / invalid_datetime_range — the years are paramete ).toBe('发生时间必须是 UTC 年份在 1000 年至 9999 年之间的日期时间'); }); }); + +/** + * #27236 — `invalid_number`'s currency sentence: a `currency` field written as + * an object, the retired `{ value, currency }` shape (ADR-0104 D1). Every + * locale teaches the same three facts, and none names a currency code: the + * field's `currencyConfig` or the tenant default decides the currency, never + * the sentence (#27119 Q2 = X). + */ +describe('invalid_number_currency_object — the shape lesson, with no currency code of its own', () => { + it('every locale names the retired shape, currencyConfig, both modes and ADR-0104', () => { + for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) { + const template = catalog.invalid_number_currency_object; + expect(template, locale).toBeDefined(); + for (const word of ['{ value, currency }', 'currencyConfig', 'defaultCurrency', 'fixed', 'dynamic', 'ADR-0104']) { + expect(template, `${locale}: ${word}`).toContain(word); + } + } + }); + + it('no locale spells a currency code', () => { + for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) { + const prose = catalog.invalid_number_currency_object.replace('ADR-0104', ''); + expect(prose, locale).not.toMatch(/(?> invalid_url: '{{label}} must be a valid URL (scheme://...)', invalid_phone: '{{label}} must be a valid phone number', invalid_number: '{{label}} must be a number', + // `invalid_number`'s second sentence (#27236): a `currency` field written + // as an object, the retired `{ value, currency }` shape (ADR-0104 D1). The + // sentence names no currency code: the field's `currencyConfig` or the + // tenant default decides it (#27119 Q2 = X). + invalid_number_currency_object: '{{label}} is a currency amount: write it as a bare number (e.g. 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 (ADR-0104)', invalid_boolean: '{{label}} must be true or false', invalid_date: '{{label}} must be a valid date (ISO-8601)', invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)', @@ -174,6 +179,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_url: '{{label}}必须是有效的 URL(scheme://...)', invalid_phone: '{{label}}必须是有效的电话号码', invalid_number: '{{label}}必须是数字', + invalid_number_currency_object: '{{label}}是货币金额:请直接写数字(如 10),不要写成 { value, currency } 这样的对象。币种在字段的 currencyConfig 中声明:fixed 模式取 defaultCurrency,dynamic 模式(默认)取组织默认币种(ADR-0104)', invalid_boolean: '{{label}}必须是 true 或 false', invalid_date: '{{label}}必须是有效的日期(ISO-8601)', invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)', @@ -219,6 +225,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください', invalid_phone: '{{label}}は有効な電話番号を入力してください', invalid_number: '{{label}}は数値で入力してください', + invalid_number_currency_object: '{{label}}は通貨金額です。{ value, currency } のようなオブジェクトではなく、数値のみ(例: 10)で入力してください。通貨はフィールドの currencyConfig で宣言します。fixed モードでは defaultCurrency、dynamic モード(既定)では組織の既定通貨です(ADR-0104)', invalid_boolean: '{{label}}は true または false で入力してください', invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください', invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください', @@ -264,6 +271,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_url: '{{label}} debe ser una URL válida (scheme://...)', invalid_phone: '{{label}} debe ser un número de teléfono válido', invalid_number: '{{label}} debe ser un número', + invalid_number_currency_object: '{{label}} es un importe de moneda: escríbalo como un número sin más (p. ej., 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 (ADR-0104)', invalid_boolean: '{{label}} debe ser true o false', invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)', invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)', From 0dd08045be400cc0e17736de7cb884a180b0616e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:43:01 +0000 Subject: [PATCH 2/2] fix(spec): the currency-object sentence cites ADR-0104 on the bare-number clause only ADR-0104 decides that a currency value is a bare number; the tenant default comes from the currency-mode contract, not from the ADR. The cite moves next to the bare-number clause in all four locales and in the changeset's quoted sentence. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzenS8UJFmncftEGHsd --- .changeset/27236-currency-object-value-lesson.md | 2 +- packages/spec/src/system/validation-message.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/27236-currency-object-value-lesson.md b/.changeset/27236-currency-object-value-lesson.md index ce0b8c6d085..459b0d90221 100644 --- a/.changeset/27236-currency-object-value-lesson.md +++ b/.changeset/27236-currency-object-value-lesson.md @@ -12,7 +12,7 @@ Metadata-protocol: none 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. -- **`@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 (e.g. 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 (ADR-0104)". 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. +- **`@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. - **`@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. What to do: nothing. Writes that were accepted are still accepted, and writes that were refused are still refused with the same code. diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index 2d5d9714cd9..c751aaa604f 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -116,7 +116,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> // as an object, the retired `{ value, currency }` shape (ADR-0104 D1). The // sentence names no currency code: the field's `currencyConfig` or the // tenant default decides it (#27119 Q2 = X). - invalid_number_currency_object: '{{label}} is a currency amount: write it as a bare number (e.g. 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 (ADR-0104)', + 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', invalid_boolean: '{{label}} must be true or false', invalid_date: '{{label}} must be a valid date (ISO-8601)', invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)', @@ -179,7 +179,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_url: '{{label}}必须是有效的 URL(scheme://...)', invalid_phone: '{{label}}必须是有效的电话号码', invalid_number: '{{label}}必须是数字', - invalid_number_currency_object: '{{label}}是货币金额:请直接写数字(如 10),不要写成 { value, currency } 这样的对象。币种在字段的 currencyConfig 中声明:fixed 模式取 defaultCurrency,dynamic 模式(默认)取组织默认币种(ADR-0104)', + invalid_number_currency_object: '{{label}}是货币金额:请直接写数字(ADR-0104),如 10,不要写成 { value, currency } 这样的对象。币种在字段的 currencyConfig 中声明:fixed 模式取 defaultCurrency,dynamic 模式(默认)取组织默认币种', invalid_boolean: '{{label}}必须是 true 或 false', invalid_date: '{{label}}必须是有效的日期(ISO-8601)', invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)', @@ -225,7 +225,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください', invalid_phone: '{{label}}は有効な電話番号を入力してください', invalid_number: '{{label}}は数値で入力してください', - invalid_number_currency_object: '{{label}}は通貨金額です。{ value, currency } のようなオブジェクトではなく、数値のみ(例: 10)で入力してください。通貨はフィールドの currencyConfig で宣言します。fixed モードでは defaultCurrency、dynamic モード(既定)では組織の既定通貨です(ADR-0104)', + invalid_number_currency_object: '{{label}}は通貨金額です。{ value, currency } のようなオブジェクトではなく、数値のみ(ADR-0104、例: 10)で入力してください。通貨はフィールドの currencyConfig で宣言します。fixed モードでは defaultCurrency、dynamic モード(既定)では組織の既定通貨です', invalid_boolean: '{{label}}は true または false で入力してください', invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください', invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください', @@ -271,7 +271,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_url: '{{label}} debe ser una URL válida (scheme://...)', invalid_phone: '{{label}} debe ser un número de teléfono válido', invalid_number: '{{label}} debe ser un número', - invalid_number_currency_object: '{{label}} es un importe de moneda: escríbalo como un número sin más (p. ej., 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 (ADR-0104)', + 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', invalid_boolean: '{{label}} debe ser true o false', invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)', invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)',