From 59ac9e812c6c214671085823c95568b13d91e926 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:54:55 +0000 Subject: [PATCH 01/12] =?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/12] 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/12] 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/12] =?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/12] 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 a9f5abfd1b18735bf378a5965ad4a558e8a0b6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:17:49 +0000 Subject: [PATCH 06/12] 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 da1ebc7b4ee160abfb179d77e003149d9b8751c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:09:08 +0000 Subject: [PATCH 07/12] =?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 08/12] 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 09/12] 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 10/12] 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 f3d286ce170a1158ec1a6e0ee06f706ab1247aeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:22:30 +0000 Subject: [PATCH 11/12] 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 12/12] 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