From 59ac9e812c6c214671085823c95568b13d91e926 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:54:55 +0000 Subject: [PATCH 01/21] =?UTF-8?q?feat(spec):=20currencyMode=20'record'=20+?= =?UTF-8?q?=20currencyField=20=E2=80=94=20a=20record-mode=20amount=20point?= =?UTF-8?q?s=20at=20a=20sibling=20ISO=204217=20code=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .../data/currency-config-record-mode.test.ts | 147 ++++++++++++++++++ packages/spec/src/data/field.form.ts | 5 +- packages/spec/src/data/field.zod.ts | 54 ++++++- packages/spec/src/data/object.zod.ts | 83 ++++++++++ skills/objectstack-data/rules/field-types.md | 8 + 5 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 packages/spec/src/data/currency-config-record-mode.test.ts diff --git a/packages/spec/src/data/currency-config-record-mode.test.ts b/packages/spec/src/data/currency-config-record-mode.test.ts new file mode 100644 index 00000000000..cea2f5453e8 --- /dev/null +++ b/packages/spec/src/data/currency-config-record-mode.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D2] `currencyMode: 'record'` + `currencyField` — a record-mode + * amount points at a sibling ISO 4217 code field on the same object. + * + * Two doors, one per half: `CurrencyConfigSchema` refuses the half one config + * can see (`record` without a pointer, a pointer under another mode), and the + * object's `superRefine` refuses what the pointer names (nothing, a non-text + * field, a text field with no ISO 4217 value domain). A `formula` declaring + * `returnType: 'currency'` carries the same config, so it meets both doors. + * `dynamic` and `fixed` parse exactly as they did before `record` existed. + */ + +import { describe, it, expect } from 'vitest'; + +import { CurrencyConfigSchema, FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +const CODE_FIELD = { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' } as const; +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; + +function invoice(fields: Record): Record { + return { name: 'invoice', label: 'Invoice', fields }; +} + +/** The issues of a refused object parse, reduced to what an author is shown. */ +function objectIssues(input: unknown): Array<{ code: string; path: PropertyKey[]; message: string }> { + const r = ObjectSchema.safeParse(input); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path, message: i.message })); +} + +describe('`CurrencyConfigSchema`: `record` ⇔ `currencyField`', () => { + it('a `record` config with a pointer parses, keeping the mode and the pointer', () => { + const r = CurrencyConfigSchema.safeParse(RECORD); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.currencyMode).toBe('record'); + expect(r.data.currencyField).toBe('currency_code'); + }); + + it('refuses `record` without `currencyField`, at the key, with the prescription', () => { + const r = CurrencyConfigSchema.safeParse({ currencyMode: 'record' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual(['currencyField']); + expect(r.error.issues[0]!.message).toMatch(/^`currencyMode: 'record'` needs `currencyField`/); + }); + + it.each(['dynamic', 'fixed'] as const)('refuses `currencyField` under `%s`, at the key', (mode) => { + const r = CurrencyConfigSchema.safeParse({ currencyMode: mode, defaultCurrency: 'USD', currencyField: 'currency_code' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['currencyField']); + expect(r.error.issues[0]!.message).toMatch(/^`currencyField` is read only under `currencyMode: 'record'`/); + }); + + it('refuses `currencyField` under the defaulted mode (no `currencyMode` written ⇒ dynamic)', () => { + expect(CurrencyConfigSchema.safeParse({ currencyField: 'currency_code' }).success).toBe(false); + }); + + it('`dynamic` and `fixed` outputs are byte-identical to before `record` existed', () => { + const cases: Array<[unknown, string]> = [ + [{}, '{"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + [{ currencyMode: 'dynamic' }, '{"currencyMode":"dynamic","defaultCurrency":"CNY"}'], + [{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }, '{"currencyMode":"dynamic","defaultCurrency":"EUR"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, '{"currencyMode":"fixed","defaultCurrency":"USD"}'], + [{ currencyMode: 'fixed' }, '{"currencyMode":"fixed","defaultCurrency":"CNY"}'], + ]; + for (const [input, before] of cases) { + expect(JSON.stringify(CurrencyConfigSchema.parse(input))).toBe(before); + } + }); + + it('parse is idempotent: `parse(parse(x))` equals `parse(x)` in every mode', () => { + for (const input of [{}, { currencyMode: 'fixed', defaultCurrency: 'USD' }, RECORD]) { + const once = CurrencyConfigSchema.parse(input); + expect(CurrencyConfigSchema.parse(once)).toEqual(once); + } + const object = invoice({ currency_code: CODE_FIELD, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD } }); + const once = ObjectSchema.parse(object); + expect(ObjectSchema.parse(once)).toEqual(once); + }); +}); + +describe('the object judges what `currencyField` names', () => { + it('accepts a pointer at a declared `text` field with `valueDomain: iso_4217_currency`', () => { + const object = invoice({ currency_code: CODE_FIELD, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD } }); + expect(objectIssues(object)).toEqual([]); + }); + + it('refuses a pointer into nothing, naming the candidates', () => { + const issues = objectIssues(invoice({ + doc_currency: CODE_FIELD, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, but object `invoice` declares no other field/); + expect(issues[0]!.message).toContain('candidates: `doc_currency`'); + }); + + it('refuses a pointer at the amount itself', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues.map((i) => i.path)).toEqual([['fields', 'currency_code', 'currencyConfig', 'currencyField']]); + expect(issues[0]!.message).toContain('the object has none; declare one'); + }); + + it('refuses a pointer at a field that is not `text`', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'select', label: 'Currency', options: [{ label: 'USD', value: 'USD' }] }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `select` field/); + }); + + it('refuses a pointer at a `text` field without the ISO 4217 value domain', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'text', label: 'Currency' }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `text` field .* does not declare `valueDomain: 'iso_4217_currency'`/); + }); + + it('a `formula` with `returnType: currency` meets the same doors in the same shape', () => { + const formula = (currencyConfig: unknown) => ({ + type: 'formula', label: 'Net', expression: 'record.amount * 0.9', returnType: 'currency', currencyConfig, + }); + expect(FieldSchema.safeParse({ name: 'net', ...formula(RECORD) }).success).toBe(true); + expect(FieldSchema.safeParse({ name: 'net', ...formula({ currencyMode: 'record' }) }).success).toBe(false); + const ok = invoice({ currency_code: CODE_FIELD, net: formula(RECORD) }); + expect(objectIssues(ok)).toEqual([]); + const bad = invoice({ currency_code: { type: 'text', label: 'Currency' }, net: formula(RECORD) }); + expect(objectIssues(bad).map((i) => i.path)).toEqual([['fields', 'net', 'currencyConfig', 'currencyField']]); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index f40ee308f2f..233367297b9 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -108,10 +108,13 @@ export const fieldForm = defineForm({ visibleWhen: "data.type == 'currency' || (data.type == 'formula' && data.returnType == 'currency')", helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode.', fields: [ - { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency.', options: [ + { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names.', options: [ { label: 'Dynamic — the tenant default currency', value: 'dynamic' }, { label: 'Fixed — one currency for this field', value: 'fixed' }, + { label: 'Record — each record names its currency', value: 'record' }, ] }, + // ADR-0144 D2 — the record-mode pointer. + { field: 'currencyField', type: 'text', helpText: 'Record mode only, and required there: the field on this object holding each record\'s ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code.' }, { field: 'defaultCurrency', type: 'text', helpText: 'The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Defaults to CNY. Not read in dynamic mode.' }, ], }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 2777cf65b3c..d490e3a3a9d 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -534,6 +534,37 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> + ' Delete the key.', }; +/** + * [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`. A `record`-mode + * amount has no currency until a pointer names the sibling code field, and a + * pointer under `dynamic` / `fixed` is read by nothing — the declared-but-inert + * shape ADR-0078 keeps out. One located issue at `['currencyField']` either + * way; what the pointer NAMES is the object's to judge (object.zod.ts). + */ +function refuseCurrencyFieldOutsideRecordMode( + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; currencyField?: string }, + ctx: z.RefinementCtx, +): void { + if (config.currencyMode === 'record' && config.currencyField === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['currencyField'], + message: + '`currencyMode: \'record\'` needs `currencyField`: name the field on this object that holds each ' + + 'record\'s ISO 4217 code — a `text` field with `valueDomain: \'iso_4217_currency\'`, conventionally ' + + '`currency_code` — or use `currencyMode: \'fixed\'` (one currency) or `\'dynamic\'` (the tenant default).', + }); + } else if (config.currencyMode !== 'record' && config.currencyField !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['currencyField'], + message: + `\`currencyField\` is read only under \`currencyMode: 'record'\`, and this config is \`${config.currencyMode}\`: ` + + 'set `currencyMode: \'record\'` if each record carries its own currency, or delete `currencyField`.', + }); + } +} + /** * Currency Configuration Schema * Configuration for currency field type supporting multi-currency — and, in the @@ -555,14 +586,21 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ aliases: { mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE, }, { - currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way.'), - defaultCurrency: z.string().length(3).default('CNY').describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency.'), - // #19992 — no `.superRefine()` / `.overwrite()` here any more. The #7918 - // ISO 4217 contradiction check and the #11423 default-materializing - // `.overwrite()` both existed only for the removed `precision` key (see - // CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE above), so parse output is now - // exactly `{ currencyMode, defaultCurrency }`. -})); + currencyMode: z.enum(['dynamic', 'fixed', 'record']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1).'), + defaultCurrency: z.string().length(3).default('CNY').describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own.'), + // ADR-0144 D2 — the pointer of a `record`-mode amount. Optional at the key + // level; `refuseCurrencyFieldOutsideRecordMode` below makes it required + // under `record` and refuses it under the other two modes, and the object's + // `superRefine` (`refuseDishonestRecordCurrencyField`, object.zod.ts) checks + // what it names, because only the object holds the sibling field map. + currencyField: z.string().optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`.'), + // #19992 — no `.overwrite()` here any more. The #7918 ISO 4217 + // contradiction check and the #11423 default-materializing `.overwrite()` + // both existed only for the removed `precision` key (see + // CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE above), so parse output is + // exactly `{ currencyMode, defaultCurrency }` plus the `currencyField` a + // `record`-mode author wrote. +}).superRefine(refuseCurrencyFieldOutsideRecordMode)); /** * Currency Value Schema diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index ccb40733e21..5ed722c6bc9 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1812,6 +1812,86 @@ function refuseNonPictureImageField( }); } +/** + * [ADR-0144 D2] A `record`-mode amount's `currencyConfig.currencyField` names + * the field THIS object declares that holds each record's currency: a `text` + * field carrying `valueDomain: 'iso_4217_currency'`. The amount stays a bare + * number (ADR-0104 D1); the code field is the only place its currency lives, + * so a pointer into nothing, into a non-text field or into an unconstrained + * text field would parse and give the amount a currency no write ever checks. + * + * `CurrencyConfigSchema` sees one config at a time and refuses the half it + * can see (a `record` config with no pointer, a pointer under another mode); + * what the pointer names is judged here, beside + * {@link refuseNonPictureImageField}, for the same reason: only the object + * holds the field map. Judged for every field whose config is `record`, so a + * `formula` declaring `returnType: 'currency'` meets it in the same shape. + * + * Three refusals, one located issue per offending field at + * `['fields', , 'currencyConfig', 'currencyField']`, each carrying the + * prescription and the object's candidate fields: + * + * - the name is not a declared field (or is the amount itself); + * - the field is not `text`; + * - the field is `text` without `valueDomain: 'iso_4217_currency'`. + * + * Judged against the AUTHORED field map. Not judged on + * `ObjectExtensionSchema`: an extension's fields merge into an object it does + * not hold, so its pointer may name a field only the owner declares. + */ +function refuseDishonestRecordCurrencyField( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const map = fields as Record; + const isCodeField = (f: (typeof map)[string]): boolean => + f?.type === 'text' && f.valueDomain === 'iso_4217_currency'; + for (const [name, field] of Object.entries(map)) { + const config = field?.currencyConfig as { currencyMode?: unknown; currencyField?: unknown } | undefined; + if (config?.currencyMode !== 'record' || typeof config.currencyField !== 'string') continue; + const target = config.currencyField; + const candidates = Object.keys(map).filter((k) => k !== name && isCodeField(map[k]!)); + const prescription = + '`currencyField` must name a `text` field of this object declaring `valueDomain: \'iso_4217_currency\'` — ' + + (candidates.length > 0 + ? `candidates: ${candidates.map((c) => `\`${c}\``).join(', ')}.` + : 'the object has none; declare one, conventionally `currency_code: Field.text({ valueDomain: \'iso_4217_currency\' })`.'); + const path = ['fields', name, 'currencyConfig', 'currencyField']; + const declared = target !== name && Object.prototype.hasOwnProperty.call(map, target); + if (!declared) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, but object \`${objectName}\` declares no ` + + `other field \`${target}\`. ${prescription}`, + }); + continue; + } + const type = map[target]?.type; + if (type !== 'text') { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object ` + + `\`${objectName}\`; a record currency is a \`text\` ISO 4217 code. ${prescription}`, + }); + continue; + } + if (!isCodeField(map[target]!)) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`text\` field on object \`${objectName}\` ` + + `that does not declare \`valueDomain: 'iso_4217_currency'\`, so a write could store any string as the ` + + `currency. Add \`valueDomain: 'iso_4217_currency'\` to \`${target}\`, or point at another field. ${prescription}`, + }); + } + } +} + /** * [objectui#12489 ruling 6203737086, letter A] A field's * `referenceDeletedWhen: { field, value }` names a SIBLING `select` field on @@ -3394,6 +3474,9 @@ const ObjectSchemaBase = strictObject( // object — the same door, for the same reason: only the object holds the // field map the pointer resolves against. refuseNonPictureImageField(object.name, object.imageField, object.fields, ctx); + // [ADR-0144 D2] A `record`-mode amount's `currencyField` names a declared + // ISO 4217 `text` sibling — the same door: only the object holds the map. + refuseDishonestRecordCurrencyField(object.name, object.fields, ctx); // [objectui#12489 ruling 6203737086, letter A] A `referenceDeletedWhen` // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 4f929d6c349..70ec654f415 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -186,6 +186,14 @@ D1), never `{ value, currency }`. The displayed symbol: under `'fixed'`, `localization.currency` setting, not `defaultCurrency`; with neither, a plain grouped number (never a hardcoded `$`). A measure's own `currency` wins. +**Per-record currency** (`currencyMode: 'record'`): `currencyField` names a +`text` field on the same object with `valueDomain: 'iso_4217_currency'` that +holds each record's code; the amount stays a bare number. Name it +`currency_code`; an amount `price`'s home-currency copy and its rate go in +`price_home` and `price_rate` (`AMOUNT_home`, `AMOUNT_rate`) — a convention, +not a contract. A pointer at a missing, non-`text` or domain-less field is +refused at parse. + ### Select with Default ```typescript From 2537aff8fba17066889c6e99fb5c663e291b724c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:14:10 +0000 Subject: [PATCH 02/21] chore(spec): regenerate artifacts and declare the record-mode refinement in the dropped-refinements ledger Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .changeset/27184-currency-record-mode.md | 23 +++++++++++++++++++ content/docs/references/data/field.mdx | 12 ++++++---- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- packages/spec/authorable-surface/data.json | 1 + .../spec/dropped-refinements.baseline.json | 17 ++++++++++++++ .../data/currency-config-record-mode.test.ts | 4 ++-- 7 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 .changeset/27184-currency-record-mode.md diff --git a/.changeset/27184-currency-record-mode.md b/.changeset/27184-currency-record-mode.md new file mode 100644 index 00000000000..43e3071d6ea --- /dev/null +++ b/.changeset/27184-currency-record-mode.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2) + +Clause-②: yes (widening) +Metadata-protocol: added + +A currency amount (or a `formula` with `returnType: 'currency'`) can now carry a currency per record. `currencyConfig.currencyMode` gains a third value, `record`, and `currencyConfig.currencyField` names the field on the same object that holds each record's ISO 4217 code. The amount itself stays a bare number (ADR-0104 D1). + +```ts +fields: { + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + amount: Field.currency({ label: 'Amount', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }), +} +``` + +- `currencyField` is required under `record` and refused under `dynamic` and `fixed`. +- The object refuses a `currencyField` that names no other declared field, a field that is not `text`, or a `text` field without `valueDomain: 'iso_4217_currency'`. Each refusal is located at `fields.FIELD.currencyConfig.currencyField` and lists the object's candidate code fields. +- `dynamic` and `fixed` configs parse exactly as before; an app that never writes `record` sees no change. + +⛔ Not released alone: this ships in the same release as the record-mode aggregate refusals and the per-row display. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index c319e88e320..8a148e37557 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -28,8 +28,9 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | +| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | --- @@ -101,7 +102,7 @@ const result = CurrencyConfigSchema.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -248,8 +249,9 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | +| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e0c72980991..592b298cd27 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -267,7 +267,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -629,7 +629,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 148179f0d46..33757a7c57c 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -102,7 +102,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -532,7 +532,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index c6278e47f81..2f574c25018 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -101,6 +101,7 @@ "data/Cube:sql", "data/Cube:title", "data/CubeJoin:name", + "data/CurrencyConfig:currencyField", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", "data/CurrencyValue:currency", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index e0da8fefe65..008e051060a 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -105,6 +105,7 @@ "manifest.objects.element.defaultRowScope", "manifest.objects.element.fieldGroups", "manifest.objects.element.fields.out.valueType", + "manifest.objects.element.fields.out.valueType.currencyConfig", "manifest.objects.element.fields.out.valueType.inlineColumns.element", "manifest.objects.element.lifecycle", "manifest.objects.element.membershipKeys", @@ -234,6 +235,7 @@ "data.options[1].manifest.objects.element.defaultRowScope", "data.options[1].manifest.objects.element.fieldGroups", "data.options[1].manifest.objects.element.fields.out.valueType", + "data.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "data.options[1].manifest.objects.element.lifecycle", "data.options[1].manifest.objects.element.membershipKeys", @@ -362,6 +364,7 @@ "options[1].manifest.objects.element.defaultRowScope", "options[1].manifest.objects.element.fieldGroups", "options[1].manifest.objects.element.fields.out.valueType", + "options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "options[1].manifest.objects.element.lifecycle", "options[1].manifest.objects.element.membershipKeys", @@ -433,6 +436,7 @@ "data.packages.element.options[1].manifest.objects.element.defaultRowScope", "data.packages.element.options[1].manifest.objects.element.fieldGroups", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType", + "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.currencyConfig", "data.packages.element.options[1].manifest.objects.element.fields.out.valueType.inlineColumns.element", "data.packages.element.options[1].manifest.objects.element.lifecycle", "data.packages.element.options[1].manifest.objects.element.membershipKeys", @@ -485,6 +489,7 @@ "data.defaultRowScope", "data.fieldGroups", "data.fields.out.valueType", + "data.fields.out.valueType.currencyConfig", "data.fields.out.valueType.inlineColumns.element", "data.fields.out.valueType.relatedListFilter.lazy", "data.lifecycle", @@ -756,6 +761,11 @@ "measures.valueType" ] }, + "data/CurrencyConfig": { + "sites": [ + "" + ] + }, "data/DataEngineAggregateOptions": { "sites": [ "filter.options[1].lazy" @@ -875,6 +885,7 @@ "data/Field": { "sites": [ "", + "currencyConfig", "inlineColumns.element", "relatedListFilter.lazy" ] @@ -977,6 +988,7 @@ "defaultRowScope", "fieldGroups", "fields.out.valueType", + "fields.out.valueType.currencyConfig", "fields.out.valueType.inlineColumns.element", "fields.out.valueType.relatedListFilter.lazy", "lifecycle", @@ -996,6 +1008,7 @@ "sites": [ "", "fields.valueType", + "fields.valueType.currencyConfig", "fields.valueType.inlineColumns.element", "fields.valueType.relatedListFilter.lazy" ] @@ -1276,6 +1289,7 @@ "system/AddFieldOperation": { "sites": [ "field", + "field.currencyConfig", "field.inlineColumns.element", "field.relatedListFilter.lazy" ] @@ -1298,6 +1312,7 @@ "system/ChangeSet": { "sites": [ "operations.element.options[0].field", + "operations.element.options[0].field.currencyConfig", "operations.element.options[0].field.inlineColumns.element", "operations.element.options[0].field.relatedListFilter.lazy", "operations.element.options[3].object", @@ -1331,6 +1346,7 @@ "object.defaultRowScope", "object.fieldGroups", "object.fields.out.valueType", + "object.fields.out.valueType.currencyConfig", "object.fields.out.valueType.inlineColumns.element", "object.fields.out.valueType.relatedListFilter.lazy", "object.lifecycle", @@ -1370,6 +1386,7 @@ "system/MigrationOperation": { "sites": [ "options[0].field", + "options[0].field.currencyConfig", "options[0].field.inlineColumns.element", "options[0].field.relatedListFilter.lazy", "options[3].object", diff --git a/packages/spec/src/data/currency-config-record-mode.test.ts b/packages/spec/src/data/currency-config-record-mode.test.ts index cea2f5453e8..92e6613b8e5 100644 --- a/packages/spec/src/data/currency-config-record-mode.test.ts +++ b/packages/spec/src/data/currency-config-record-mode.test.ts @@ -115,12 +115,12 @@ describe('the object judges what `currencyField` names', () => { it('refuses a pointer at a field that is not `text`', () => { const issues = objectIssues(invoice({ - currency_code: { type: 'select', label: 'Currency', options: [{ label: 'USD', value: 'USD' }] }, + currency_code: { type: 'number', label: 'Currency' }, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, })); expect(issues).toHaveLength(1); expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); - expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `select` field/); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `number` field/); }); it('refuses a pointer at a `text` field without the ISO 4217 value domain', () => { From 1d9a5331614a575393b6bb63b9855d934f5ee8f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:20:46 +0000 Subject: [PATCH 03/21] chore(spec): dropped-refinements ledger header totals for the record-mode sites Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 008e051060a..8770d6cf062 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 251, - "droppedRefinementSites": 887, + "publishedSchemasWithDroppedRefinements": 252, + "droppedRefinementSites": 900, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, From d1316ddb9c1097ccd97ce5608a7a73241b2432ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:21:53 +0000 Subject: [PATCH 04/21] =?UTF-8?q?docs(skills):=20field-types=20=E2=80=94?= =?UTF-8?q?=20the=20record=20currency=20mode=20and=20its=20sibling-field?= =?UTF-8?q?=20naming=20convention,=20paid=20by=20tightening=20the=20curren?= =?UTF-8?q?cy=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- skills/objectstack-data/rules/field-types.md | 26 ++++++-------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 70ec654f415..bbdd811bb92 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -172,27 +172,17 @@ Stored as JSON on the parent row — no separate table / FK: ```typescript { type: 'currency', - currencyConfig: { - currencyMode: 'fixed', // 'fixed' = the column is pinned to - // defaultCurrency; 'dynamic' = tenant default - defaultCurrency: 'USD', // ISO 4217 - }, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, // ISO 4217 } ``` -**Currency resolution.** The value is a bare number in both modes (ADR-0104 -D1), never `{ value, currency }`. The displayed symbol: under `'fixed'`, -`currencyConfig.defaultCurrency`; under `'dynamic'` (default), the tenant -`localization.currency` setting, not `defaultCurrency`; with neither, a plain -grouped number (never a hardcoded `$`). A measure's own `currency` wins. - -**Per-record currency** (`currencyMode: 'record'`): `currencyField` names a -`text` field on the same object with `valueDomain: 'iso_4217_currency'` that -holds each record's code; the amount stays a bare number. Name it -`currency_code`; an amount `price`'s home-currency copy and its rate go in -`price_home` and `price_rate` (`AMOUNT_home`, `AMOUNT_rate`) — a convention, -not a contract. A pointer at a missing, non-`text` or domain-less field is -refused at parse. +**Currency resolution.** The value is a bare number in every mode (ADR-0104 +D1), never `{ value, currency }`. Shown: `'fixed'`, `defaultCurrency`; +`'dynamic'` (default), the tenant `localization.currency`, not +`defaultCurrency`, else a plain number (never `$`); `'record'`, each record's +code in the `text` field `currencyField` names, which declares `valueDomain: +'iso_4217_currency'` (names by convention, not contract: `currency_code`, +`AMOUNT_home`, `AMOUNT_rate`). A measure's own `currency` wins. ### Select with Default From 5232c8b14bd74f8ab8f496ef06cdf0e3ee189a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:50:12 +0000 Subject: [PATCH 05/21] chore(platform-objects): metadata-form translation bundles for the record-mode currencyField Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 6 +++++- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 1bf6bb40b84..5b449157cde 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -614,7 +614,11 @@ export const enMetadataForms: NonNullable = { }, "currencyConfig.currencyMode": { label: "Currency Mode", - helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency." + helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names." + }, + "currencyConfig.currencyField": { + label: "Currency Field", + helpText: "Record mode only, and required there: the field on this object holding each record's ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code." }, "currencyConfig.defaultCurrency": { label: "Default Currency", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 97f7a5ac903..c05a81b4425 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const esESMetadataForms: NonNullable = label: "Modo de moneda", helpText: "dynamic (predeterminado): el campo no tiene moneda propia y los importes se muestran en la moneda predeterminada del tenant (el ajuste localization.currency). fixed: el campo tiene una única moneda, defaultCurrency." }, + "currencyConfig.currencyField": { + label: "Campo de moneda", + helpText: "Solo en modo record, y obligatorio en él: el campo de este objeto que guarda el código ISO 4217 de cada registro — un campo de texto con valueDomain iso_4217_currency, por convención currency_code." + }, "currencyConfig.defaultCurrency": { label: "Moneda predeterminada", helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Por defecto, CNY. No se lee en modo dynamic." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 0c1da605a44..c3d0815b7d9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const jaJPMetadataForms: NonNullable = label: "通貨モード", helpText: "dynamic(既定):フィールド自体は通貨を持たず、金額はテナントの既定通貨(localization.currency 設定)で表示されます。fixed:フィールドは 1 つの通貨 defaultCurrency を持ちます。" }, + "currencyConfig.currencyField": { + label: "通貨フィールド", + helpText: "record モード専用で、同モードでは必須:各レコードの ISO 4217 通貨コードを保持する、このオブジェクト上のフィールド。valueDomain iso_4217_currency を宣言したテキストフィールドで、慣例名は currency_code。" + }, "currencyConfig.defaultCurrency": { label: "既定の通貨", helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。既定は CNY です。dynamic モードでは参照されません。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index eae72e313c1..417bc714a17 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -616,6 +616,10 @@ export const zhCNMetadataForms: NonNullable = label: "货币模式", helpText: "dynamic(默认):字段本身没有货币,金额以租户默认货币显示(localization.currency 设置)。fixed:字段只有一种货币,即 defaultCurrency。" }, + "currencyConfig.currencyField": { + label: "币种字段", + helpText: "仅用于 record 模式,且在该模式下必填:本对象上保存每条记录 ISO 4217 币种代码的字段——一个声明了 valueDomain iso_4217_currency 的文本字段,惯例命名为 currency_code。" + }, "currencyConfig.defaultCurrency": { label: "默认货币", helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。默认为 CNY。dynamic 模式下不读取。" From 696c3f29e6d2c4f0815fbe6c2be2dc00d37dcadb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:07:17 +0000 Subject: [PATCH 06/21] =?UTF-8?q?feat(objectql):=20record-currency=20guard?= =?UTF-8?q?s=20=E2=80=94=20blank=20code=20refuses=20the=20write,=20ungroup?= =?UTF-8?q?ed=20raw=20aggregate=20refused,=20summary=20roll-up=20refused?= =?UTF-8?q?=20at=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0144 D6 and D8 rule 4. A record-mode amount beside a blank currency code is refused ERR_CURRENCY_CODE_REQUIRED (registered in the ADR-0112 ledger); sum/avg/min/max over a record-mode amount refuses INVALID_FIELD unless grouped by its code field; a summary roll-up reading one refuses INVALID_METADATA at registration. Rider: inferFormulaReturn carries record mode and its currencyField for a formula over a record-mode amount. Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- packages/formula/src/formula-return.test.ts | 37 +++ packages/formula/src/formula-return.ts | 50 ++- packages/objectql/src/engine.ts | 32 ++ .../src/record-currency-aggregate-door.ts | 245 ++++++++++++++ .../src/record-currency-guards.test.ts | 302 ++++++++++++++++++ packages/objectql/src/registry.ts | 12 + .../src/validation/record-currency-code.ts | 158 +++++++++ .../spec/src/api/error-code-ledger.zod.ts | 6 + 8 files changed, 828 insertions(+), 14 deletions(-) create mode 100644 packages/objectql/src/record-currency-aggregate-door.ts create mode 100644 packages/objectql/src/record-currency-guards.test.ts create mode 100644 packages/objectql/src/validation/record-currency-code.ts diff --git a/packages/formula/src/formula-return.test.ts b/packages/formula/src/formula-return.test.ts index c7d7f3e7068..443c225c955 100644 --- a/packages/formula/src/formula-return.test.ts +++ b/packages/formula/src/formula-return.test.ts @@ -165,3 +165,40 @@ describe('CONTROL — a numeric formula stays `number`', () => { expect(inferFormulaReturn('record.amount *', FIELDS)).toEqual({}); }); }); + +describe('a formula over a record-mode amount carries the record currency (ADR-0144 D2)', () => { + const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; + const RECORD_FIELDS: Readonly> = { + currency_code: { type: 'text' }, + other_code: { type: 'text' }, + amount: { type: 'currency', currencyConfig: RECORD }, + tax: { type: 'currency', currencyConfig: RECORD }, + fee: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'other_code' } }, + budget: { type: 'currency' }, + usd: { type: 'currency', currencyConfig: USD }, + quantity: { type: 'number' }, + }; + + it('stamps `record` mode with the source amount\'s code field, never the tenant default', () => { + expect(inferFormulaReturn('record.amount * 0.1', RECORD_FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: RECORD }); + expect(inferFormulaReturn('record.amount + record.tax', RECORD_FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: RECORD }); + expect(inferFormulaReturn('round(record.amount * record.quantity)', RECORD_FIELDS)) + .toEqual({ returnType: 'currency', currencyConfig: RECORD }); + }); + + it('an amount over its own currency is a plain ratio', () => { + expect(inferFormulaReturn('record.tax / record.amount', RECORD_FIELDS).returnType).not.toBe('currency'); + }); + + it('two different currencies meeting are not one amount', () => { + for (const source of [ + 'record.amount + record.fee', // two code fields + 'record.amount + record.budget', // record + the tenant default + 'record.amount + record.usd', // record + fixed USD + ]) { + expect(inferFormulaReturn(source, RECORD_FIELDS).returnType, source).not.toBe('currency'); + } + }); +}); diff --git a/packages/formula/src/formula-return.ts b/packages/formula/src/formula-return.ts index 49abfdec9a6..2fa7aca7ab6 100644 --- a/packages/formula/src/formula-return.ts +++ b/packages/formula/src/formula-return.ts @@ -79,15 +79,25 @@ export interface FormulaSourceField { * * - `returnType` — absent when no concrete type can be proven. * - `currencyConfig` — present only for a `currency` result whose currency is - * `fixed`. A `dynamic` result (the tenant default currency) declares no config, - * exactly as a `dynamic` currency field may carry none. + * `fixed`, or `record` (ADR-0144 D2: each record's own currency, carried as + * the same `currencyField` its source amounts name). A `dynamic` result (the + * tenant default currency) declares no config, exactly as a `dynamic` + * currency field may carry none. */ export interface FormulaReturnDeclaration { readonly returnType?: NonNullable; - readonly currencyConfig?: { readonly currencyMode: 'fixed'; readonly defaultCurrency: string }; + readonly currencyConfig?: + | { readonly currencyMode: 'fixed'; readonly defaultCurrency: string } + | { readonly currencyMode: 'record'; readonly currencyField: string }; } -type MoneyCurrency = { readonly mode: 'dynamic' } | { readonly mode: 'fixed'; readonly code: string }; +type MoneyCurrency = + | { readonly mode: 'dynamic' } + | { readonly mode: 'fixed'; readonly code: string } + // [ADR-0144 D2] Each record's own currency, the code in the sibling field + // `field` names. Two record-mode amounts are one currency only when they + // name the same code field. + | { readonly mode: 'record'; readonly field: string }; type Dim = | { readonly kind: 'money'; readonly currency: MoneyCurrency } @@ -125,17 +135,23 @@ function isNode(v: unknown): v is Node { } function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean { - if (a.mode === 'dynamic' || b.mode === 'dynamic') return a.mode === b.mode; - return a.code === b.code; + if (a.mode === 'fixed' && b.mode === 'fixed') return a.code === b.code; + if (a.mode === 'record' && b.mode === 'record') return a.field === b.field; + return a.mode === b.mode; } /** A field's currency, read through the schema a currency field is parsed with — or `null` when it does not parse. */ function currencyOf(config: unknown): MoneyCurrency | null { const parsed = CurrencyConfigSchema.safeParse(config ?? {}); if (!parsed.success) return null; - return parsed.data.currencyMode === 'fixed' - ? { mode: 'fixed', code: parsed.data.defaultCurrency } - : { mode: 'dynamic' }; + if (parsed.data.currencyMode === 'fixed') return { mode: 'fixed', code: parsed.data.defaultCurrency }; + // [ADR-0144 D2] A `record`-mode amount is in the currency its sibling code + // field holds — never the tenant default a `dynamic` stamp would claim. The + // schema requires the pointer under `record`, so a parsed config carries it. + if (parsed.data.currencyMode === 'record') { + return parsed.data.currencyField ? { mode: 'record', field: parsed.data.currencyField } : null; + } + return { mode: 'dynamic' }; } /** Is `d` a number that is not an amount (a quantity or a constant)? */ @@ -253,8 +269,10 @@ function sourceOf(input: ExprInput): string | null { * * - Provably money ⇒ `{ returnType: 'currency' }`, plus * `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when the - * source amounts are in one fixed currency. Sources in two different - * currencies are not provably one amount, and fall through. + * source amounts are in one fixed currency, or + * `currencyConfig: { currencyMode: 'record', currencyField }` when they are + * record-mode amounts naming one code field (ADR-0144 D2). Sources in two + * different currencies are not provably one amount, and fall through. * - Otherwise ⇒ the {@link inferExpressionType} answer as `returnType` * (`number` / `text` / `boolean` / `date`), or `{}` when that is `unknown` — * unchanged from before this function existed, so a numeric formula stays @@ -278,9 +296,13 @@ export function inferFormulaReturn( if (celType !== null && MONEY_CEL_TYPES.has(celType)) { const dim = dimOf(parseCelToAst(source), fields); if (dim.kind === 'money') { - return dim.currency.mode === 'fixed' - ? { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } } - : { returnType: 'currency' }; + if (dim.currency.mode === 'fixed') { + return { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } }; + } + if (dim.currency.mode === 'record') { + return { returnType: 'currency', currencyConfig: { currencyMode: 'record', currencyField: dim.currency.field } }; + } + return { returnType: 'currency' }; } } } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3dcaaed4af7..114320f3316 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -305,6 +305,8 @@ import { count, rejectCredentialAggregation } from './engine-count-aggregate.js' import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js'; import { assertProjectionNamesAField } from './empty-projection-door.js'; import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js'; +import { assertRecordCurrencyAggregatesGrouped } from './record-currency-aggregate-door.js'; +import { assertRecordCurrencyCodes, touchesRecordCurrency } from './validation/record-currency-code.js'; import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js'; import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; import { SchemaRegistry, type ArtifactInstallScope } from './registry.js'; @@ -15382,6 +15384,9 @@ export class ObjectQL implements IObjectQLEngine { normalizeMultiValueFields(schemaForValidation, rows[i], 'include'); // [#22183] `keptOptionValues`: the option values an import kept for this write. validateRecordInScope(schemaForValidation, rows[i], 'insert', 'include', { mediaValueShapeStrict, valueShapeStrict, messages: msgCtx, onAdmittedValueShapeViolation, keptOptionValues: opCtx.context?.keptOptionValues }); + // [ADR-0144 D8 rule 4] A `record`-mode amount is stored with its + // currency code, or the row is refused (`ERR_CURRENCY_CODE_REQUIRED`). + assertRecordCurrencyCodes(object, (schemaForValidation as { fields?: unknown } | undefined)?.fields, rows[i], 'insert'); emitValidationAdvisories(options?.onValidationAdvisory, evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx, parent: insertParentForRow?.(rows[i]), related: batchSummaries ? batchSummaries.overlay(insertRelatedForRow(rows[i])) : insertRelatedForRow(rows[i]), permissions: insertPermissionsFor(rows[i]) }), this.logger, object); await this.assertReferencesResolve( schemaForValidation, rows[i], suppliedPerRow[i], opCtx.context, msgCtx, @@ -17099,6 +17104,14 @@ export class ObjectQL implements IObjectQLEngine { // because those are already judged. See `ReadonlyValueScope`. normalizeMultiValueFields(updateSchema, hookContext.input.data as Record, 'only'); validateRecordInScope(updateSchema, hookContext.input.data as Record, 'update', 'only', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation }); + // [ADR-0144 D8 rule 4] The stored row with the FINAL payload laid + // over it keeps a currency code beside a record-mode amount the + // payload touches (`ERR_CURRENCY_CODE_REQUIRED`). + assertRecordCurrencyCodes( + object, (updateSchema as { fields?: unknown } | undefined)?.fields, + { ...(priorRecord ?? {}), ...(hookContext.input.data as Record) }, + 'update', hookContext.input.data as Record, hookContext.input.id, + ); // ── [#19989] The post-image seam on the BY-ID path ───────────── // // The by-id twin of the predicate-path call below, placed at the @@ -17418,6 +17431,20 @@ export class ObjectQL implements IObjectQLEngine { // N rows are written. normalizeMultiValueFields(updateSchema, hookContext.input.data as Record, 'only'); validateRecordInScope(updateSchema, hookContext.input.data as Record, 'update', 'only', { mediaValueShapeStrict, valueShapeStrict, messages: updateMsgCtx, onAdmittedValueShapeViolation }); + // [ADR-0144 D8 rule 4] The predicate-path twin: every matched row, + // with the FINAL payload laid over it, keeps a currency code beside + // a record-mode amount the payload touches. The matched rows are + // read only for such a payload (`readPriorRows` is the memo the + // seams below share). + { + const currencyPayload = hookContext.input.data as Record; + const currencyFields = (updateSchema as { fields?: unknown } | undefined)?.fields; + if (touchesRecordCurrency(currencyFields, currencyPayload)) { + for (const row of (await readPriorRows()) ?? []) { + assertRecordCurrencyCodes(object, currencyFields, { ...row, ...currencyPayload }, 'update', currencyPayload, row?.id); + } + } + } // ── [#19950] The post-image seam on the PREDICATE path ───────── // // An enforcement layer's write `check` must hold for EVERY row a @@ -20343,6 +20370,11 @@ export class ObjectQL implements IObjectQLEngine { // string on SQLite and a 500 on PostgreSQL; `sum` over one answered `0`, // `0` and a 500. Every row of the table is asked (see the door's header). assertAggregationFieldTypesAccepted(object, this._registry.getObject(object), query.aggregations); + // [ADR-0144 D6] …and a `sum` / `avg` / `min` / `max` over a `record`-mode + // amount that does not group by its code field: the rows hold amounts in + // different currencies, which add into one number under one label. + // After the type door, so a type it refuses keeps that door's words. + assertRecordCurrencyAggregatesGrouped(object, this._registry.getObject(object), query.groupBy, query.aggregations); // [#25818] …and the two shapes that answered a plausible wrong table: a // `dateGranularity` over a field declared neither a date nor a datetime // (every row in one null bucket), and two columns of the aggregated row diff --git a/packages/objectql/src/record-currency-aggregate-door.ts b/packages/objectql/src/record-currency-aggregate-door.ts new file mode 100644 index 00000000000..d43a2214e1c --- /dev/null +++ b/packages/objectql/src/record-currency-aggregate-door.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D6] A raw aggregate over a `record`-mode amount refuses unless it + * groups by that amount's code field; a `summary` roll-up over one refuses at + * registration. + * + * ## What ran before + * + * The measurement round on epic #8345 (comment 6041378776) modelled a record + * currency the way authors do today, as a sibling text field, and probed a + * real engine. `sum` over a USD row of 100 and an EUR row of 100 answered + * `200` under one currency label (PROBE-A); a `summary` roll-up over child + * rows of USD 100 and JPY 15000 stored `15100`, and `avg` `7550` (PROBE-R). + * Grouped by the code, every per-group value was right (PROBE-C). A declared + * `record` mode makes the mixed rows knowable, so the engine refuses the two + * shapes that add them. + * + * ## Which aggregates are raw + * + * `sum`, `avg`, `min` and `max` read the amount (ADR-0144 Open item 10). + * `count` reads no amount, and `count_distinct` counts values without adding + * them, so neither is judged. Grouping by the code field makes every group one + * currency, which is the one shape the ruling admits; a consolidated total + * reads the stored home-currency field instead (D6), an ordinary currency + * field this door never judges. + * + * ## Where it stands + * + * The aggregate half stands in `ObjectQL.aggregate` beside the aggregate × + * field-type door (`aggregate-field-type-door.ts`), before any driver is + * asked, so it holds for every caller that reaches the engine — the REST + * query door, a flow, a hook, a roll-up's recompute and the analytics + * strategy. It answers that door's `INVALID_FIELD` / 400: the verdict is about + * the named field at a position. ADR-0144 assigns no new wire code to it. + * + * The roll-up half stands in `SchemaRegistry.registerObject`, the one door + * every metadata path passes, before anything is materialized, so a refused + * declaration leaves nothing behind. It answers `INVALID_METADATA` / 422, the + * registry's code for a programmatically supplied body it refuses. The pair is + * judged at whichever registration completes it: the parent's, when the child + * is registered already, or the child's, when a registered parent rolls up the + * field the child now declares `record`. A pair with an end not registered yet + * is no verdict: the other end's registration judges it. + */ + +import { StandardErrorCode } from '@objectstack/spec/api'; +import { recordCurrencyFieldOf } from './validation/record-currency-code.js'; + +/** The aggregate functions that read the amount — ADR-0144 Open item 10. */ +const RAW_AMOUNT_FUNCTIONS: Readonly> = { + sum: { does: 'sums' }, + avg: { does: 'averages' }, + min: { does: 'takes the min of' }, + max: { does: 'takes the max of' }, +}; + +/** Is `fn` an aggregate that reads the amount? */ +export function readsRecordCurrencyAmount(fn: unknown): boolean { + return typeof fn === 'string' && Object.prototype.hasOwnProperty.call(RAW_AMOUNT_FUNCTIONS, fn); +} + +/** The field names a `groupBy` groups on — its string entries and its `{ field }` entries. */ +function groupedFields(groupBy: unknown): Set { + const names = new Set(); + if (!Array.isArray(groupBy)) return names; + for (const entry of groupBy) { + if (typeof entry === 'string') names.add(entry); + else if (entry !== null && typeof entry === 'object' && typeof (entry as { field?: unknown }).field === 'string') { + names.add((entry as { field: string }).field); + } + } + return names; +} + +interface UngroupedAmount { + readonly fn: string; + readonly field: string; + readonly codeField: string; + readonly position: string; +} + +/** + * Refuse a raw aggregation over a declared `record`-mode amount when the query + * does not group by its code field — `INVALID_FIELD` / 400, before any driver + * is asked. See the module header. + * + * The route comes before the reason so it lands inside the 500 characters the + * REST door keeps of a 4xx message. + */ +export function assertRecordCurrencyAggregatesGrouped( + object: string, + schema: unknown, + groupBy: unknown, + aggregations: unknown, +): void { + if (!Array.isArray(aggregations) || aggregations.length === 0) return; + const fields = (schema as { fields?: unknown } | undefined)?.fields; + if (!fields || typeof fields !== 'object') return; + const grouped = groupedFields(groupBy); + const hits: UngroupedAmount[] = []; + for (const [i, agg] of aggregations.entries()) { + if (agg === null || typeof agg !== 'object' || Array.isArray(agg)) continue; + const fn = (agg as { function?: unknown }).function; + if (!readsRecordCurrencyAmount(fn)) continue; + const field = (agg as { field?: unknown }).field; + if (typeof field !== 'string' || !Object.prototype.hasOwnProperty.call(fields, field)) continue; + const codeField = recordCurrencyFieldOf((fields as Record)[field]); + if (codeField === undefined || grouped.has(codeField)) continue; + hits.push({ fn: fn as string, field, codeField, position: `aggregations[${i}].field` }); + } + if (hits.length === 0) return; + const [first] = hits; + const err = new Error( + `aggregate('${object}'): ${first.position} ${RAW_AMOUNT_FUNCTIONS[first.fn].does} '${first.field}', an amount ` + + `in each record's own currency (currencyMode: 'record', the code in '${first.codeField}'), and the query ` + + `does not group by '${first.codeField}'` + + (hits.length > 1 ? ` (also: ${hits.slice(1).map((h) => `'${h.field}'`).join(', ')})` : '') + + `. The query was NOT run. Add '${first.codeField}' to groupBy for one ${first.fn} per currency, or ` + + `aggregate a stored home-currency amount field for one consolidated figure. Amounts in different ` + + `currencies do not add: USD 100 and EUR 100 summed to 200 under one label.`, + ) as Error & { + code?: string; status?: number; httpStatus?: number; + field?: string; fields?: string[]; object?: string; param?: string; + }; + err.code = StandardErrorCode.enum.INVALID_FIELD; + err.status = 400; + err.httpStatus = 400; + err.field = first.field; + err.fields = hits.map((h) => h.field); + err.object = object; + err.param = 'aggregations'; + throw err; +} + +/** + * [ADR-0144 D6] The registry's code for this refusal: `INVALID_METADATA`, its + * spelling for a programmatically supplied body it refuses. ⛔ Not a new code; + * the condition is named by the error's `name`. + */ +export const RECORD_CURRENCY_SUMMARY_REFUSED_CODE = 'INVALID_METADATA' as const; + +/** + * A `summary` field rolls up `sum` / `avg` / `min` / `max` of a child field + * declared `currencyMode: 'record'`. The roll-up adds the child rows' amounts + * into one stored number with no currency. The ADR-0112 envelope (`code` + + * `status: 422`), never a bare throw. + */ +export class RecordCurrencySummaryRefusedError extends Error { + readonly code = RECORD_CURRENCY_SUMMARY_REFUSED_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** The object declaring the `summary` field. */ + readonly objectName: string; + /** The `summary` field. */ + readonly fieldName: string; + /** The child object it rolls up. */ + readonly childObject: string; + /** The child's record-mode amount. */ + readonly childField: string; + + constructor(parent: string, summaryField: string, fn: string, child: string, childField: string, codeField: string) { + super( + `summary field "${parent}.${summaryField}" rolls up ${fn}(${child}.${childField}), and ${child}.${childField} ` + + `is an amount in each record's own currency (currencyMode: 'record', the code in '${codeField}'). A ` + + `roll-up stores one number with no currency, so child rows in different currencies would be added ` + + `together: USD 100 and JPY 15000 rolled up to 15100. Use function: 'count', or roll up a stored ` + + `home-currency amount field of '${child}' instead.`, + ); + this.name = 'RecordCurrencySummaryRefusedError'; + this.objectName = parent; + this.fieldName = summaryField; + this.childObject = child; + this.childField = childField; + } +} + +interface ObjectLike { + readonly name?: unknown; + readonly fields?: unknown; +} + +function fieldsOf(obj: ObjectLike | undefined): Record | undefined { + const f = obj?.fields; + return f && typeof f === 'object' && !Array.isArray(f) ? (f as Record) : undefined; +} + +/** A `summary` field's roll-up when it reads the amount: `{ fn, child, childField }`. */ +function rawRollupOf(def: unknown): { fn: string; child: string; childField: string } | undefined { + if (def === null || typeof def !== 'object' || (def as { type?: unknown }).type !== 'summary') return undefined; + const ops = (def as { summaryOperations?: unknown }).summaryOperations as + { object?: unknown; field?: unknown; function?: unknown } | undefined; + if (!ops || !readsRecordCurrencyAmount(ops.function)) return undefined; + if (typeof ops.object !== 'string' || typeof ops.field !== 'string') return undefined; + return { fn: ops.function as string, child: ops.object, childField: ops.field }; +} + +/** + * The refusal for an object about to register, or `undefined` — see the + * module header. `lookup` resolves a REGISTERED object by name and `all` + * lists the registered objects; both are asked only as far as the incoming + * body's own fields make necessary. + */ +export function recordCurrencySummaryRefusal( + incoming: ObjectLike, + lookup: (name: string) => ObjectLike | undefined, + all: () => readonly ObjectLike[], +): RecordCurrencySummaryRefusedError | undefined { + const name = typeof incoming.name === 'string' ? incoming.name : undefined; + const own = fieldsOf(incoming); + if (!name || !own) return undefined; + + // The incoming body's own roll-ups, over a child already registered (or over itself). + for (const [summaryField, def] of Object.entries(own)) { + const rollup = rawRollupOf(def); + if (!rollup) continue; + const childFields = rollup.child === name ? own : fieldsOf(lookup(rollup.child)); + const codeField = childFields ? recordCurrencyFieldOf(childFields[rollup.childField]) : undefined; + if (codeField !== undefined) { + return new RecordCurrencySummaryRefusedError(name, summaryField, rollup.fn, rollup.child, rollup.childField, codeField); + } + } + + // The incoming body's record-mode amounts, rolled up by a parent already registered. + const recordAmounts = new Map(); + for (const [field, def] of Object.entries(own)) { + const codeField = recordCurrencyFieldOf(def); + if (codeField !== undefined) recordAmounts.set(field, codeField); + } + if (recordAmounts.size === 0) return undefined; + for (const parent of all()) { + const parentName = typeof parent.name === 'string' ? parent.name : undefined; + if (!parentName || parentName === name) continue; + for (const [summaryField, def] of Object.entries(fieldsOf(parent) ?? {})) { + const rollup = rawRollupOf(def); + if (!rollup || rollup.child !== name) continue; + const codeField = recordAmounts.get(rollup.childField); + if (codeField !== undefined) { + return new RecordCurrencySummaryRefusedError(parentName, summaryField, rollup.fn, name, rollup.childField, codeField); + } + } + } + return undefined; +} diff --git a/packages/objectql/src/record-currency-guards.test.ts b/packages/objectql/src/record-currency-guards.test.ts new file mode 100644 index 00000000000..8abb711a6bd --- /dev/null +++ b/packages/objectql/src/record-currency-guards.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D6, D8 rule 4] The three record-currency guards, through the engine +// and the registry: +// +// - a write storing a `record`-mode amount beside a blank code is refused +// `ERR_CURRENCY_CODE_REQUIRED` / 400 (insert, by-id update, predicate update); +// - `sum` / `avg` / `min` / `max` over a `record`-mode amount is refused +// `INVALID_FIELD` / 400 unless the query groups by the code field; +// - a `summary` roll-up reading one is refused `INVALID_METADATA` / 422 at +// registration, whichever end registers second. +// +// The fixture is the measurement round's `fx_invoice` (epic #8345, comment +// 6041378776), with the sibling code field DECLARED as the record currency. +// Every refusal here precedes the driver, so the driver is the rows-shaped +// in-memory double the roll-up suite uses; the same probes on driver-memory and +// on SqlDriver over better-sqlite3 are packages/qa/dogfood's +// `record-currency-probes.dogfood.test.ts`. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from './engine.js'; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (v !== null && typeof v === 'object' && '$eq' in v) return row?.[k] === v.$eq; + if (v !== null && typeof v === 'object' && '$in' in v) return (v.$in as unknown[]).includes(row?.[k]); + return row?.[k] === v; + }); + }; + let n = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return row; + }, + async updateMany(object: string, ast: any, data: Record) { + const s = storeFor(object); + let count = 0; + for (const [id, r] of Array.from(s.entries())) { + if (matches(r, ast?.where)) { s.set(id, { ...r, ...data, id }); count += 1; } + } + return count; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async deleteMany() { return 0; }, + async count() { return 0; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +type Thrown = (Error & { + code?: string; status?: number; httpStatus?: number; field?: string; fields?: string[]; + amountField?: string; object?: string; param?: string; objectName?: string; fieldName?: string; +}) | null; + +const refusalOf = async (p: Promise | (() => unknown)): Promise => { + try { + await (typeof p === 'function' ? p() : p); + return null; + } catch (e) { + return e as Thrown; + } +}; +const envelopeOf = (err: Thrown) => ({ code: err?.code, status: err?.status, httpStatus: err?.httpStatus }); + +const CODE_REQUIRED = { code: 'ERR_CURRENCY_CODE_REQUIRED', status: 400, httpStatus: 400 }; +const INVALID_FIELD = { code: 'INVALID_FIELD', status: 400, httpStatus: 400 }; +const INVALID_METADATA = { code: 'INVALID_METADATA', status: 422, httpStatus: 422 }; + +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; + +const FX_INVOICE = { + name: 'fx_invoice', + fields: { + title: { type: 'text' }, + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + amount: { type: 'currency', currencyConfig: RECORD }, + amt_usd: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, + }, +}; + +async function bootEngine() { + const engine = new ObjectQL(); + const d = makeDriver(); + engine.registerDriver(d.driver, true); + await engine.init(); + return { engine, storeFor: d.storeFor }; +} + +describe('a record-mode amount is stored with its currency code (ERR_CURRENCY_CODE_REQUIRED)', () => { + let engine: ObjectQL; + let storeFor: ReturnType['storeFor']; + + beforeEach(async () => { + ({ engine, storeFor } = await bootEngine()); + engine.registry.registerObject(FX_INVOICE as any); + }); + + it('insert: an amount beside a blank code is refused, naming the code field, and nothing is written', async () => { + for (const code of [undefined, null, '', ' ']) { + const err = await refusalOf(engine.insert('fx_invoice', { title: 'i1', amount: 100, ...(code === undefined ? {} : { currency_code: code }) })); + expect(envelopeOf(err), String(code)).toEqual(CODE_REQUIRED); + expect(err?.field).toBe('currency_code'); + expect(err?.fields).toEqual(['currency_code']); + expect(err?.amountField).toBe('amount'); + expect(err?.message).toContain("'currency_code' is blank"); + } + expect(storeFor('fx_invoice').size).toBe(0); + }); + + it('insert: an amount with its code, or no amount at all, is written', async () => { + await engine.insert('fx_invoice', { title: 'i1', amount: 100, currency_code: 'USD' }); + await engine.insert('fx_invoice', { title: 'i2', amount: 0, currency_code: 'JPY' }); + await engine.insert('fx_invoice', { title: 'i3' }); + await engine.insert('fx_invoice', { title: 'i4', amount: null }); + expect(storeFor('fx_invoice').size).toBe(4); + }); + + it('by-id update: clearing the code under a stored amount, or writing an amount onto a row with none, is refused', async () => { + const withCode = await engine.insert('fx_invoice', { title: 'i1', amount: 100, currency_code: 'USD' }); + const noAmount = await engine.insert('fx_invoice', { title: 'i2' }); + + const cleared = await refusalOf(engine.update('fx_invoice', { id: withCode.id, currency_code: null })); + expect(envelopeOf(cleared)).toEqual(CODE_REQUIRED); + expect(cleared?.message).toContain(`(record ${withCode.id})`); + expect(storeFor('fx_invoice').get(withCode.id).currency_code).toBe('USD'); + + const amountOnly = await refusalOf(engine.update('fx_invoice', { id: noAmount.id, amount: 50 })); + expect(envelopeOf(amountOnly)).toEqual(CODE_REQUIRED); + expect(storeFor('fx_invoice').get(noAmount.id).amount).toBeUndefined(); + + // The controls: the stored code covers a new amount; amount and code cleared together. + await engine.update('fx_invoice', { id: withCode.id, amount: 250 }); + expect(storeFor('fx_invoice').get(withCode.id).amount).toBe(250); + await engine.update('fx_invoice', { id: withCode.id, amount: null, currency_code: null }); + expect(storeFor('fx_invoice').get(withCode.id).currency_code).toBeNull(); + }); + + it('by-id update: a row stored before the guard is not refused for an edit that leaves the pair alone', async () => { + storeFor('fx_invoice').set('legacy', { id: 'legacy', title: 'old', amount: 100, currency_code: null }); + await engine.update('fx_invoice', { id: 'legacy', title: 'renamed' }); + expect(storeFor('fx_invoice').get('legacy').title).toBe('renamed'); + }); + + it('predicate update: one matched row left without a code refuses the whole write', async () => { + const a = await engine.insert('fx_invoice', { title: 'batch', amount: 1, currency_code: 'EUR' }); + const b = await engine.insert('fx_invoice', { title: 'batch' }); + + const err = await refusalOf(engine.update('fx_invoice', { amount: 9 }, { where: { title: 'batch' }, multi: true } as any)); + expect(envelopeOf(err)).toEqual(CODE_REQUIRED); + expect(err?.message).toContain(`(record ${b.id})`); + expect(storeFor('fx_invoice').get(a.id).amount).toBe(1); + + // The control: the same write carrying the code. + await engine.update('fx_invoice', { amount: 9, currency_code: 'EUR' }, { where: { title: 'batch' }, multi: true } as any); + expect(storeFor('fx_invoice').get(b.id)).toMatchObject({ amount: 9, currency_code: 'EUR' }); + }); +}); + +describe('a raw aggregate over a record-mode amount groups by its code field (PROBE-A, PROBE-C)', () => { + let engine: ObjectQL; + + beforeEach(async () => { + ({ engine } = await bootEngine()); + engine.registry.registerObject(FX_INVOICE as any); + await engine.insert('fx_invoice', { title: 'i1', currency_code: 'USD', amount: 100, amt_usd: 100 }); + await engine.insert('fx_invoice', { title: 'i2', currency_code: 'EUR', amount: 100, amt_usd: 0 }); + }); + + it('sum / avg / min / max ungrouped is refused INVALID_FIELD, with the route first', async () => { + for (const fn of ['sum', 'avg', 'min', 'max']) { + const err = await refusalOf(engine.aggregate('fx_invoice', { + aggregations: [{ function: fn, field: 'amount', alias: 'v' }], + } as any)); + expect(envelopeOf(err), fn).toEqual(INVALID_FIELD); + expect(err?.field).toBe('amount'); + expect(err?.param).toBe('aggregations'); + expect(err?.message).toContain("does not group by 'currency_code'"); + expect(err?.message.indexOf("Add 'currency_code' to groupBy")).toBeLessThan(500); + } + }); + + it('grouped by a field other than the code is still refused', async () => { + const err = await refusalOf(engine.aggregate('fx_invoice', { + groupBy: ['title'], + aggregations: [{ function: 'sum', field: 'amount', alias: 'v' }], + } as any)); + expect(envelopeOf(err)).toEqual(INVALID_FIELD); + }); + + it('grouped by the code field answers one correct total per currency', async () => { + for (const groupBy of [['currency_code'], [{ field: 'currency_code' }]]) { + const rows = await engine.aggregate('fx_invoice', { + groupBy, + aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], + } as any); + const byCode = Object.fromEntries(rows.map((r: any) => [r.currency_code, r.total])); + expect(byCode).toEqual({ USD: 100, EUR: 100 }); + } + }); + + it('count, and a fixed-currency amount, are not judged', async () => { + const rows = await engine.aggregate('fx_invoice', { + aggregations: [ + { function: 'count', field: 'amount', alias: 'n' }, + { function: 'sum', field: 'amt_usd', alias: 'usd' }, + ], + } as any); + expect(rows[0]).toMatchObject({ n: 2, usd: 100 }); + }); +}); + +describe('a summary roll-up over a record-mode amount is refused at registration (PROBE-R)', () => { + const LINE = { + name: 'fx_line', + fields: { + currency_code: { type: 'text', valueDomain: 'iso_4217_currency' }, + amount: { type: 'currency', currencyConfig: RECORD }, + invoice: { type: 'master_detail', reference: 'fx_parent' }, + }, + }; + const parentWith = (fn: string, field = 'amount') => ({ + name: 'fx_parent', + fields: { + title: { type: 'text' }, + billed: { type: 'summary', summaryOperations: { object: 'fx_line', field, function: fn } }, + }, + }); + + it('the parent registering after the child is refused, naming the roll-up', async () => { + for (const fn of ['sum', 'avg', 'min', 'max']) { + const { engine } = await bootEngine(); + engine.registry.registerObject(LINE as any); + const err = await refusalOf(() => engine.registry.registerObject(parentWith(fn) as any)); + expect(envelopeOf(err), fn).toEqual(INVALID_METADATA); + expect(err?.name).toBe('RecordCurrencySummaryRefusedError'); + expect(err?.objectName).toBe('fx_parent'); + expect(err?.fieldName).toBe('billed'); + expect(err?.message).toContain(`rolls up ${fn}(fx_line.amount)`); + expect(engine.registry.getObject('fx_parent')).toBeUndefined(); + } + }); + + it('the child registering after the parent is refused, naming the same roll-up', async () => { + const { engine } = await bootEngine(); + engine.registry.registerObject(parentWith('sum') as any); + const err = await refusalOf(() => engine.registry.registerObject(LINE as any)); + expect(envelopeOf(err)).toEqual(INVALID_METADATA); + expect(err?.objectName).toBe('fx_parent'); + expect(err?.message).toContain('rolls up sum(fx_line.amount)'); + expect(engine.registry.getObject('fx_line')).toBeUndefined(); + }); + + it('a count roll-up over the amount, and a sum over a non-record child field, register', async () => { + const { engine } = await bootEngine(); + engine.registry.registerObject(LINE as any); + engine.registry.registerObject(parentWith('count') as any); + expect(engine.registry.getObject('fx_parent')).toBeDefined(); + + const other = await bootEngine(); + other.engine.registry.registerObject({ + ...LINE, + fields: { ...LINE.fields, qty: { type: 'number' } }, + } as any); + other.engine.registry.registerObject(parentWith('sum', 'qty') as any); + expect(other.engine.registry.getObject('fx_parent')).toBeDefined(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5c63cd3d85f..4c61b670569 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -103,6 +103,7 @@ import { type PicklistReference, } from './picklist-resolution.js'; import { standaloneActionOwnerKey, isObjectLessActionKey } from './action-governance.js'; +import { recordCurrencySummaryRefusal } from './record-currency-aggregate-door.js'; /** * [#24400] A bound top-level action whose target object no package has @@ -3622,6 +3623,17 @@ export class SchemaRegistry { if (ownership === 'extend' && (schema as { membershipKeys?: unknown }).membershipKeys !== undefined) { throw new ExtensionMembershipKeysRefusedError(schema.name, packageId); } + // [ADR-0144 D6] A `summary` roll-up that reads a `record`-mode amount — + // `sum` / `avg` / `min` / `max` of a child field whose rows each carry + // their own currency — is refused here, the door every metadata path + // passes, before any materialization: the pair is judged at whichever + // registration completes it. See `record-currency-aggregate-door.ts`. + const recordCurrencySummary = recordCurrencySummaryRefusal( + schema, + (name) => this.getObject(name), + () => this.getAllObjects(), + ); + if (recordCurrencySummary) throw recordCurrencySummary; // [#25744] A code package's object keeps its own actions as their // `ActionSchema` parse — `requiresFeature` / `requiresMembershipReach` diff --git a/packages/objectql/src/validation/record-currency-code.ts b/packages/objectql/src/validation/record-currency-code.ts new file mode 100644 index 00000000000..51d8d042731 --- /dev/null +++ b/packages/objectql/src/validation/record-currency-code.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D2, D8 rule 4] A `record`-mode amount is stored with its currency + * code, or the write is refused. + * + * A `currency` field declaring `currencyConfig: { currencyMode: 'record', + * currencyField }` holds a bare number (ADR-0104 D1) whose currency is the + * ISO 4217 code in the sibling field `currencyField` names. An amount stored + * beside a blank code is a number in no currency: every reader would label it + * with something it never was. The ADR's design rule 4 settles who supplies + * the code: a form pre-fills the organization's currency at the renderer, and + * the stored value is always the one submitted — the server never fills one + * in. This refusal is what makes that true. + * + * ## What is judged + * + * The row's IMAGE after the write — the insert row as the driver will store + * it, or the stored row with the update payload laid over it. A pair is + * judged when its amount holds a value and its code field is blank (absent, + * `null`, or a string with no characters but white space). A blank amount + * needs no code; a code that is not an ISO 4217 member is the code field's + * own `valueDomain` refusal, judged by the record validator, not here. + * + * On an update only the pairs the payload touches — it carries the amount or + * the code — are judged, as every other write-time rule here judges only what + * a write changes: a row stored before the amount was declared `record` is + * not refused for an edit of an unrelated field. + * + * ## The answer + * + * `ERR_CURRENCY_CODE_REQUIRED` / 400, registered under `@objectstack/objectql` + * in the ADR-0112 ledger (ADR-0144 Open item 9), naming the code field in + * `field` and the amount in `amountField`. A top-level code rather than a + * field-level `VALIDATION_FAILED` entry because the ADR assigns this refusal + * to ADR-0112's vocabulary, and a client that pre-fills the code recognises it + * by that name. + */ + +/** The ADR-0112 `code` {@link RecordCurrencyCodeRequiredError} carries. */ +export const CURRENCY_CODE_REQUIRED_CODE = 'ERR_CURRENCY_CODE_REQUIRED' as const; + +/** One record-mode amount and the field that holds its currency code. */ +export interface RecordCurrencyPair { + /** The `currency` field declaring `currencyMode: 'record'`. */ + readonly amountField: string; + /** Its `currencyConfig.currencyField` — the sibling ISO 4217 code field. */ + readonly codeField: string; +} + +/** + * The code field a declared field's amount is in, when the field is a + * `record`-mode `currency` field naming one — `undefined` otherwise. Read off + * the declaration as registered: the parse already refused a `record` config + * with no pointer, and a body that skipped the parse and carries none names no + * currency this seam could judge. + */ +export function recordCurrencyFieldOf(def: unknown): string | undefined { + if (def === null || typeof def !== 'object') return undefined; + const { type, currencyConfig } = def as { type?: unknown; currencyConfig?: unknown }; + if (type !== 'currency' || currencyConfig === null || typeof currencyConfig !== 'object') return undefined; + const { currencyMode, currencyField } = currencyConfig as { currencyMode?: unknown; currencyField?: unknown }; + return currencyMode === 'record' && typeof currencyField === 'string' && currencyField !== '' + ? currencyField + : undefined; +} + +/** Every record-mode amount an object's field map declares, in declaration order. */ +export function recordCurrencyPairs(fields: unknown): RecordCurrencyPair[] { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return []; + const pairs: RecordCurrencyPair[] = []; + for (const [amountField, def] of Object.entries(fields as Record)) { + const codeField = recordCurrencyFieldOf(def); + if (codeField !== undefined) pairs.push({ amountField, codeField }); + } + return pairs; +} + +function isBlank(value: unknown): boolean { + return value === undefined || value === null || (typeof value === 'string' && value.trim() === ''); +} + +/** + * `ERR_CURRENCY_CODE_REQUIRED` / 400 — see the module header. Identified by + * `code`, which survives crossing package boundaries. + */ +export class RecordCurrencyCodeRequiredError extends Error { + readonly code = CURRENCY_CODE_REQUIRED_CODE; + readonly status = 400; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 400; + /** The object written. */ + readonly object: string; + /** The first blank code field — the one the caller must fill. */ + readonly field: string; + /** Every blank code field of the row, first included. */ + readonly fields: string[]; + /** The record-mode amount whose currency `field` holds. */ + readonly amountField: string; + + constructor(object: string, operation: 'insert' | 'update', hits: readonly RecordCurrencyPair[], recordId?: unknown) { + const [first] = hits; + const row = recordId === undefined || recordId === null ? '' : ` (record ${String(recordId)})`; + super( + `${operation === 'insert' ? 'Insert' : 'Update'} on '${object}'${row} was REFUSED: '${first.amountField}' ` + + `holds an amount in the record's own currency (currencyMode: 'record'), and its currency code field ` + + `'${first.codeField}' is blank` + + (hits.length > 1 ? ` (also: ${hits.slice(1).map((h) => `'${h.codeField}' for '${h.amountField}'`).join(', ')})` : '') + + `. Nothing was written. Write the record's ISO 4217 code (for example 'USD') into '${first.codeField}' ` + + `in the same write, or leave '${first.amountField}' empty. The server never fills a currency in: a form ` + + `pre-fills the organization's currency, and the stored code is always the one submitted.`, + ); + this.name = 'RecordCurrencyCodeRequiredError'; + this.object = object; + this.field = first.codeField; + this.fields = [...new Set(hits.map((h) => h.codeField))]; + this.amountField = first.amountField; + } +} + +/** + * Refuse a row image whose record-mode amount holds a value beside a blank + * currency code — see the module header. + * + * @param image the row as it will be stored (insert row, or stored row ⊕ payload) + * @param touched an update's payload: only the pairs it carries a key of are judged; + * omitted on insert, where every pair is judged + */ +export function assertRecordCurrencyCodes( + object: string, + fields: unknown, + image: Record | null | undefined, + operation: 'insert' | 'update', + touched?: Record | null, + recordId?: unknown, +): void { + if (!image) return; + const pairs = recordCurrencyPairs(fields); + if (pairs.length === 0) return; + const has = (key: string) => touched != null && Object.prototype.hasOwnProperty.call(touched, key); + const hits = pairs.filter((p) => { + if (operation === 'update' && !has(p.amountField) && !has(p.codeField)) return false; + return !isBlank(image[p.amountField]) && isBlank(image[p.codeField]); + }); + if (hits.length > 0) throw new RecordCurrencyCodeRequiredError(object, operation, hits, recordId); +} + +/** + * Does an update payload touch any record-mode pair? The engine asks it before + * paying for the stored rows a predicate update would otherwise not read. + */ +export function touchesRecordCurrency(fields: unknown, payload: Record | null | undefined): boolean { + if (!payload) return false; + return recordCurrencyPairs(fields).some( + (p) => Object.prototype.hasOwnProperty.call(payload, p.amountField) + || Object.prototype.hasOwnProperty.call(payload, p.codeField), + ); +} diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 91f7a2bf848..73d17500a9e 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -784,6 +784,12 @@ export const ERROR_CODE_LEDGER = { // the one the transaction was opened on (`CrossDatasourceTransactionWriteError`, // `transaction-errors.ts`). Same #8087-gate family as above. 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE', + // [ADR-0144 D8 rule 4, Open item 9] A write would store a `record`-mode + // currency amount beside a blank currency code — a number in no currency. + // 400, refused before the driver is asked (`RecordCurrencyCodeRequiredError`, + // `validation/record-currency-code.ts`); `field` names the code field to + // fill. The server never fills a currency in. + 'ERR_CURRENCY_CODE_REQUIRED', 'ERR_DATASOURCE_UNAVAILABLE', 'ERR_DRIVER_CONNECT', // [#8846] a `before*` hook moved or cleared the id the engine resolved a From 285f0471cf4a9fe17a3d740877ade5b03df4386e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:12:17 +0000 Subject: [PATCH 07/21] test(dogfood): record-currency PROBE-A / PROBE-R on both SQLite drivers Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- .../src/validation/record-currency-code.ts | 2 +- .../test/record-currency-probes.test.ts | 148 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/qa/dogfood/test/record-currency-probes.test.ts diff --git a/packages/objectql/src/validation/record-currency-code.ts b/packages/objectql/src/validation/record-currency-code.ts index 51d8d042731..e1f62d31511 100644 --- a/packages/objectql/src/validation/record-currency-code.ts +++ b/packages/objectql/src/validation/record-currency-code.ts @@ -106,7 +106,7 @@ export class RecordCurrencyCodeRequiredError extends Error { + `holds an amount in the record's own currency (currencyMode: 'record'), and its currency code field ` + `'${first.codeField}' is blank` + (hits.length > 1 ? ` (also: ${hits.slice(1).map((h) => `'${h.codeField}' for '${h.amountField}'`).join(', ')})` : '') - + `. Nothing was written. Write the record's ISO 4217 code (for example 'USD') into '${first.codeField}' ` + + `. The row was not written. Write the record's ISO 4217 code (for example 'USD') into '${first.codeField}' ` + `in the same write, or leave '${first.amountField}' empty. The server never fills a currency in: a form ` + `pre-fills the organization's currency, and the stored code is always the one submitted.`, ); diff --git a/packages/qa/dogfood/test/record-currency-probes.test.ts b/packages/qa/dogfood/test/record-currency-probes.test.ts new file mode 100644 index 00000000000..4a011d4c3bb --- /dev/null +++ b/packages/qa/dogfood/test/record-currency-probes.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0144 D6, D8 rule 4] The measurement round's probes (epic #8345, comment +// 6041378776) as fixtures, on the real SQLite drivers, with the record currency +// DECLARED (`currencyMode: 'record'`, the code in `currency_code`): +// +// - PROBE-A: `sum` over a USD row of 100 and an EUR row of 100 answered `200` +// under one label. Now refused `INVALID_FIELD` / 400 unless grouped by the +// code field; grouped (PROBE-C), each currency's total comes back on its own. +// - PROBE-R: a `summary` roll-up over child rows of USD 100 and JPY 15000 +// stored `15100`. Now the roll-up is refused `INVALID_METADATA` / 422 when +// the app registers. +// - The write side: an amount with no code is refused +// `ERR_CURRENCY_CODE_REQUIRED` / 400, and the table stays empty. +// +// The engine-door cells over the in-memory aggregation face live beside the +// guards in `packages/objectql/src/record-currency-guards.test.ts`. ⛔ No +// driver-memory arm: its test consumers are a closed census +// (`check:driver-memory-census`), and the project's test backends are sqlite +// `:memory:`. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const DRIVERS = [ + { + name: 'driver-sql (better-sqlite3 :memory:)', + make: (): SqlDriver => + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + }, + { + name: 'driver-sqlite-wasm (:memory:)', + make: (): SqlDriver => new SqliteWasmDriver({ filename: ':memory:' }), + }, +]; + +const SYS = { context: { isSystem: true, userId: 'usr_system' } }; + +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; +const CODE = { name: 'currency_code', type: 'text', valueDomain: 'iso_4217_currency' }; + +const FX_INVOICE = { + name: 'fx_invoice', + label: 'FX invoice', + fields: { + name: { name: 'name', type: 'text' }, + currency_code: CODE, + amount: { name: 'amount', type: 'currency', currencyConfig: RECORD }, + }, +}; + +const appWith = (objects: unknown[]) => ({ + id: 'com.objectstack.dogfood.record-currency-probes', + name: 'Record currency probes', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects, +}); + +interface Refusal { code?: unknown; status?: unknown; field?: unknown; name?: unknown; message?: unknown } +const envelope = (e: Refusal) => ({ code: e.code, status: e.status, field: e.field }); + +async function refusalOf(run: () => unknown): Promise { + try { + await run(); + } catch (e) { + return e as Refusal; + } + throw new Error('expected the engine to refuse, and it answered'); +} + +describe.each(DRIVERS)('record-currency probes: $name', ({ make }) => { + let driver: SqlDriver; + let engine: ObjectQL; + + beforeEach(async () => { + driver = make(); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + }); + + afterEach(async () => { + await engine.destroy(); + }); + + async function boot(objects: unknown[]) { + engine.registerApp(appWith(objects)); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + } + + it('PROBE-A: an ungrouped sum over USD 100 + EUR 100 is refused; grouped by the code, each currency totals alone', async () => { + await boot([FX_INVOICE]); + await engine.insert('fx_invoice', { name: 'i1', currency_code: 'USD', amount: 100 }, SYS); + await engine.insert('fx_invoice', { name: 'i2', currency_code: 'EUR', amount: 100 }, SYS); + + for (const fn of ['sum', 'avg', 'min', 'max']) { + const err = await refusalOf(() => engine.aggregate('fx_invoice', { + aggregations: [{ function: fn, field: 'amount', alias: 'v' }], + } as never, SYS)); + expect(envelope(err), fn).toEqual({ code: 'INVALID_FIELD', status: 400, field: 'amount' }); + } + + const rows = await engine.aggregate('fx_invoice', { + groupBy: ['currency_code'], + aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], + } as never, SYS); + const byCode = Object.fromEntries((rows as Array>).map((r) => [r.currency_code, Number(r.total)])); + expect(byCode).toEqual({ USD: 100, EUR: 100 }); + }); + + it('the write side: an amount with no code is refused and nothing is stored', async () => { + await boot([FX_INVOICE]); + const err = await refusalOf(() => engine.insert('fx_invoice', { name: 'i1', amount: 100 }, SYS)); + expect(envelope(err)).toEqual({ code: 'ERR_CURRENCY_CODE_REQUIRED', status: 400, field: 'currency_code' }); + expect(await engine.find('fx_invoice', {}, SYS)).toEqual([]); + }); + + it('PROBE-R: a summary roll-up over the record-mode amount is refused when the app registers', async () => { + const parent = { + name: 'fx_bill', + label: 'FX bill', + fields: { + name: { name: 'name', type: 'text' }, + billed_total: { name: 'billed_total', type: 'summary', summaryOperations: { object: 'fx_bill_line', field: 'amount', function: 'sum' } }, + }, + }; + const line = { + name: 'fx_bill_line', + label: 'FX bill line', + fields: { + currency_code: CODE, + amount: { name: 'amount', type: 'currency', currencyConfig: RECORD }, + bill: { name: 'bill', type: 'master_detail', reference: 'fx_bill' }, + }, + }; + const err = await refusalOf(() => engine.registerApp(appWith([parent, line]))); + expect({ code: err.code, status: err.status, name: err.name }).toEqual({ + code: 'INVALID_METADATA', status: 422, name: 'RecordCurrencySummaryRefusedError', + }); + expect(String(err.message)).toContain('rolls up sum(fx_bill_line.amount)'); + }); +}); From a195484744a0ff2a7493979f3addfd7281835e75 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:13:48 +0000 Subject: [PATCH 08/21] chore(changeset): record-currency guards Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- .changeset/27186-record-currency-guards.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/27186-record-currency-guards.md diff --git a/.changeset/27186-record-currency-guards.md b/.changeset/27186-record-currency-guards.md new file mode 100644 index 00000000000..477a65e4efe --- /dev/null +++ b/.changeset/27186-record-currency-guards.md @@ -0,0 +1,16 @@ +--- +'@objectstack/objectql': patch +'@objectstack/formula': patch +'@objectstack/spec': patch +--- + +feat(objectql): a record-currency amount is refused without its code, refused in an ungrouped raw aggregate, and refused under a summary roll-up + +Clause-②: no +Metadata-protocol: none + +- **A `record`-mode amount is stored with its currency code (ADR-0144 D8 rule 4).** An insert or update that would store a value in a `currency` field declaring `currencyConfig: { currencyMode: 'record', currencyField }` while that code field is blank (absent, `null` or white space) is refused `ERR_CURRENCY_CODE_REQUIRED` / 400, with `field` naming the code field and `amountField` the amount. The server never fills a currency in. On an update, only a write that carries the amount or its code is judged, against the stored row with the payload laid over it, so an edit of another field never meets it. The code is registered under `@objectstack/objectql` in the ADR-0112 error-code ledger. +- **A raw aggregate over a `record`-mode amount groups by its code field (ADR-0144 D6).** `sum`, `avg`, `min` and `max` over such a field through `engine.aggregate` are refused `INVALID_FIELD` / 400 unless `groupBy` names the code field; grouped, each currency totals on its own. `count` and `count_distinct` are not judged. Before, USD 100 and EUR 100 summed to 200 under one label. +- **A `summary` roll-up over a `record`-mode amount is refused at registration (ADR-0144 D6).** `sum` / `avg` / `min` / `max` of such a child field is refused `INVALID_METADATA` / 422 (`RecordCurrencySummaryRefusedError`) at whichever object's registration completes the pair. Before, child rows of USD 100 and JPY 15000 rolled up to 15100. Use `count`, or roll up a stored home-currency amount. +- **`inferFormulaReturn` carries a record currency (`@objectstack/formula`).** A formula over a `record`-mode amount now declares `returnType: 'currency'` with `currencyConfig: { currencyMode: 'record', currencyField }`, the code field its sources name, instead of a tenant-default (`dynamic`) amount. Two record-mode amounts naming different code fields, or a record-mode amount meeting a `fixed` or `dynamic` one, are not one currency and are not stamped as money. +- **Nothing to migrate.** `currencyMode: 'record'` is new; no field declared before it meets any of these refusals. From a9f5abfd1b18735bf378a5965ad4a558e8a0b6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:17:49 +0000 Subject: [PATCH 09/21] chore(spec): regenerate artifacts after bringing main in Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- content/docs/references/data/field.mdx | 6 +++--- content/docs/references/data/object.mdx | 9 ++++++--- content/docs/references/system/migration.mdx | 6 ++++-- packages/spec/authorable-surface/data.json | 2 ++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 8a148e37557..bffa4ef1874 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -29,7 +29,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | @@ -86,7 +86,7 @@ const result = CurrencyConfigSchema.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -250,7 +250,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | -| **defaultCurrency** | `string` | optional (default: `"CNY"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 592b298cd27..c0e83af4ed0 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -165,6 +165,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -251,7 +252,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -314,6 +315,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | +| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused, and so is a name that matches no inbound lookup / master_detail field. relatedList: false on the relationship still hides the table. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` | | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | @@ -509,7 +511,7 @@ const result = ApiMethod.parse(data); | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | -| **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | +| **newTabUrl** | `string` | optional | New-tab path template on this backend, starting with / (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response; for a flow, the completed run's answer); `openIn` defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -613,7 +615,7 @@ const result = ApiMethod.parse(data); | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -699,6 +701,7 @@ External datasource binding (ADR-0015) | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | +| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused, and so is a name that matches no inbound lookup / master_detail field. relatedList: false on the relationship still hides the table. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` | | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 33757a7c57c..c034e66a503 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -86,7 +86,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -348,6 +348,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | @@ -516,7 +517,7 @@ Add a new field to an existing object | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | | **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | -| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | +| **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card beside the record (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after the field sections) as a full-width table (its own tab on the tabbed page). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=`` / sort=-``, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | @@ -643,6 +644,7 @@ Create a new object | **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | +| **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry that matches no inbound lookup / master_detail field of this object is refused. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value `select` / `radio` with inline `options`, each declaring its `kind` ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 2f574c25018..a433eac4846 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -691,6 +691,7 @@ "data/Object:pluralLabel", "data/Object:protection", "data/Object:publicSharing", + "data/Object:relatedLists", "data/Object:requiredPermissions", "data/Object:searchableFields", "data/Object:sharing", @@ -739,6 +740,7 @@ "data/ObjectFieldGroup:icon", "data/ObjectFieldGroup:key", "data/ObjectFieldGroup:label", + "data/ObjectFieldGroup:relatedList", "data/ObjectFieldGroup:visibleWhen", "data/PerOperationRequiredPermissions:create", "data/PerOperationRequiredPermissions:delete", From 77bc11a4aea8af237ce62522adc7ca1d5ddeb971 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:39:02 +0000 Subject: [PATCH 10/21] chore(spec): regenerate the reference pages for ERR_CURRENCY_CODE_REQUIRED Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- content/docs/references/api/automation-api.mdx | 3 ++- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 46ead571b7f..466ae9f9d7e 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +347 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +348 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -479,6 +479,7 @@ const result = AutomationApiErrorCode.parse(data); * `ERR_AUTONUMBER_COLLISION` * `ERR_BULK_RESULT_MISMATCH` * `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` +* `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` * `ERR_FILE_CONSTRAINT` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 26074cf317d..5330f90abed 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +347 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +348 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -167,6 +167,7 @@ const result = ApiErrorSchema.parse(data); * `ERR_AUTONUMBER_COLLISION` * `ERR_BULK_RESULT_MISMATCH` * `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` +* `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` * `ERR_FILE_CONSTRAINT` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 45028637f64..0a0c7e98430 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -334,6 +334,7 @@ const result = ErrorCode.parse(data); * `ERR_AUTONUMBER_COLLISION` * `ERR_BULK_RESULT_MISMATCH` * `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE` +* `ERR_CURRENCY_CODE_REQUIRED` * `ERR_DATASOURCE_UNAVAILABLE` * `ERR_DRIVER_CONNECT` * `ERR_FILE_CONSTRAINT` From 901cbb01c047ad3698e656f7d3081569987a15b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 20:53:18 +0000 Subject: [PATCH 11/21] test(objectql,dogfood): record-currency suites type their query options and hold the find double's limit Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- .../src/record-currency-guards.test.ts | 32 +++++++++++-------- .../test/record-currency-probes.test.ts | 14 +++++--- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/objectql/src/record-currency-guards.test.ts b/packages/objectql/src/record-currency-guards.test.ts index 8abb711a6bd..eb8ab7715cc 100644 --- a/packages/objectql/src/record-currency-guards.test.ts +++ b/packages/objectql/src/record-currency-guards.test.ts @@ -18,6 +18,7 @@ // `record-currency-probes.dogfood.test.ts`. import { describe, it, expect, beforeEach } from 'vitest'; +import type { EngineAggregateOptions, EngineUpdateOptions } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; function makeDriver() { @@ -42,7 +43,8 @@ function makeDriver() { name: 'memory', version: '0.0.0', supports: {}, async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r }; @@ -102,6 +104,9 @@ const INVALID_FIELD = { code: 'INVALID_FIELD', status: 400, httpStatus: 400 }; const INVALID_METADATA = { code: 'INVALID_METADATA', status: 422, httpStatus: 422 }; const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; +/** Typed at the call, never asserted (`check:query-options-erasure`). */ +const aggregateQuery = (query: EngineAggregateOptions): EngineAggregateOptions => query; +const BATCH: EngineUpdateOptions = { where: { title: 'batch' }, multi: true }; const FX_INVOICE = { name: 'fx_invoice', @@ -180,13 +185,13 @@ describe('a record-mode amount is stored with its currency code (ERR_CURRENCY_CO const a = await engine.insert('fx_invoice', { title: 'batch', amount: 1, currency_code: 'EUR' }); const b = await engine.insert('fx_invoice', { title: 'batch' }); - const err = await refusalOf(engine.update('fx_invoice', { amount: 9 }, { where: { title: 'batch' }, multi: true } as any)); + const err = await refusalOf(engine.update('fx_invoice', { amount: 9 }, BATCH)); expect(envelopeOf(err)).toEqual(CODE_REQUIRED); expect(err?.message).toContain(`(record ${b.id})`); expect(storeFor('fx_invoice').get(a.id).amount).toBe(1); // The control: the same write carrying the code. - await engine.update('fx_invoice', { amount: 9, currency_code: 'EUR' }, { where: { title: 'batch' }, multi: true } as any); + await engine.update('fx_invoice', { amount: 9, currency_code: 'EUR' }, BATCH); expect(storeFor('fx_invoice').get(b.id)).toMatchObject({ amount: 9, currency_code: 'EUR' }); }); }); @@ -202,10 +207,10 @@ describe('a raw aggregate over a record-mode amount groups by its code field (PR }); it('sum / avg / min / max ungrouped is refused INVALID_FIELD, with the route first', async () => { - for (const fn of ['sum', 'avg', 'min', 'max']) { - const err = await refusalOf(engine.aggregate('fx_invoice', { + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + const err = await refusalOf(engine.aggregate('fx_invoice', aggregateQuery({ aggregations: [{ function: fn, field: 'amount', alias: 'v' }], - } as any)); + }))); expect(envelopeOf(err), fn).toEqual(INVALID_FIELD); expect(err?.field).toBe('amount'); expect(err?.param).toBe('aggregations'); @@ -215,31 +220,32 @@ describe('a raw aggregate over a record-mode amount groups by its code field (PR }); it('grouped by a field other than the code is still refused', async () => { - const err = await refusalOf(engine.aggregate('fx_invoice', { + const err = await refusalOf(engine.aggregate('fx_invoice', aggregateQuery({ groupBy: ['title'], aggregations: [{ function: 'sum', field: 'amount', alias: 'v' }], - } as any)); + }))); expect(envelopeOf(err)).toEqual(INVALID_FIELD); }); it('grouped by the code field answers one correct total per currency', async () => { - for (const groupBy of [['currency_code'], [{ field: 'currency_code' }]]) { - const rows = await engine.aggregate('fx_invoice', { + const groupings: Array = [['currency_code'], [{ field: 'currency_code' }]]; + for (const groupBy of groupings) { + const rows = await engine.aggregate('fx_invoice', aggregateQuery({ groupBy, aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], - } as any); + })); const byCode = Object.fromEntries(rows.map((r: any) => [r.currency_code, r.total])); expect(byCode).toEqual({ USD: 100, EUR: 100 }); } }); it('count, and a fixed-currency amount, are not judged', async () => { - const rows = await engine.aggregate('fx_invoice', { + const rows = await engine.aggregate('fx_invoice', aggregateQuery({ aggregations: [ { function: 'count', field: 'amount', alias: 'n' }, { function: 'sum', field: 'amt_usd', alias: 'usd' }, ], - } as any); + })); expect(rows[0]).toMatchObject({ n: 2, usd: 100 }); }); }); diff --git a/packages/qa/dogfood/test/record-currency-probes.test.ts b/packages/qa/dogfood/test/record-currency-probes.test.ts index 4a011d4c3bb..7c619033dd8 100644 --- a/packages/qa/dogfood/test/record-currency-probes.test.ts +++ b/packages/qa/dogfood/test/record-currency-probes.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; +import type { EngineAggregateOptions } from '@objectstack/spec/data'; import { SqlDriver } from '@objectstack/driver-sql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { installDefaultOrganization } from './default-organization.testkit.js'; @@ -37,6 +38,9 @@ const DRIVERS = [ }, ]; +/** Typed at the call, never asserted (`check:query-options-erasure`). */ +const aggregateQuery = (query: EngineAggregateOptions): EngineAggregateOptions => query; + const SYS = { context: { isSystem: true, userId: 'usr_system' } }; const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; @@ -99,17 +103,17 @@ describe.each(DRIVERS)('record-currency probes: $name', ({ make }) => { await engine.insert('fx_invoice', { name: 'i1', currency_code: 'USD', amount: 100 }, SYS); await engine.insert('fx_invoice', { name: 'i2', currency_code: 'EUR', amount: 100 }, SYS); - for (const fn of ['sum', 'avg', 'min', 'max']) { - const err = await refusalOf(() => engine.aggregate('fx_invoice', { + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + const err = await refusalOf(() => engine.aggregate('fx_invoice', aggregateQuery({ aggregations: [{ function: fn, field: 'amount', alias: 'v' }], - } as never, SYS)); + }), SYS)); expect(envelope(err), fn).toEqual({ code: 'INVALID_FIELD', status: 400, field: 'amount' }); } - const rows = await engine.aggregate('fx_invoice', { + const rows = await engine.aggregate('fx_invoice', aggregateQuery({ groupBy: ['currency_code'], aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }], - } as never, SYS); + }), SYS); const byCode = Object.fromEntries((rows as Array>).map((r) => [r.currency_code, Number(r.total)])); expect(byCode).toEqual({ USD: 100, EUR: 100 }); }); From 4201bb53a8ab7fc8fbdf81a135771a6b70c9eca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:05:04 +0000 Subject: [PATCH 12/21] test(objectql): the record-currency find double applies the limit before copying rows Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- packages/objectql/src/record-currency-guards.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/record-currency-guards.test.ts b/packages/objectql/src/record-currency-guards.test.ts index eb8ab7715cc..c41a2c76f5d 100644 --- a/packages/objectql/src/record-currency-guards.test.ts +++ b/packages/objectql/src/record-currency-guards.test.ts @@ -43,8 +43,9 @@ function makeDriver() { name: 'memory', version: '0.0.0', supports: {}, async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); - return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return page.map((r) => ({ ...r })); }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r }; From da1ebc7b4ee160abfb179d77e003149d9b8751c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:09:08 +0000 Subject: [PATCH 13/21] =?UTF-8?q?fix(i18n):=20resolveFieldCurrency=20accep?= =?UTF-8?q?ts=20the=20record=20currency=20mode=20=E2=80=94=20the=20field-l?= =?UTF-8?q?evel=20answer=20never=20reads=20a=20code=20from=20a=20record-mo?= =?UTF-8?q?de=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f Co-authored-by: Claude --- .changeset/27184-currency-record-mode.md | 1 + .../i18n/src/__tests__/resolveFieldCurrency.test.ts | 7 +++++++ ui/packages/i18n/src/currency.ts | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.changeset/27184-currency-record-mode.md b/.changeset/27184-currency-record-mode.md index 43e3071d6ea..79a5b99b31a 100644 --- a/.changeset/27184-currency-record-mode.md +++ b/.changeset/27184-currency-record-mode.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': minor +'@object-ui/i18n': minor --- feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2) diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts index ca5ff20e760..fb4a5bc2337 100644 --- a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts @@ -78,6 +78,13 @@ describe('currencyMode decides whether currencyConfig.defaultCurrency is read (o expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); }); + it('record: the field names no currency of its own, so the config never supplies one', () => { + const parsed = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); + expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBe('USD'); + expect(resolveFieldCurrency({ currencyConfig: parsed })).toBeUndefined(); + }); + it('parsed-empty: the materialized shape as a renderer receives it', () => { expect( resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, TENANT), diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index 075de0ccad9..1cf4656e6d2 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -42,6 +42,12 @@ * its own selected code, that code is the one to win, ahead of the tenant * default. * + * `currencyMode: 'record'` (ADR-0144 D2) also skips step 2: the field has no + * currency of its own — each record's code lives in the sibling field + * `currencyConfig.currencyField` names, which only a renderer holding the row + * can read. This field-level answer therefore never claims a code from the + * config; reading the row's code is the per-row display's job. + * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only * `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n` @@ -53,7 +59,7 @@ export function resolveFieldCurrency( | { currency?: string; defaultCurrency?: string; - currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string }; + currencyConfig?: { currencyMode?: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }; } | null | undefined, From f174d4b0c07b858058fc3720ea38147646e254bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:18:39 +0000 Subject: [PATCH 14/21] test(platform-objects): move the metadata-form catalog pins for the currencyField row The field form's new `currencyConfig.currencyField` row is one more unwalked composite child (10 -> 11) and one more translated row label in each locale's positive control (695 -> 696). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- .../apps/translations/field-panel-echo-decisions.test.ts | 6 ++++-- .../object-lifecycle-panel-echo-decisions.test.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts index 7d4d617c6da..280c7c7ccbe 100644 --- a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts @@ -708,9 +708,11 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // locales, so the echo rule above reads them translated, not undecided. // 10 since #23735: the `referenceDeletedWhen` composite (`field`, `value`), // whose four string leaves that card authored in all three locales. - expect(UNWALKED_CHILDREN.length).toBe(10); + // 11 since #27184: `currencyConfig.currencyField` (the record mode's code + // field), its two string leaves authored in all three locales. + expect(UNWALKED_CHILDREN.length).toBe(11); for (const child of [ - 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'storage.notNull', + 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'currencyConfig.currencyField', 'storage.notNull', 'referenceDeletedWhen.field', 'referenceDeletedWhen.value', ]) { expect(UNWALKED_CHILDREN).toContain(child); diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 7036b1ba7e5..ce35b19f2d9 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1238,7 +1238,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 695 since #27069: the object form's `relatedLists` row and the // `fieldGroups` repeater's `relatedList` sub-row — two row labels, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(695); + // 696 since #27184: the field form's `currencyConfig.currencyField` row + // (record currency mode) — one row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(696); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From bf7f2b74eb28b6da52d9222ece2f28f4438d91be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:37:05 +0000 Subject: [PATCH 15/21] fix(i18n): a record-mode field resolves no currency, never the tenant default Under `currencyMode: 'record'` each record names its own code in the sibling field, which only a per-row renderer can read. The field-level answer is now `undefined` outright, so a record with a blank code shows no symbol instead of a currency it never named. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- .../i18n/src/__tests__/resolveFieldCurrency.test.ts | 7 ++++--- ui/packages/i18n/src/currency.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts index fb4a5bc2337..6360c005f30 100644 --- a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts @@ -78,10 +78,11 @@ describe('currencyMode decides whether currencyConfig.defaultCurrency is read (o expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); }); - it('record: the field names no currency of its own, so the config never supplies one', () => { + it('record: the field names no currency of its own, so nothing supplies one, the tenant default included', () => { const parsed = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); - expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); - expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBe('USD'); + expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ defaultCurrency: 'EUR', currencyConfig: parsed }, TENANT)).toBeUndefined(); expect(resolveFieldCurrency({ currencyConfig: parsed })).toBeUndefined(); }); diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index 1cf4656e6d2..50f9d0d03e7 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -42,11 +42,12 @@ * its own selected code, that code is the one to win, ahead of the tenant * default. * - * `currencyMode: 'record'` (ADR-0144 D2) also skips step 2: the field has no - * currency of its own — each record's code lives in the sibling field - * `currencyConfig.currencyField` names, which only a renderer holding the row - * can read. This field-level answer therefore never claims a code from the - * config; reading the row's code is the per-row display's job. + * `currencyMode: 'record'` (ADR-0144 D2) returns `undefined` outright: the + * field has no currency of its own — each record's code lives in the sibling + * field `currencyConfig.currencyField` names, which only a renderer holding the + * row can read. No step applies, the tenant default included: a record whose + * code is blank shows no symbol rather than a currency it never named. Reading + * the row's code is the per-row display's job. * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only @@ -66,6 +67,7 @@ export function resolveFieldCurrency( tenantDefault?: string, ): string | undefined { const config = field?.currencyConfig; + if (config?.currencyMode === 'record') return undefined; return ( field?.currency || (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) || From 90114641aeaeaa9a71a153be5051bba23326e8ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:25:20 +0000 Subject: [PATCH 16/21] chore(spec): regenerate artifacts after bringing main in The authorable-surface baseline is taken from main (#27233 retired `CurrencyValue`) and rebuilt, keeping `currencyConfig.currencyField`; the field reference page is regenerated from the merged tree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- content/docs/references/data/field.mdx | 18 +++--------------- packages/spec/authorable-surface/data.json | 2 -- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index bffa4ef1874..a96c06ba876 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -1,7 +1,7 @@ --- title: Field schema — Data Protocol reference navTitle: Field -description: "Field schemas of the ObjectStack Data Protocol: CurrencyConfig, CurrencyValue, Field and 7 more — each property with its type, default and a TypeScript example." +description: "Field schemas of the ObjectStack Data Protocol: CurrencyConfig, Field and 7 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Field schemas of the ObjectStack Data Protocol: CurrencyConfig, Cu ## TypeScript Usage ```typescript -import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, InlineGridColumnSchema, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; -import type { CurrencyConfig, CurrencyValue, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, InlineGridColumn, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; +import { CurrencyConfigSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, InlineGridColumnSchema, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; +import type { CurrencyConfig, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, InlineGridColumn, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; // Validate data const result = CurrencyConfigSchema.parse(data); @@ -33,18 +33,6 @@ const result = CurrencyConfigSchema.parse(data); | **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | ---- - -## CurrencyValue - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **value** | `number` | ✅ | Monetary amount | -| **currency** | `string` | ✅ | Currency code (ISO 4217) | - - --- ## Field diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index a433eac4846..7d807145820 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -104,8 +104,6 @@ "data/CurrencyConfig:currencyField", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", - "data/CurrencyValue:currency", - "data/CurrencyValue:value", "data/DataEngineAggregateOptions:aggregations", "data/DataEngineAggregateOptions:context", "data/DataEngineAggregateOptions:filter", From d36d8b721fe50f958d810d1de274d070819f6df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 03:48:13 +0000 Subject: [PATCH 17/21] chore(spec): regenerate the reference pages after bringing #27268 in Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 5330f90abed..4350ce4e56a 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -581,7 +581,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -634,7 +634,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns, at least one: omit `fields` to read every column, because an empty list or a blank name is refused rather than read as every column. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | From f3d286ce170a1158ec1a6e0ee06f706ab1247aeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:22:30 +0000 Subject: [PATCH 18/21] fix(spec): record currency mode refuses an empty pointer and an inert defaultCurrency Contract-review should-fixes on the record currency mode (ADR-0144 D2): - `currencyField` is never empty (`.min(1)`), so no record amount carries a pointer the object door names as nothing. - `defaultCurrency` is refused under `record`, at the key, with a prescription. `record` is new, so no stored row needs the tolerance `dynamic` keeps; the inert key is ADR-0078's shape. - The object door reads `VALUE_DOMAIN_FIELD_TYPES` instead of repeating the `'text'` literal. - ADR-0144 anchors on field.zod.ts and object.zod.ts. - The changeset lists `@objectstack/platform-objects`, which ships the regenerated metadata-form bundles. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- .changeset/27184-currency-record-mode.md | 3 +- .../data/currency-config-record-mode.test.ts | 28 ++++++++++- packages/spec/src/data/field.zod.ts | 48 ++++++++++++------- packages/spec/src/data/object.zod.ts | 21 ++++---- ...ckages__spec__src__data__field.zod.ts.json | 5 +- ...kages__spec__src__data__object.zod.ts.json | 5 +- 6 files changed, 79 insertions(+), 31 deletions(-) diff --git a/.changeset/27184-currency-record-mode.md b/.changeset/27184-currency-record-mode.md index 79a5b99b31a..18188a665c9 100644 --- a/.changeset/27184-currency-record-mode.md +++ b/.changeset/27184-currency-record-mode.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@object-ui/i18n': minor +'@objectstack/platform-objects': minor --- feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2) @@ -17,7 +18,7 @@ fields: { } ``` -- `currencyField` is required under `record` and refused under `dynamic` and `fixed`. +- `currencyField` is required under `record`, refused under `dynamic` and `fixed`, and never empty; `defaultCurrency` is refused under `record`. - The object refuses a `currencyField` that names no other declared field, a field that is not `text`, or a `text` field without `valueDomain: 'iso_4217_currency'`. Each refusal is located at `fields.FIELD.currencyConfig.currencyField` and lists the object's candidate code fields. - `dynamic` and `fixed` configs parse exactly as before; an app that never writes `record` sees no change. diff --git a/packages/spec/src/data/currency-config-record-mode.test.ts b/packages/spec/src/data/currency-config-record-mode.test.ts index 104273b5241..72d0a2a98c7 100644 --- a/packages/spec/src/data/currency-config-record-mode.test.ts +++ b/packages/spec/src/data/currency-config-record-mode.test.ts @@ -5,7 +5,8 @@ * amount points at a sibling ISO 4217 code field on the same object. * * Two doors, one per half: `CurrencyConfigSchema` refuses the half one config - * can see (`record` without a pointer, a pointer under another mode), and the + * can see (`record` without a pointer or with a `defaultCurrency`, a pointer + * under another mode, an empty pointer), and the * object's `superRefine` refuses what the pointer names (nothing, a non-text * field, a text field with no ISO 4217 value domain). A `formula` declaring * `returnType: 'currency'` carries the same config, so it meets both doors. @@ -59,6 +60,31 @@ describe('`CurrencyConfigSchema`: `record` ⇔ `currencyField`', () => { expect(r.error.issues[0]!.message).toMatch(/^`currencyField` is read only under `currencyMode: 'record'`/); }); + it('refuses an empty `currencyField`, at the key, in every mode', () => { + for (const mode of ['record', 'dynamic', 'fixed'] as const) { + const config = mode === 'fixed' ? { currencyMode: mode, defaultCurrency: 'USD', currencyField: '' } : { currencyMode: mode, currencyField: '' }; + const r = CurrencyConfigSchema.safeParse(config); + expect(r.success, mode).toBe(false); + if (r.success) continue; + // Under `dynamic` / `fixed` the out-of-mode refusal is told too, at the same key. + expect(r.error.issues.length, mode).toBe(mode === 'record' ? 1 : 2); + expect(r.error.issues.every((i) => i.path.join('.') === 'currencyField'), mode).toBe(true); + expect(r.error.issues[0]!.message).toMatch(/^`currencyField` names a field: an empty name points at nothing/); + } + }); + + it('refuses `defaultCurrency` under `record`, at the key, with the prescription', () => { + const r = CurrencyConfigSchema.safeParse({ ...RECORD, defaultCurrency: 'EUR' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual(['defaultCurrency']); + expect(r.error.issues[0]!.message).toMatch(/^`defaultCurrency` is not read under `currencyMode: 'record'`/); + // …and `dynamic` keeps tolerating its inert code, for the rows #27157 left behind. + expect(CurrencyConfigSchema.safeParse({ currencyMode: 'dynamic', defaultCurrency: 'EUR' }).success).toBe(true); + }); + it('refuses `currencyField` under the defaulted mode (no `currencyMode` written ⇒ dynamic)', () => { expect(CurrencyConfigSchema.safeParse({ currencyField: 'currency_code' }).success).toBe(false); }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 251755bc619..3b601830760 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -535,16 +535,29 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> }; /** - * [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`. A `record`-mode - * amount has no currency until a pointer names the sibling code field, and a - * pointer under `dynamic` / `fixed` is read by nothing — the declared-but-inert - * shape ADR-0078 keeps out. One located issue at `['currencyField']` either - * way; what the pointer NAMES is the object's to judge (object.zod.ts). + * [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`, and no + * `defaultCurrency` beside it. A `record`-mode amount has no currency until a + * pointer names the sibling code field; a pointer under `dynamic` / `fixed` is + * read by nothing, and neither is a `defaultCurrency` under `record` — each is + * the declared-but-inert shape ADR-0078 keeps out. `record` is new, so no + * stored row carries the second one (unlike `dynamic`, whose inert code is + * tolerated for the rows #27157 left behind). One located issue per inert or + * missing key; what the pointer NAMES is the object's to judge (object.zod.ts). */ -function refuseCurrencyFieldOutsideRecordMode( - config: { currencyMode: 'dynamic' | 'fixed' | 'record'; currencyField?: string }, +function refuseRecordModeKeyMismatch( + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }, ctx: z.RefinementCtx, ): void { + if (config.currencyMode === 'record' && config.defaultCurrency !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['defaultCurrency'], + message: + `\`defaultCurrency\` is not read under \`currencyMode: 'record'\`, where each record names its own currency in ` + + 'the field `currencyField` names: delete `defaultCurrency`, or use `currencyMode: \'fixed\'` if the field has ' + + 'one currency.', + }); + } if (config.currencyMode === 'record' && config.currencyField === undefined) { ctx.addIssue({ code: 'custom', @@ -621,21 +634,22 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ aliases: { mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE, }, { - currencyMode: z.enum(['dynamic', 'fixed', 'record']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1).'), - defaultCurrency: z.string().length(3).optional().describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own.'), + currencyMode: z.enum(['dynamic', 'fixed', 'record']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1).'), + defaultCurrency: z.string().length(3).optional().describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own.'), // ADR-0144 D2 — the pointer of a `record`-mode amount. Optional at the key - // level; `refuseCurrencyFieldOutsideRecordMode` below makes it required - // under `record` and refuses it under the other two modes, and the object's - // `superRefine` (`refuseDishonestRecordCurrencyField`, object.zod.ts) checks - // what it names, because only the object holds the sibling field map. - currencyField: z.string().optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`.'), + // level and never empty; `refuseRecordModeKeyMismatch` below makes it + // required under `record` and refuses it under the other two modes, and the + // object's `superRefine` (`refuseDishonestRecordCurrencyField`, + // object.zod.ts) checks what it names, because only the object holds the + // sibling field map. + currencyField: z.string().min(1, '`currencyField` names a field: an empty name points at nothing — name the field on this object that holds each record\'s ISO 4217 code, conventionally `currency_code`.').optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty.'), // #19992 — no `.overwrite()` here any more: the #7918 ISO 4217 contradiction // check and the #11423 default-materializing `.overwrite()` both existed only // for the removed `precision` key (see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE - // above). #27157 — `fixed` ⇒ a code; ADR-0144 D2 — `record` ⇔ a pointer, both - // below. Parse output is `{ currencyMode }` plus the `defaultCurrency` and + // above). #27157 — `fixed` ⇒ a code; ADR-0144 D2 — `record` ⇔ a pointer and + // no code, both below. Parse output is `{ currencyMode }` plus the `defaultCurrency` and // `currencyField` the author wrote. -}).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseCurrencyFieldOutsideRecordMode)); +}).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseRecordModeKeyMismatch)); /** * Address Schema — structured address for the `address` field type. diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 9a6758b3a8b..edf34334319 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { FieldSchema, FieldType } from './field.zod'; +import { FieldSchema, FieldType, VALUE_DOMAIN_FIELD_TYPES } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; import { ActionSchema, type ActionParam } from '../ui/action.zod'; import { ObjectListViewSchema } from '../ui/view.zod'; @@ -1857,8 +1857,11 @@ function refuseNonPictureImageField( * prescription and the object's candidate fields: * * - the name is not a declared field (or is the amount itself); - * - the field is not `text`; - * - the field is `text` without `valueDomain: 'iso_4217_currency'`. + * - the field's type is not one that may declare a value domain + * (`VALUE_DOMAIN_FIELD_TYPES`, field.zod.ts — `text` alone today; the ADR's + * "text field" rests on that set, so this door reads it rather than + * repeating the literal); + * - the field is of such a type without `valueDomain: 'iso_4217_currency'`. * * Judged against the AUTHORED field map. Not judged on * `ObjectExtensionSchema`: an extension's fields merge into an object it does @@ -1872,15 +1875,17 @@ function refuseDishonestRecordCurrencyField( if (fields === null || typeof fields !== 'object') return; const objectName = typeof ownName === 'string' ? ownName : ''; const map = fields as Record; + const isCodeType = (type: unknown): boolean => typeof type === 'string' && VALUE_DOMAIN_FIELD_TYPES.has(type); + const codeTypes = [...VALUE_DOMAIN_FIELD_TYPES].map((t) => `\`${t}\``).join(' or '); const isCodeField = (f: (typeof map)[string]): boolean => - f?.type === 'text' && f.valueDomain === 'iso_4217_currency'; + isCodeType(f?.type) && f?.valueDomain === 'iso_4217_currency'; for (const [name, field] of Object.entries(map)) { const config = field?.currencyConfig as { currencyMode?: unknown; currencyField?: unknown } | undefined; if (config?.currencyMode !== 'record' || typeof config.currencyField !== 'string') continue; const target = config.currencyField; const candidates = Object.keys(map).filter((k) => k !== name && isCodeField(map[k]!)); const prescription = - '`currencyField` must name a `text` field of this object declaring `valueDomain: \'iso_4217_currency\'` — ' + `\`currencyField\` must name a ${codeTypes} field of this object declaring \`valueDomain: 'iso_4217_currency'\` — ` + (candidates.length > 0 ? `candidates: ${candidates.map((c) => `\`${c}\``).join(', ')}.` : 'the object has none; declare one, conventionally `currency_code: Field.text({ valueDomain: \'iso_4217_currency\' })`.'); @@ -1896,12 +1901,12 @@ function refuseDishonestRecordCurrencyField( continue; } const type = map[target]?.type; - if (type !== 'text') { + if (!isCodeType(type)) { ctx.addIssue({ code: 'custom', path, message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object ` - + `\`${objectName}\`; a record currency is a \`text\` ISO 4217 code. ${prescription}`, + + `\`${objectName}\`; a record currency is an ISO 4217 code in a ${codeTypes} field. ${prescription}`, }); continue; } @@ -1909,7 +1914,7 @@ function refuseDishonestRecordCurrencyField( ctx.addIssue({ code: 'custom', path, - message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`text\` field on object \`${objectName}\` ` + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object \`${objectName}\` ` + `that does not declare \`valueDomain: 'iso_4217_currency'\`, so a write could store any string as the ` + `currency. Add \`valueDomain: 'iso_4217_currency'\` to \`${target}\`, or point at another field. ${prescription}`, }); diff --git a/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json index 36d077697e0..056303ba6d1 100644 --- a/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json @@ -1,7 +1,8 @@ { "file": "packages/spec/src/data/field.zod.ts", "adrs": [ - "ADR-0120" + "ADR-0120", + "ADR-0144" ], - "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified." + "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified.\n\nADR-0144 D2 — `CurrencyConfigSchema` has a third mode, `record`: the amount stays a bare number (ADR-0104 D1) and `currencyField` names the sibling field holding each record's ISO 4217 code. The config door (`refuseRecordModeKeyMismatch`) requires a non-empty `currencyField` under `record`, refuses it under `dynamic` / `fixed`, and refuses `defaultCurrency` under `record`. Each refused key would be declared but read by nothing (ADR-0078). Do NOT give `record` a `defaultCurrency` fallback or a platform-chosen code field name: per D8 rules 2 and 4, the code is the record's own, the field is the author's, and nothing fills either in. What the pointer NAMES is judged on the object (object.zod.ts), not here." } diff --git a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json index 5b081c48b6b..76300265b17 100644 --- a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json @@ -2,7 +2,8 @@ "file": "packages/spec/src/data/object.zod.ts", "adrs": [ "ADR-0055", - "ADR-0120" + "ADR-0120", + "ADR-0144" ], - "invariant": "ADR-0055 §1 — a `controlled_by_parent` object derives its master from EXACTLY ONE `master_detail` field declared `required: true`, and the parse refuses anything else (`refuseControlledByParentWithoutOneMaster`, in `ObjectSchemaBase`'s superRefine): no such field (a non-required `master_detail`, a required `lookup`, nothing) at `sharingModel`, two or more at `fields`. It runs on the PARSE, not only in `ObjectSchema.create()`, because the shapes it refuses were reachable through every door that skips the builder and the security runtime derives no master from them — so an object the parse accepted would be denied every read and refused every write. Do NOT move it back to `create()` only, and do NOT accept a second tier here (any `master_detail`, a required `lookup`): the runtime's two fallback tiers were retired under ADR-0049 with the step-18 semantic migration `cbp-master-relation-tiers-retired`, and a parse wider than the runtime is the three-seam disagreement that retirement closed.\n\n`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030)." + "invariant": "ADR-0055 §1 — a `controlled_by_parent` object derives its master from EXACTLY ONE `master_detail` field declared `required: true`, and the parse refuses anything else (`refuseControlledByParentWithoutOneMaster`, in `ObjectSchemaBase`'s superRefine): no such field (a non-required `master_detail`, a required `lookup`, nothing) at `sharingModel`, two or more at `fields`. It runs on the PARSE, not only in `ObjectSchema.create()`, because the shapes it refuses were reachable through every door that skips the builder and the security runtime derives no master from them — so an object the parse accepted would be denied every read and refused every write. Do NOT move it back to `create()` only, and do NOT accept a second tier here (any `master_detail`, a required `lookup`): the runtime's two fallback tiers were retired under ADR-0049 with the step-18 semantic migration `cbp-master-relation-tiers-retired`, and a parse wider than the runtime is the three-seam disagreement that retirement closed.\n\n`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030).\n\nADR-0144 D2 — a `record`-mode amount's `currencyConfig.currencyField` must name ANOTHER declared field of the same object whose type may carry a value domain (`VALUE_DOMAIN_FIELD_TYPES`, `text` today) and which declares `valueDomain: 'iso_4217_currency'` (`refuseDishonestRecordCurrencyField`, in `ObjectSchemaBase`'s superRefine beside `refuseNonPictureImageField`). It runs on the parse because only the object holds the sibling field map, and it covers a `formula` with `returnType: 'currency'` in the same shape. Do NOT accept a pointer into nothing, at a non-code field, or at a text field without the domain: each lets a write store an amount whose currency is any string or none. It is deliberately not run on `ObjectExtensionSchema`, whose pointer may name a field only the owner declares." } From b6f152bd17b1d7e70bed8c6b59098b3353f38691 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:22:38 +0000 Subject: [PATCH 19/21] docs(spec): three-mode currency help texts, translated; the skill's currency block scoped to record mode Contract-review should-fixes: - The `currencyConfig.currencyMode` help text in zh-CN, ja-JP and es-ES now describes `record` as the English does. `currencyConfig` says the value is a bare number in every mode, and `defaultCurrency` says it is refused in record mode, in all four locales. - field-types.md: a one-line record-mode example; "a measure's own `currency` wins" scoped to fixed/dynamic; `AMOUNT_home`/`AMOUNT_rate` dropped until the home projection exists. Paid within the same file (3151 / 3158 tokens). - The field reference page regenerated from the new descriptions. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f --- content/docs/references/data/field.mdx | 12 ++++++------ .../translations/en.metadata-forms.generated.ts | 4 ++-- .../translations/es-ES.metadata-forms.generated.ts | 6 +++--- .../translations/ja-JP.metadata-forms.generated.ts | 6 +++--- .../translations/zh-CN.metadata-forms.generated.ts | 6 +++--- packages/spec/src/data/field.form.ts | 4 ++-- skills/objectstack-data/rules/field-types.md | 14 +++++++------- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index a96c06ba876..b4b64c754a5 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -28,9 +28,9 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | -| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | -| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | --- @@ -237,9 +237,9 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1). | -| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own. | -| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | +| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 8ac9a7d06ab..4416ef64977 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -618,7 +618,7 @@ export const enMetadataForms: NonNullable = { }, currencyConfig: { label: "Currency Config", - helpText: "Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode." + helpText: "Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in every mode." }, "currencyConfig.currencyMode": { label: "Currency Mode", @@ -630,7 +630,7 @@ export const enMetadataForms: NonNullable = { }, "currencyConfig.defaultCurrency": { label: "Default Currency", - helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode." + helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode; refused in record mode." }, step: { label: "Step", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 7109cb824a1..c0056418ef8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -618,11 +618,11 @@ export const esESMetadataForms: NonNullable = }, currencyConfig: { label: "Configuración de moneda", - helpText: "En qué moneda está este campo: un campo de moneda, o una fórmula cuyo tipo de retorno es Moneda. Sin definir: modo dynamic. En ambos modos el valor es un número sin más." + helpText: "En qué moneda está este campo: un campo de moneda, o una fórmula cuyo tipo de retorno es Moneda. Sin definir: modo dynamic. En todos los modos el valor es un número sin más." }, "currencyConfig.currencyMode": { label: "Modo de moneda", - helpText: "dynamic (predeterminado): el campo no tiene moneda propia y los importes se muestran en la moneda predeterminada del tenant (el ajuste localization.currency). fixed: el campo tiene una única moneda, defaultCurrency." + helpText: "dynamic (predeterminado): el campo no tiene moneda propia y los importes se muestran en la moneda predeterminada del tenant (el ajuste localization.currency). fixed: el campo tiene una única moneda, defaultCurrency. record: cada registro tiene su propia moneda, guardada en el campo que indica currencyField." }, "currencyConfig.currencyField": { label: "Campo de moneda", @@ -630,7 +630,7 @@ export const esESMetadataForms: NonNullable = }, "currencyConfig.defaultCurrency": { label: "Moneda predeterminada", - helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Obligatorio en modo fixed; no se rellena automáticamente. No se lee en modo dynamic." + helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Obligatorio en modo fixed; no se rellena automáticamente. No se lee en modo dynamic; se rechaza en modo record." }, step: { label: "Incremento", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 9a96144af89..c2cf601fb12 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -618,11 +618,11 @@ export const jaJPMetadataForms: NonNullable = }, currencyConfig: { label: "通貨設定", - helpText: "このフィールドの通貨(通貨フィールド、または戻り値型が「通貨」の数式フィールド)。未設定の場合は dynamic モードです。どちらのモードでも、値は単なる数値です。" + helpText: "このフィールドの通貨(通貨フィールド、または戻り値型が「通貨」の数式フィールド)。未設定の場合は dynamic モードです。どのモードでも、値は単なる数値です。" }, "currencyConfig.currencyMode": { label: "通貨モード", - helpText: "dynamic(既定):フィールド自体は通貨を持たず、金額はテナントの既定通貨(localization.currency 設定)で表示されます。fixed:フィールドは 1 つの通貨 defaultCurrency を持ちます。" + helpText: "dynamic(既定):フィールド自体は通貨を持たず、金額はテナントの既定通貨(localization.currency 設定)で表示されます。fixed:フィールドは 1 つの通貨 defaultCurrency を持ちます。record:各レコードが独自の通貨を持ち、currencyField が指すフィールドに保存されます。" }, "currencyConfig.currencyField": { label: "通貨フィールド", @@ -630,7 +630,7 @@ export const jaJPMetadataForms: NonNullable = }, "currencyConfig.defaultCurrency": { label: "既定の通貨", - helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されません。" + helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されず、record モードでは指定できません。" }, step: { label: "ステップ値", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 1a60bdfdf1d..6ed55dee301 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -618,11 +618,11 @@ export const zhCNMetadataForms: NonNullable = }, currencyConfig: { label: "货币配置", - helpText: "此字段使用哪种货币——货币字段,或返回类型为“货币”的公式字段。未设置时为 dynamic 模式。两种模式下值都是一个纯数字。" + helpText: "此字段使用哪种货币——货币字段,或返回类型为“货币”的公式字段。未设置时为 dynamic 模式。任何模式下值都是一个纯数字。" }, "currencyConfig.currencyMode": { label: "货币模式", - helpText: "dynamic(默认):字段本身没有货币,金额以租户默认货币显示(localization.currency 设置)。fixed:字段只有一种货币,即 defaultCurrency。" + helpText: "dynamic(默认):字段本身没有货币,金额以租户默认货币显示(localization.currency 设置)。fixed:字段只有一种货币,即 defaultCurrency。record:每条记录有自己的货币,保存在 currencyField 指向的字段中。" }, "currencyConfig.currencyField": { label: "币种字段", @@ -630,7 +630,7 @@ export const zhCNMetadataForms: NonNullable = }, "currencyConfig.defaultCurrency": { label: "默认货币", - helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取。" + helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取;record 模式下不允许设置。" }, step: { label: "步进值", diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 1ad99d89dba..ec717d59816 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -107,7 +107,7 @@ export const fieldForm = defineForm({ field: 'currencyConfig', type: 'composite', visibleWhen: "data.type == 'currency' || (data.type == 'formula' && data.returnType == 'currency')", - helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode.', + helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in every mode.', fields: [ { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names.', options: [ { label: 'Dynamic — the tenant default currency', value: 'dynamic' }, @@ -116,7 +116,7 @@ export const fieldForm = defineForm({ ] }, // ADR-0144 D2 — the record-mode pointer. { field: 'currencyField', type: 'text', helpText: 'Record mode only, and required there: the field on this object holding each record\'s ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code.' }, - { field: 'defaultCurrency', type: 'text', helpText: 'The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode.' }, + { field: 'defaultCurrency', type: 'text', helpText: 'The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode; refused in record mode.' }, ], }, // Every `visibleWhen` below is a MEANINGFULNESS gate, not a parse gate: diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index bbdd811bb92..2e9cf5472ed 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -174,15 +174,15 @@ Stored as JSON on the parent row — no separate table / FK: type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, // ISO 4217 } +// per record: { currencyMode: 'record', currencyField: 'currency_code' } ``` -**Currency resolution.** The value is a bare number in every mode (ADR-0104 -D1), never `{ value, currency }`. Shown: `'fixed'`, `defaultCurrency`; -`'dynamic'` (default), the tenant `localization.currency`, not -`defaultCurrency`, else a plain number (never `$`); `'record'`, each record's -code in the `text` field `currencyField` names, which declares `valueDomain: -'iso_4217_currency'` (names by convention, not contract: `currency_code`, -`AMOUNT_home`, `AMOUNT_rate`). A measure's own `currency` wins. +**Currency resolution.** The value is a bare number in every mode, never +`{ value, currency }`. Shown: `'fixed'`, `defaultCurrency`; `'dynamic'` +(default), the tenant `localization.currency`, else a plain number (never +`$`); `'record'`, each record's code in the `text` field `currencyField` +names, declaring `valueDomain: 'iso_4217_currency'`. Under +`'fixed'`/`'dynamic'` a measure's own `currency` wins. ### Select with Default From 2749517c90c600dafa86e7574b35677a57461b13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:09:45 +0000 Subject: [PATCH 20/21] =?UTF-8?q?fix(objectql):=20the=20validate()=20previ?= =?UTF-8?q?ew=20refuses=20a=20blank=20record-currency=20code;=20Clause-?= =?UTF-8?q?=E2=91=A1=20declared=20as=20a=20widening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insert and by-id update previews now judge the write's blank-code refusal on the write's own image (the insert row, or the stored row with the patch over it) and answer it as the row's ERR_CURRENCY_CODE_REQUIRED finding, so a dry run no longer passes a row the write refuses (changeset 26684's parity). The changeset declares Clause-②: yes (widening): the ledger gains a registered code and the formula rider widens the exported FormulaReturnDeclaration. The ledger comment says the write is never asked, not the driver. Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- .changeset/27186-record-currency-guards.md | 4 +-- packages/objectql/src/engine-validate.ts | 25 ++++++++++++++ .../src/record-currency-guards.test.ts | 33 +++++++++++++++++-- .../spec/src/api/error-code-ledger.zod.ts | 2 +- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/.changeset/27186-record-currency-guards.md b/.changeset/27186-record-currency-guards.md index 477a65e4efe..e70c832ae91 100644 --- a/.changeset/27186-record-currency-guards.md +++ b/.changeset/27186-record-currency-guards.md @@ -6,10 +6,10 @@ feat(objectql): a record-currency amount is refused without its code, refused in an ungrouped raw aggregate, and refused under a summary roll-up -Clause-②: no +Clause-②: yes (widening) Metadata-protocol: none -- **A `record`-mode amount is stored with its currency code (ADR-0144 D8 rule 4).** An insert or update that would store a value in a `currency` field declaring `currencyConfig: { currencyMode: 'record', currencyField }` while that code field is blank (absent, `null` or white space) is refused `ERR_CURRENCY_CODE_REQUIRED` / 400, with `field` naming the code field and `amountField` the amount. The server never fills a currency in. On an update, only a write that carries the amount or its code is judged, against the stored row with the payload laid over it, so an edit of another field never meets it. The code is registered under `@objectstack/objectql` in the ADR-0112 error-code ledger. +- **A `record`-mode amount is stored with its currency code (ADR-0144 D8 rule 4).** An insert or update that would store a value in a `currency` field declaring `currencyConfig: { currencyMode: 'record', currencyField }` while that code field is blank (absent, `null` or white space) is refused `ERR_CURRENCY_CODE_REQUIRED` / 400, with `field` naming the code field and `amountField` the amount. The server never fills a currency in. On an update, only a write that carries the amount or its code is judged, against the stored row with the payload laid over it, so an edit of another field never meets it. The `validate()` preview (`validateData`) answers the same refusal as the row's finding for an insert and for a by-id update, so a dry run does not pass a row the write refuses. The code is registered under `@objectstack/objectql` in the ADR-0112 error-code ledger. - **A raw aggregate over a `record`-mode amount groups by its code field (ADR-0144 D6).** `sum`, `avg`, `min` and `max` over such a field through `engine.aggregate` are refused `INVALID_FIELD` / 400 unless `groupBy` names the code field; grouped, each currency totals on its own. `count` and `count_distinct` are not judged. Before, USD 100 and EUR 100 summed to 200 under one label. - **A `summary` roll-up over a `record`-mode amount is refused at registration (ADR-0144 D6).** `sum` / `avg` / `min` / `max` of such a child field is refused `INVALID_METADATA` / 422 (`RecordCurrencySummaryRefusedError`) at whichever object's registration completes the pair. Before, child rows of USD 100 and JPY 15000 rolled up to 15100. Use `count`, or roll up a stored home-currency amount. - **`inferFormulaReturn` carries a record currency (`@objectstack/formula`).** A formula over a `record`-mode amount now declares `returnType: 'currency'` with `currencyConfig: { currencyMode: 'record', currencyField }`, the code field its sources name, instead of a tenant-default (`dynamic`) amount. Two record-mode amounts naming different code fields, or a record-mode amount meeting a `fixed` or `dynamic` one, are not one currency and are not stamped as money. diff --git a/packages/objectql/src/engine-validate.ts b/packages/objectql/src/engine-validate.ts index 5939b489c3b..7659524e34f 100644 --- a/packages/objectql/src/engine-validate.ts +++ b/packages/objectql/src/engine-validate.ts @@ -50,6 +50,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStringValues, ValidationError, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js'; import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js'; +import { assertRecordCurrencyCodes, RecordCurrencyCodeRequiredError } from './validation/record-currency-code.js'; import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js'; import { droppedFieldEvents, @@ -393,11 +394,15 @@ export async function validate( // `undefined` = no stored row: the row is judged on the patch alone, as // it was before these reads existed. const storedRows: Array | undefined> = []; + // The by-id address each stored row was read from, for a refusal that names the record. + const storedIds: unknown[] = []; for (const submitted of submittedRows) { let stored: Record | undefined; + let storedId: unknown; if (mode === 'update' && submitted && typeof submitted === 'object') { const dispatch = resolveEngineUpdateDispatch(submitted as EngineUpdateDispatchData, undefined); if (dispatch.kind === 'by-id') { + storedId = dispatch.id; const visible = await this.findOne(object, { where: { id: dispatch.id }, context: { ...(options?.context ?? {}), [RAW_FILE_VALUES_CONTEXT_KEY]: true }, @@ -417,6 +422,7 @@ export async function validate( } } storedRows.push(stored); + storedIds.push(storedId); } // The merged view each rule evaluates, and the one the reference reads // below key on: the write reads a traversed FK off the same merge @@ -543,6 +549,21 @@ export async function validate( // preview admits exactly what the write would. keptOptionValues: options?.context?.keptOptionValues, }); + // [ADR-0144 D8 rule 4] The write's blank-code refusal, at the write's + // step and on the write's image: the insert row as stored, or the stored + // row with the patch over it (`judgedViews`, the by-id update's + // `priorRecord ⊕ payload`), judging only the pairs the patch touches. + // An update with no stored row here (a predicate dispatch, an id this + // caller cannot read) is not judged: the image the write judges is a + // row the preview does not hold. A code column the read door withholds + // from this caller is absent from the stored row, so the preview reads + // it as blank — stricter than the write, never laxer. + if (mode === 'insert' || storedRows[i]) { + assertRecordCurrencyCodes( + object, (schemaForValidation as { fields?: unknown } | undefined)?.fields, + mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i], + ); + } warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { // [#22445] The stored row, when this row named one: the by-id // update's `previous: priorRecord`. Absent, the record is the patch. @@ -571,6 +592,10 @@ export async function validate( if (e instanceof ValidationError) { return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; } + // The write throws this refusal whole; the preview answers it as the row's finding. + if (e instanceof RecordCurrencyCodeRequiredError) { + return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings }; + } throw e; } batchSummaries?.accept(row); diff --git a/packages/objectql/src/record-currency-guards.test.ts b/packages/objectql/src/record-currency-guards.test.ts index c41a2c76f5d..926ef929c30 100644 --- a/packages/objectql/src/record-currency-guards.test.ts +++ b/packages/objectql/src/record-currency-guards.test.ts @@ -13,9 +13,10 @@ // The fixture is the measurement round's `fx_invoice` (epic #8345, comment // 6041378776), with the sibling code field DECLARED as the record currency. // Every refusal here precedes the driver, so the driver is the rows-shaped -// in-memory double the roll-up suite uses; the same probes on driver-memory and -// on SqlDriver over better-sqlite3 are packages/qa/dogfood's -// `record-currency-probes.dogfood.test.ts`. +// in-memory double the roll-up suite uses; the same probes on SqlDriver over +// better-sqlite3 and on SqliteWasmDriver are packages/qa/dogfood's +// `test/record-currency-probes.test.ts`. The write's blank-code refusal is +// also the `validate()` preview's finding (changeset 26684's parity). import { describe, it, expect, beforeEach } from 'vitest'; import type { EngineAggregateOptions, EngineUpdateOptions } from '@objectstack/spec/data'; @@ -195,6 +196,32 @@ describe('a record-mode amount is stored with its currency code (ERR_CURRENCY_CO await engine.update('fx_invoice', { amount: 9, currency_code: 'EUR' }, BATCH); expect(storeFor('fx_invoice').get(b.id)).toMatchObject({ amount: 9, currency_code: 'EUR' }); }); + it('validate(): the insert and by-id update previews refuse what the write refuses, as the row\'s finding', async () => { + const insertPreview = await engine.validate('fx_invoice', [ + { title: 'p1', amount: 100 }, + { title: 'p2', amount: 100, currency_code: 'USD' }, + { title: 'p3' }, + ]); + expect(insertPreview.valid).toBe(false); + expect(insertPreview.results?.map((r) => r.valid)).toEqual([false, true, true]); + expect(insertPreview.results?.[0]?.errors).toEqual([ + { field: 'currency_code', code: 'ERR_CURRENCY_CODE_REQUIRED', message: expect.stringContaining("'currency_code' is blank") }, + ]); + + const withCode = await engine.insert('fx_invoice', { title: 'i1', amount: 100, currency_code: 'USD' }); + storeFor('fx_invoice').set('legacy', { id: 'legacy', title: 'old', amount: 100, currency_code: null }); + const cleared = await engine.validate('fx_invoice', { id: withCode.id, currency_code: null }, { mode: 'update' }); + expect(cleared.valid).toBe(false); + expect(cleared.results?.[0]?.errors).toEqual([ + { field: 'currency_code', code: 'ERR_CURRENCY_CODE_REQUIRED', message: expect.stringContaining(`(record ${withCode.id})`) }, + ]); + + // The controls, answered as the write answers them: the stored code covers + // a new amount, and a codeless legacy row's unrelated edit is not judged. + expect((await engine.validate('fx_invoice', { id: withCode.id, amount: 250 }, { mode: 'update' })).valid).toBe(true); + expect((await engine.validate('fx_invoice', { id: 'legacy', title: 'renamed' }, { mode: 'update' })).valid).toBe(true); + expect(storeFor('fx_invoice').get(withCode.id)).toMatchObject({ amount: 100, currency_code: 'USD' }); + }); }); describe('a raw aggregate over a record-mode amount groups by its code field (PROBE-A, PROBE-C)', () => { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 73d17500a9e..a5989ecb7e9 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -786,7 +786,7 @@ export const ERROR_CODE_LEDGER = { 'ERR_CROSS_DATASOURCE_TRANSACTION_WRITE', // [ADR-0144 D8 rule 4, Open item 9] A write would store a `record`-mode // currency amount beside a blank currency code — a number in no currency. - // 400, refused before the driver is asked (`RecordCurrencyCodeRequiredError`, + // 400; the driver is never asked to write the row (`RecordCurrencyCodeRequiredError`, // `validation/record-currency-code.ts`); `field` names the code field to // fill. The server never fills a currency in. 'ERR_CURRENCY_CODE_REQUIRED', From 45e6b2614f93a4a5d014e2341ad5d5f0c8ed74ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:46:50 +0000 Subject: [PATCH 21/21] chore(spec): regenerate artifacts after bringing main in The authorable-surface baseline is taken from main and rebuilt, keeping `currencyConfig.currencyField`; the API reference pages are regenerated from the merged tree. Claude-Session: https://claude.ai/code/session_01Utp6CboxRwQPbQZnhxyuzt Co-authored-by: Claude --- .../docs/references/api/automation-api.mdx | 4 ++- content/docs/references/api/contract.mdx | 4 ++- .../docs/references/api/error-code-ledger.mdx | 2 ++ packages/spec/authorable-surface/data.json | 35 ------------------- 4 files changed, 8 insertions(+), 37 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 466ae9f9d7e..5ce1f5c4517 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +348 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +350 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -497,9 +497,11 @@ const result = AutomationApiErrorCode.parse(data); * `EXPORT_NOT_PERMITTED` * `EXTERNAL_DATASOURCE_ERROR` * `EXTERNAL_IMPORT_ERROR` +* `EXTERNAL_ROW_CAP_EXCEEDED` * `EXTERNAL_SCHEMA_MISMATCH` * `EXTERNAL_SCHEMA_MODE_VIOLATION` * `EXTERNAL_WRITE_FORBIDDEN` +* `EXTERNAL_WRITE_REFUSED` * `FEEDS_DISABLED` * `FIELD_VISIBILITY_UNRESOLVED` * `FILES_DISABLED` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 4350ce4e56a..9ddc208b598 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +348 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +350 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -185,9 +185,11 @@ const result = ApiErrorSchema.parse(data); * `EXPORT_NOT_PERMITTED` * `EXTERNAL_DATASOURCE_ERROR` * `EXTERNAL_IMPORT_ERROR` +* `EXTERNAL_ROW_CAP_EXCEEDED` * `EXTERNAL_SCHEMA_MISMATCH` * `EXTERNAL_SCHEMA_MODE_VIOLATION` * `EXTERNAL_WRITE_FORBIDDEN` +* `EXTERNAL_WRITE_REFUSED` * `FEEDS_DISABLED` * `FIELD_VISIBILITY_UNRESOLVED` * `FILES_DISABLED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 0a0c7e98430..6a0021e1ae4 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -352,9 +352,11 @@ const result = ErrorCode.parse(data); * `EXPORT_NOT_PERMITTED` * `EXTERNAL_DATASOURCE_ERROR` * `EXTERNAL_IMPORT_ERROR` +* `EXTERNAL_ROW_CAP_EXCEEDED` * `EXTERNAL_SCHEMA_MISMATCH` * `EXTERNAL_SCHEMA_MODE_VIOLATION` * `EXTERNAL_WRITE_FORBIDDEN` +* `EXTERNAL_WRITE_REFUSED` * `FEEDS_DISABLED` * `FIELD_VISIBILITY_UNRESOLVED` * `FILES_DISABLED` diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 7d807145820..ed9e5f57555 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -108,36 +108,13 @@ "data/DataEngineAggregateOptions:context", "data/DataEngineAggregateOptions:filter", "data/DataEngineAggregateOptions:groupBy", - "data/DataEngineAggregateRequest:method", - "data/DataEngineAggregateRequest:object", - "data/DataEngineAggregateRequest:query", "data/DataEngineCountOptions:context", "data/DataEngineCountOptions:filter", - "data/DataEngineCountRequest:method", - "data/DataEngineCountRequest:object", - "data/DataEngineCountRequest:query", "data/DataEngineDeleteOptions:context", "data/DataEngineDeleteOptions:filter", "data/DataEngineDeleteOptions:multi", - "data/DataEngineDeleteRequest:id", - "data/DataEngineDeleteRequest:method", - "data/DataEngineDeleteRequest:object", - "data/DataEngineDeleteRequest:options", - "data/DataEngineExecuteRequest:command", - "data/DataEngineExecuteRequest:method", - "data/DataEngineExecuteRequest:options", - "data/DataEngineFindOneRequest:method", - "data/DataEngineFindOneRequest:object", - "data/DataEngineFindOneRequest:query", - "data/DataEngineFindRequest:method", - "data/DataEngineFindRequest:object", - "data/DataEngineFindRequest:query", "data/DataEngineInsertOptions:context", "data/DataEngineInsertOptions:returning", - "data/DataEngineInsertRequest:data", - "data/DataEngineInsertRequest:method", - "data/DataEngineInsertRequest:object", - "data/DataEngineInsertRequest:options", "data/DataEngineQueryOptions:context", "data/DataEngineQueryOptions:filter", "data/DataEngineQueryOptions:limit", @@ -151,18 +128,6 @@ "data/DataEngineUpdateOptions:multi", "data/DataEngineUpdateOptions:returning", "data/DataEngineUpdateOptions:upsert [RETIRED]", - "data/DataEngineUpdateRequest:data", - "data/DataEngineUpdateRequest:id", - "data/DataEngineUpdateRequest:method", - "data/DataEngineUpdateRequest:object", - "data/DataEngineUpdateRequest:options", - "data/DataEngineVectorFindRequest:fields", - "data/DataEngineVectorFindRequest:limit", - "data/DataEngineVectorFindRequest:method", - "data/DataEngineVectorFindRequest:object", - "data/DataEngineVectorFindRequest:threshold", - "data/DataEngineVectorFindRequest:vector", - "data/DataEngineVectorFindRequest:where", "data/DataTypeMapping:binary", "data/DataTypeMapping:boolean", "data/DataTypeMapping:date",