diff --git a/.changeset/27191-home-amount-pointer.md b/.changeset/27191-home-amount-pointer.md new file mode 100644 index 00000000000..8661f5dcfc0 --- /dev/null +++ b/.changeset/27191-home-amount-pointer.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': minor +'@objectstack/skills': minor +--- + +feat(spec): `currencyConfig.homeAmount: { field, rateDate, rateField }` — a record-mode amount declares its stored home-currency amount by pointer (ADR-0144 D5) + +Clause-②: yes (widening) +Metadata-protocol: added + +⛔ Not released alone: this ships in the same release as the write-time home-currency projection that fills these fields (ADR-0144 D9, release R2: "the `homeAmount` key never ships ahead of the engine"). + +An amount in `currencyMode: 'record'` can now declare where its value is stored after conversion into the organization's home currency (the `localization.currency` setting), which date picks the exchange rate, and where the rate used is stored. Consolidated totals then aggregate the home field like any other stored currency field. + +```ts +fields: { + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + invoice_date: Field.date({ label: 'Invoice date' }), + amount: Field.currency({ + label: 'Amount', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }), + amount_home: Field.currency({ label: 'Amount (home)', readonly: true }), + amount_rate: Field.number({ label: 'Rate used', readonly: true }), +} +``` + +- **Three required pointers.** When `homeAmount` is declared, `field`, `rateDate` and `rateField` must all be given, and none may be empty. There is no default, no injected column and no fixed field name. The document date is never taken from `created_at`. `homeAmount` is refused under `dynamic` and `fixed`. +- **What the pointers name, refused at parse with a remedy and the object's candidates.** Each refusal is located at `fields.AMOUNT.currencyConfig.homeAmount.POINTER`. + - `field` must name another `currency` field of the same object in `dynamic` mode (it displays in the home currency, so metadata never names that currency). No other amount may already store into it. + - `rateDate` must name a `date` or `datetime` field. + - `rateField` must name a `number` field. Two amounts may share it only when they have the same `currencyField` and `rateDate`. + - The amount itself must be a stored `currency` field. A `formula` amount is refused. +- **The home and rate fields are written by the platform only.** They must declare `readonly: true`, the existing mark that drops a client's write of the field, and no `expression`. +- **Skills:** `objectstack-data/rules/field-types.md` teaches `homeAmount`. Its field names (`amount_home`, `amount_rate`) are examples, not a contract. +- **Nothing to migrate.** `homeAmount` is new, and no existing config changes meaning. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 542713f20b7..c8756a07779 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -31,6 +31,15 @@ const result = CurrencyConfigSchema.parse(data); | **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. | +| **homeAmount** | `{ field: string; rateDate: string; rateField: string }` | optional | `record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. | + +### Nested Shape: `CurrencyConfig.homeAmount` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field on the same object that stores the amount converted into the home currency: a stored `currency` field in `dynamic` mode (it displays in `localization.currency`, so metadata never names the home currency), declaring `readonly: true` — only the engine writes it. Conventionally `AMOUNT_home`. | +| **rateDate** | `string` | ✅ | The field on the same object holding the record's document date, whose value picks the exchange rate: a `date` or `datetime` field. Required, with no default — the engine never guesses the date (not `created_at`). | +| **rateField** | `string` | ✅ | The field on the same object that stores the exchange rate the conversion used: a `number` field declaring `readonly: true` — only the engine writes it. Required: the home amount is stored with the rate used. Conventionally `AMOUNT_rate`. | --- @@ -92,7 +101,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' \| '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. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | 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") | @@ -250,6 +259,7 @@ const result = CurrencyConfigSchema.parse(data); | **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. | +| **homeAmount** | `{ field: string; rateDate: string; rateField: string }` | optional | `record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. 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 7fbdc69ae59..f44f781b478 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -270,7 +270,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' \| '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. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | 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") | @@ -636,7 +636,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' \| '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. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | 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 3628ded946c..04dfb509d58 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -104,7 +104,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' \| '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. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | 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") | @@ -537,7 +537,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' \| '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. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | 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/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 4af374d9628..a77032c64eb 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 155 | 89 | 2 | 0 | 64 | +| `data/` | 156 | 90 | 2 | 0 | 64 | ## `data/` — sites @@ -45,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | -| `field.zod.ts` | 14 | +| `field.zod.ts` | 15 | | `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 8 | @@ -57,7 +57,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **155** | +| **total** | **156** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**64 strip of 155**, in 10 file(s). +**64 strip of 156**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -74,12 +74,12 @@ over it is here. | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 1 | 14 | +| `field.zod.ts` | 1 | 15 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **155** | +| **total** | **64** | **156** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index be518ea52e0..d04a1e830f1 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -104,6 +104,7 @@ "data/CurrencyConfig:currencyField", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", + "data/CurrencyConfig:homeAmount", "data/DataEngineAggregateOptions:aggregations", "data/DataEngineAggregateOptions:context", "data/DataEngineAggregateOptions:filter", diff --git a/packages/spec/src/data/currency-config-home-amount.test.ts b/packages/spec/src/data/currency-config-home-amount.test.ts new file mode 100644 index 00000000000..042df547568 --- /dev/null +++ b/packages/spec/src/data/currency-config-home-amount.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D5] `currencyConfig.homeAmount: { field, rateDate, rateField }` — + * a `record`-mode amount declares, by pointer, where the engine stores it + * converted into the home currency, which date picks the rate, and where the + * rate used is stored. + * + * Two doors, as for `currencyField`: `CurrencyConfigSchema` refuses what one + * config can see (`homeAmount` outside `record`, a missing, empty or unknown + * pointer), and the object's `superRefine` refuses what the pointers name. + * The home and rate fields are platform-written, marked with the existing + * `readonly: true`. + */ + +import { describe, it, expect } from 'vitest'; + +import { CurrencyConfigSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +const HOME = { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' } as const; +const RECORD = { currencyMode: 'record', currencyField: 'currency_code', homeAmount: HOME } as const; + +/** The fields every accepted invoice carries; a case overrides one of them. */ +const BASE_FIELDS = { + currency_code: { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' }, + invoice_date: { type: 'date', label: 'Invoice date' }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + amount_home: { type: 'currency', label: 'Amount (home)', readonly: true }, + amount_rate: { type: 'number', label: 'Rate used', readonly: true }, +} as const; + +function invoice(overrides: Record = {}): Record { + const fields: Record = { ...BASE_FIELDS, ...overrides }; + for (const [k, v] of Object.entries(fields)) if (v === undefined) delete fields[k]; + 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: string; message: string }> { + const r = ObjectSchema.safeParse(input); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +} + +const AT = 'fields.amount.currencyConfig.homeAmount'; + +describe('`CurrencyConfigSchema`: `homeAmount` under `record` only, three required pointers', () => { + it('accepts a `record` config with all three pointers, keeping them in the parse output', () => { + const r = CurrencyConfigSchema.safeParse(RECORD); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.homeAmount).toEqual(HOME); + expect(CurrencyConfigSchema.parse(r.data)).toEqual(r.data); + }); + + it('a `record` config without `homeAmount` parses as before, with no key added', () => { + const r = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(JSON.stringify(r)).toBe('{"currencyMode":"record","currencyField":"currency_code"}'); + }); + + it.each([ + ['fixed', { currencyMode: 'fixed', defaultCurrency: 'USD' }], + ['dynamic', { currencyMode: 'dynamic' }], + ['dynamic (defaulted)', {}], + ] as const)('refuses `homeAmount` under `%s`, at the key, with the remedy', (_mode, config) => { + const r = CurrencyConfigSchema.safeParse({ ...config, homeAmount: HOME }); + 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(['homeAmount']); + expect(r.error.issues[0]!.message).toMatch(/^`homeAmount` is read only under `currencyMode: 'record'`/); + }); + + it.each(['field', 'rateDate', 'rateField'] as const)('refuses a `homeAmount` without `%s` (every pointer is required)', (key) => { + const { [key]: _dropped, ...rest } = HOME; + const r = CurrencyConfigSchema.safeParse({ ...RECORD, homeAmount: rest }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual([`homeAmount.${key}`]); + expect(r.error.issues[0]!.code).toBe('invalid_type'); + expect(r.error.issues[0]!.message).toMatch(new RegExp(`^\`homeAmount\` needs \`${key}\`, the name of `)); + }); + + it.each(['field', 'rateDate', 'rateField'] as const)('refuses an empty `homeAmount.%s`, at the pointer', (key) => { + const r = CurrencyConfigSchema.safeParse({ ...RECORD, homeAmount: { ...HOME, [key]: '' } }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual([`homeAmount.${key}`]); + expect(r.error.issues[0]!.message).toMatch(new RegExp(`^\`homeAmount\\.${key}\` names a field: an empty name points at nothing`)); + }); + + it('refuses an unknown key inside `homeAmount`, steering a near-miss to the declared pointer', () => { + const r = CurrencyConfigSchema.safeParse({ ...RECORD, homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rate: 'amount_rate' } }); + expect(r.success).toBe(false); + if (r.success) return; + const paths = r.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('homeAmount.rateField'); + const unknown = r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown?.message).toContain('rateField'); + }); +}); + +describe('the object judges what `homeAmount` names', () => { + it('accepts the honest shape: a readonly `dynamic` home field, a date, a readonly number rate field', () => { + expect(objectIssues(invoice())).toEqual([]); + // A `datetime` document date and a home field with an explicit `dynamic` config are honest too. + expect(objectIssues(invoice({ + invoice_date: { type: 'datetime', label: 'Issued at' }, + amount_home: { type: 'currency', label: 'Amount (home)', readonly: true, currencyConfig: { currencyMode: 'dynamic' } }, + }))).toEqual([]); + const once = ObjectSchema.parse(invoice()); + expect(ObjectSchema.parse(once)).toEqual(once); + }); + + it('two amounts with the same code field and document date may share one rate field', () => { + expect(objectIssues(invoice({ + tax: { type: 'currency', label: 'Tax', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'tax_home' } } }, + tax_home: { type: 'currency', label: 'Tax (home)', readonly: true }, + }))).toEqual([]); + }); + + describe('`homeAmount.field`', () => { + it('refuses a pointer into nothing, naming the candidates', () => { + const issues = objectIssues(invoice({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'home' } } }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`${AT}.field`); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.field` names `home`, but object `invoice` declares no other field `home`/); + expect(issues[0]!.message).toContain('candidates: `amount_home`'); + }); + + it('refuses a pointer at the amount itself', () => { + const issues = objectIssues(invoice({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'amount' } } }, + })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.field`]); + expect(issues[0]!.message).toContain('declares no other field `amount`'); + }); + + it('refuses a pointer at a field that is not `currency` (a `number`, a currency `formula`)', () => { + for (const home of [ + { type: 'number', label: 'Amount (home)', readonly: true }, + { type: 'formula', label: 'Amount (home)', expression: 'record.amount', returnType: 'currency' }, + ]) { + const issues = objectIssues(invoice({ amount_home: home })); + expect(issues.map((i) => i.path), home.type).toEqual([`${AT}.field`]); + expect(issues[0]!.message).toMatch(new RegExp(`^\`amount\\.currencyConfig\\.homeAmount\\.field\` names \`amount_home\`, a \`${home.type}\` field`)); + } + }); + + it.each([ + ['fixed', { currencyMode: 'fixed', defaultCurrency: 'CNY' }], + ['record', { currencyMode: 'record', currencyField: 'currency_code' }], + ] as const)('refuses a `%s`-mode home field: metadata never names the home currency', (mode, currencyConfig) => { + const issues = objectIssues(invoice({ amount_home: { type: 'currency', label: 'Amount (home)', readonly: true, currencyConfig } })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.field`]); + expect(issues[0]!.message).toMatch(new RegExp(`^\`amount\\.currencyConfig\\.homeAmount\\.field\` names \`amount_home\`, a \`${mode}\`-mode currency field`)); + }); + + it('refuses a home field another amount already stores into', () => { + const issues = objectIssues(invoice({ + tax: { type: 'currency', label: 'Tax', currencyConfig: RECORD }, + })); + expect(issues.map((i) => i.path)).toEqual(['fields.tax.currencyConfig.homeAmount.field']); + expect(issues[0]!.message).toContain('already the home field of `amount`'); + }); + }); + + describe('`homeAmount.rateDate`', () => { + it('refuses a pointer into nothing, naming the date candidates', () => { + const issues = objectIssues(invoice({ + amount: { type: 'currency', label: 'Amount', currencyConfig: { ...RECORD, homeAmount: { ...HOME, rateDate: 'doc_date' } } }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`${AT}.rateDate`); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateDate` names `doc_date`, but object `invoice` declares no field `doc_date`/); + expect(issues[0]!.message).toContain('candidates: `invoice_date`'); + }); + + it('refuses a pointer at a field that is not `date` / `datetime`, and says nothing defaults to `created_at`', () => { + const issues = objectIssues(invoice({ invoice_date: { type: 'text', label: 'Invoice date' } })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.rateDate`]); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateDate` names `invoice_date`, a `text` field/); + expect(issues[0]!.message).toContain('nothing defaults to `created_at`'); + }); + }); + + describe('`homeAmount.rateField`', () => { + it('refuses a pointer into nothing', () => { + const issues = objectIssues(invoice({ amount_rate: undefined })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toBe(`${AT}.rateField`); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateField` names `amount_rate`, but object `invoice` declares no field `amount_rate`/); + expect(issues[0]!.message).toContain('the object has none'); + }); + + it('refuses a pointer at a field that is not `number`', () => { + const issues = objectIssues(invoice({ amount_rate: { type: 'currency', label: 'Rate used', readonly: true } })); + expect(issues.map((i) => i.path)).toEqual([`${AT}.rateField`]); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount\.rateField` names `amount_rate`, a `currency` field/); + }); + + it('refuses a rate field shared with an amount that names another document date', () => { + const issues = objectIssues(invoice({ + due_date: { type: 'date', label: 'Due date' }, + tax: { type: 'currency', label: 'Tax', currencyConfig: { ...RECORD, homeAmount: { ...HOME, field: 'tax_home', rateDate: 'due_date' } } }, + tax_home: { type: 'currency', label: 'Tax (home)', readonly: true }, + })); + expect(issues.map((i) => i.path)).toEqual(['fields.tax.currencyConfig.homeAmount.rateField']); + expect(issues[0]!.message).toContain('already stores the rate of `amount`'); + }); + }); + + describe('the home and rate fields are platform-written (`readonly: true`, no `expression`)', () => { + it.each(['amount_home', 'amount_rate'] as const)('refuses `%s` without `readonly: true`', (name) => { + const issues = objectIssues(invoice({ [name]: { ...BASE_FIELDS[name], readonly: false } })); + const key = name === 'amount_home' ? 'field' : 'rateField'; + expect(issues.map((i) => i.path)).toEqual([`${AT}.${key}`]); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toContain('does not declare `readonly: true`'); + expect(issues[0]!.message).toContain(`Add \`readonly: true\` to \`${name}\``); + }); + + it.each(['amount_home', 'amount_rate'] as const)('refuses `%s` declaring an `expression`', (name) => { + const issues = objectIssues(invoice({ [name]: { ...BASE_FIELDS[name], expression: 'record.amount' } })); + const key = name === 'amount_home' ? 'field' : 'rateField'; + expect(issues.map((i) => i.path)).toEqual([`${AT}.${key}`]); + expect(issues[0]!.message).toContain('declares an `expression`'); + }); + }); + + it('refuses `homeAmount` on a `formula` amount, at `homeAmount`, judging no pointer', () => { + const issues = objectIssues(invoice({ + amount: { type: 'formula', label: 'Net', expression: 'record.gross * 0.9', returnType: 'currency', currencyConfig: RECORD }, + gross: { type: 'currency', label: 'Gross' }, + amount_rate: undefined, + })); + expect(issues.map((i) => i.path)).toEqual([AT]); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.homeAmount` is declared on a `formula` field of object `invoice`, which computes its value on read/); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 570bb6f57de..8fee086c249 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -545,9 +545,23 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> * missing key; what the pointer NAMES is the object's to judge (object.zod.ts). */ function refuseRecordModeKeyMismatch( - config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }, + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string; homeAmount?: unknown }, ctx: z.RefinementCtx, ): void { + // [ADR-0144 D5] `homeAmount` converts a record's own currency into the home + // currency, so it has a reader only under `record`: a `fixed` amount has one + // currency for every row and a `dynamic` amount is already in the home + // currency, so the engine converts neither and the declaration would be inert. + if (config.currencyMode !== 'record' && config.homeAmount !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['homeAmount'], + message: + `\`homeAmount\` is read only under \`currencyMode: 'record'\`, and this config is \`${config.currencyMode}\`: ` + + 'only an amount whose records each carry their own currency is converted into the home currency. ' + + 'Set `currencyMode: \'record\'` with its `currencyField`, or delete `homeAmount`.', + }); + } if (config.currencyMode === 'record' && config.defaultCurrency !== undefined) { ctx.addIssue({ code: 'custom', @@ -589,6 +603,58 @@ const CURRENCY_CONFIG_FIXED_WITHOUT_CODE = + 'to display amounts in the tenant default currency (the `localization.currency` setting). ' + 'Since @objectstack/spec 18 nothing fills a code in; it used to be `\'CNY\'`.'; +/** The message an empty `homeAmount` pointer meets, naming the pointer and what it must name. */ +const homeAmountEmptyPointer = (key: string, names: string): string => + `\`homeAmount.${key}\` names a field: an empty name points at nothing — name ${names} on this object.`; + +/** + * The message a missing (or non-string) `homeAmount` pointer meets. ADR-0144 + * D5 requires all three whenever `homeAmount` is declared, and nothing fills + * one in, so the remedy is to name the field or to drop the declaration. + */ +const homeAmountPointerRequired = (key: string, names: string): (() => string) => () => + `\`homeAmount\` needs \`${key}\`, the name of ${names} on this object: the engine has no default for it. ` + + 'Name the field, or delete `homeAmount` if the amount needs no stored home-currency projection.'; + +/** + * [ADR-0144 D5] `homeAmount` — a `record`-mode amount's stored home-currency + * projection, declared by three pointers into the same object. At write the + * engine converts the amount at the rate of the record's document date and + * stores the result in `field` and the rate it used in `rateField` (ruling + * 6465431966 D1: "STORED with the rate used"). Every pointer is required: D8 + * rule 2 lets every currency-related field exist only as an author-declared + * field referenced by pointer, so the engine has no default to fall back on — + * no injected column, no fixed name, and no `created_at` as the document + * date. The names `rateDate` / `rateField` are this card's spelling (ADR-0144 + * Open item 1); the ADR corrects the drafted optional `rateField?` to required. + * + * This shape sees only names. What each one names — a `dynamic`-mode stored + * `currency` field, a `date` / `datetime` field, a `number` field, the home + * and rate fields `readonly` — is the object's to judge + * (`refuseDishonestHomeAmount`, object.zod.ts), because only the object holds + * the field map. Nothing reads the declaration until the engine's write-time + * projection ships: the key ships in the same release as that engine (ADR-0144 + * D9, release R2). + */ +const HomeAmountSchema = strictObject({ + surface: "this amount's `homeAmount` declaration", + history: + 'This shape was closed from the day it was declared — an unknown key here was never accepted, so a ' + + 'misspelled pointer cannot parse and leave the home amount, its date or its rate unwritten.', + aliases: { + homeField: 'field', target: 'field', amountField: 'field', + date: 'rateDate', dateField: 'rateDate', documentDate: 'rateDate', + rate: 'rateField', exchangeRate: 'rateField', rateUsed: 'rateField', + }, +}, { + field: z.string({ error: homeAmountPointerRequired('field', 'the `dynamic`-mode `currency` field that stores the home amount') }).min(1, homeAmountEmptyPointer('field', 'the `dynamic`-mode `currency` field that stores the home amount')) + .describe('The field on the same object that stores the amount converted into the home currency: a stored `currency` field in `dynamic` mode (it displays in `localization.currency`, so metadata never names the home currency), declaring `readonly: true` — only the engine writes it. Conventionally `AMOUNT_home`.'), + rateDate: z.string({ error: homeAmountPointerRequired('rateDate', 'the `date` / `datetime` field whose value picks the rate') }).min(1, homeAmountEmptyPointer('rateDate', 'the `date` / `datetime` field whose value picks the rate')) + .describe('The field on the same object holding the record\'s document date, whose value picks the exchange rate: a `date` or `datetime` field. Required, with no default — the engine never guesses the date (not `created_at`).'), + rateField: z.string({ error: homeAmountPointerRequired('rateField', 'the `number` field that stores the rate used') }).min(1, homeAmountEmptyPointer('rateField', 'the `number` field that stores the rate used')) + .describe('The field on the same object that stores the exchange rate the conversion used: a `number` field declaring `readonly: true` — only the engine writes it. Required: the home amount is stored with the rate used. Conventionally `AMOUNT_rate`.'), +}); + /** `fixed` ⇒ an authored `defaultCurrency`; `dynamic` reads none, so it needs none. */ function refuseFixedCurrencyWithoutCode( config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string }, @@ -673,12 +739,18 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ // 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.'), + // ADR-0144 D5 — the stored home-currency projection, declared by pointer. + // Optional; `refuseRecordModeKeyMismatch` below refuses it outside `record`, + // and the object's `superRefine` (`refuseDishonestHomeAmount`, + // object.zod.ts) checks what its three pointers name. + homeAmount: HomeAmountSchema.optional().describe('`record` mode only, optional: where the engine stores this amount converted into the organization\'s home currency (the `localization.currency` setting) at write, at the rate of the record\'s document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. 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). #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. + // no code; ADR-0144 D5 — `homeAmount` under `record` only; all below. Parse + // output is `{ currencyMode }` plus the `defaultCurrency`, `currencyField` + // and `homeAmount` the author wrote. }).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseRecordModeKeyMismatch)); /** diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index b4f00f7edd9..fa659aa7991 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1938,6 +1938,206 @@ function refuseDishonestRecordCurrencyField( } } +/** The field types a `homeAmount.rateDate` pointer may name: a calendar date, with or without a time. */ +const HOME_AMOUNT_RATE_DATE_TYPES: ReadonlySet = new Set(['date', 'datetime']); + +/** + * [ADR-0144 D5] A `record`-mode amount's `currencyConfig.homeAmount` names + * three fields THIS object declares: `field`, where the engine stores the + * amount converted into the home currency; `rateDate`, the document date whose + * rate it converts at; and `rateField`, where it stores the rate it used + * (ruling 6465431966 D1, "STORED with the rate used"; D8 rule 2, every + * currency-related field "declared by the app author and referenced by + * pointer"). `CurrencyConfigSchema` sees one config and refuses the half it can + * see (`homeAmount` outside `record`, a missing or empty pointer); what the + * pointers name is judged here, beside + * {@link refuseDishonestRecordCurrencyField}, because only the object holds + * the field map. + * + * One located issue per offending pointer, at + * `['fields', , 'currencyConfig', 'homeAmount', ]`, each with + * its remedy and the object's candidate fields: + * + * - the amount is not a stored `currency` field — a `formula` computes on + * read and is never written, so nothing converts it at write (at + * `homeAmount` itself; the pointers are not judged further); + * - `field` is not another declared field, is not a stored `currency` field, + * or is one whose mode is not `dynamic` (D8 rule 5: "the home field is a + * `dynamic`-mode currency field", so metadata never names the home + * currency), or is already another amount's home field (one column, two + * writers); + * - `rateDate` is not a declared `date` / `datetime` field; + * - `rateField` is not a declared `number` field, or is shared with an amount + * that names another code field or another document date (the two would + * store two different rates into one column); + * - the home field or the rate field is not marked platform-written. The + * engine writes both, so each declares `readonly: true` — the existing mark + * that drops every non-system write of the field from the payload — and no + * `expression`, which would make the engine store a second, CEL-computed + * value in the same column. + * + * Judged against the AUTHORED (parsed) field map, so a field an extension or + * the registry adds is not a candidate. Not judged on `ObjectExtensionSchema`, + * for the reason {@link refuseDishonestRecordCurrencyField} gives. + */ +function refuseDishonestHomeAmount( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + type HomeAmount = { field?: unknown; rateDate?: unknown; rateField?: unknown }; + type CurrencyConfigView = { currencyMode?: unknown; currencyField?: unknown; homeAmount?: HomeAmount }; + type FieldView = { type?: unknown; readonly?: unknown; expression?: unknown; currencyConfig?: CurrencyConfigView } | null; + const map = fields as Record; + const has = (name: string): boolean => Object.prototype.hasOwnProperty.call(map, name); + const modeOf = (f: FieldView): unknown => f?.currencyConfig?.currencyMode ?? 'dynamic'; + const isHomeCandidate = (f: FieldView): boolean => f?.type === 'currency' && modeOf(f) === 'dynamic'; + const list = (names: string[], none: string): string => + names.length > 0 ? `candidates: ${names.map((c) => `\`${c}\``).join(', ')}.` : `the object has none; ${none}.`; + // The amounts that declare a home amount, in declaration order: the first + // claimant of a home field keeps it, a later one is told who holds it. + const declaring = Object.entries(map).filter(([, f]) => { + const config = f?.currencyConfig; + return config?.currencyMode === 'record' && config.homeAmount !== null && typeof config.homeAmount === 'object'; + }); + const homeOwner = new Map(); + const rateOwner = new Map(); + + for (const [name, f] of declaring) { + const config = f!.currencyConfig!; + const home = config.homeAmount!; + const base = ['fields', name, 'currencyConfig', 'homeAmount']; + const at = `\`${name}.currencyConfig.homeAmount`; + if (f?.type !== 'currency') { + ctx.addIssue({ + code: 'custom', + path: base, + message: `${at}\` is declared on a \`${String(f?.type)}\` field of object \`${objectName}\`, ` + + (f?.type === 'formula' + ? 'which computes its value on read and is never written, so nothing converts it at write. ' + + 'Declare `homeAmount` on the stored `currency` amount the formula reads, or delete it.' + : 'and only a stored `currency` amount is converted at write. Declare `homeAmount` on a `currency` ' + + 'field, or delete it.'), + }); + continue; + } + // A field the engine writes, and nobody else: `readonly: true` drops every + // non-system write of it, and no `expression` competes with the engine. + const refuseUnlessPlatformWritten = (key: 'field' | 'rateField', target: string, what: string): void => { + const t = map[target]; + if (t?.readonly === true && t.expression === undefined) return; + ctx.addIssue({ + code: 'custom', + path: [...base, key], + message: `${at}.${key}\` names \`${target}\`, ${what} only the engine writes, but it ` + + (t?.readonly === true + ? 'declares an `expression`, so the engine would store a second, computed value in the same column. ' + + `Delete \`expression\` from \`${target}\`.` + : 'does not declare `readonly: true`, so a client write could overwrite what the engine stored. ' + + `Add \`readonly: true\` to \`${target}\`.`), + }); + }; + + // `field` — a stored `dynamic`-mode `currency` sibling, claimed by this amount alone. + if (typeof home.field === 'string') { + const target = home.field; + const candidates = Object.keys(map).filter((k) => k !== name && isHomeCandidate(map[k]!) && !homeOwner.has(k)); + const remedy = `\`homeAmount.field\` must name a stored \`currency\` field of this object in \`dynamic\` mode — ` + + list(candidates, `declare one, conventionally \`${name}_home: Field.currency({ readonly: true })\``); + const t = map[target]; + if (target === name || !has(target)) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, but object \`${objectName}\` declares no other field \`${target}\`. ${remedy}`, + }); + } else if (t?.type !== 'currency') { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, a \`${String(t?.type)}\` field on object \`${objectName}\`; the ` + + `home amount is stored, as an amount of money, in a \`currency\` field. ${remedy}`, + }); + } else if (modeOf(t) !== 'dynamic') { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, a \`${String(modeOf(t))}\`-mode currency field on object ` + + `\`${objectName}\`; the home field is in \`dynamic\` mode, so it displays in the home currency ` + + `(\`localization.currency\`) and metadata never names that currency. Give \`${target}\` ` + + `\`currencyMode: 'dynamic'\` (or no \`currencyConfig\`), or point at another field. ${remedy}`, + }); + } else if (homeOwner.has(target)) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'field'], + message: `${at}.field\` names \`${target}\`, which is already the home field of \`${homeOwner.get(target)}\`; ` + + `one column cannot store two converted amounts. ${remedy}`, + }); + } else { + homeOwner.set(target, name); + refuseUnlessPlatformWritten('field', target, 'the home field'); + } + } + + // `rateDate` — a declared `date` / `datetime` sibling. + if (typeof home.rateDate === 'string') { + const target = home.rateDate; + const t = map[target]; + if (!has(target) || typeof t?.type !== 'string' || !HOME_AMOUNT_RATE_DATE_TYPES.has(t.type)) { + const candidates = Object.keys(map).filter((k) => typeof map[k]?.type === 'string' + && HOME_AMOUNT_RATE_DATE_TYPES.has(map[k]!.type as string)); + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateDate'], + message: `${at}.rateDate\` names \`${target}\`, ` + + (has(target) + ? `a \`${String(t?.type)}\` field on object \`${objectName}\`; the document date is a \`date\` or \`datetime\` field.` + : `but object \`${objectName}\` declares no field \`${target}\`.`) + + ' `homeAmount.rateDate` must name the record\'s document date, whose rate the amount converts at — ' + + list(candidates, 'declare one, e.g. `invoice_date: Field.date()`; nothing defaults to `created_at`'), + }); + } + } + + // `rateField` — a declared `number` sibling, shared only by amounts that store the same rate. + if (typeof home.rateField === 'string') { + const target = home.rateField; + const t = map[target]; + const holder = rateOwner.get(target); + if (!has(target) || t?.type !== 'number') { + const candidates = Object.keys(map).filter((k) => map[k]?.type === 'number'); + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateField'], + message: `${at}.rateField\` names \`${target}\`, ` + + (has(target) + ? `a \`${String(t?.type)}\` field on object \`${objectName}\`; the rate used is stored in a \`number\` field.` + : `but object \`${objectName}\` declares no field \`${target}\`.`) + + ' `homeAmount.rateField` must name the field that stores the exchange rate the conversion used — ' + + list(candidates, `declare one, conventionally \`${name}_rate: Field.number({ readonly: true })\``), + }); + } else if (holder !== undefined && (holder.currencyField !== config.currencyField || holder.rateDate !== home.rateDate)) { + ctx.addIssue({ + code: 'custom', + path: [...base, 'rateField'], + message: `${at}.rateField\` names \`${target}\`, which already stores the rate of \`${holder.amount}\`, an ` + + 'amount with another code field or another document date, so the two rates can differ and one column ' + + `cannot store both. Give \`${name}\` its own \`number\` rate field, or the same \`currencyField\` and ` + + '`rateDate` as the amount it shares with.', + }); + } else if (holder === undefined) { + // The first holder is judged for the platform-written mark; a consistent + // sharer stores the same rate into the same, already judged, column. + rateOwner.set(target, { amount: name, currencyField: config.currencyField, rateDate: home.rateDate }); + refuseUnlessPlatformWritten('rateField', target, 'the rate field'); + } + } + } +} + /** * [objectui#12489 ruling 6203737086, letter A] A field's * `referenceDeletedWhen: { field, value }` names a SIBLING `select` field on @@ -3686,6 +3886,9 @@ const ObjectSchemaBase = strictObject( // [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); + // [ADR-0144 D5] Its `homeAmount` names a `dynamic` currency home field, a + // date and a number rate field, the two written ones `readonly` — same door. + refuseDishonestHomeAmount(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/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index a30fe1ab0cf..c4a59e66ee5 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,6 +365,16 @@ const LEDGER: ReadonlyArray = [ key: 'conditionalFormatting', why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", }, + // [ADR-0144 D5] A record-mode amount's home-currency projection lands + // spec-first the same way: declared and judged at parse, written by nothing + // until the engine's write-time projection lands in the same release (D9). + { + kind: 'omit', + type: 'field', + path: 'currencyConfig', + key: 'homeAmount', + why: "planned, not yet written — the `{ field, rateDate, rateField }` pointers are validated at parse (all three required, each judged against the object's field map), but no engine converts the amount or stores the home amount and the rate until the write-time home-currency projection lands, which ADR-0144 D9 releases with this key. A form control offered today would let an author declare a home amount nothing writes", + }, // #27946 — an action's pressed-state predicate lands spec-first the same // way: `planned` in the liveness ledger, drawn by no action surface until // the console card that draws `aria-pressed` on the header buttons. 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 056303ba6d1..5a421454897 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 @@ -4,5 +4,5 @@ "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.\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." + "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.\n\nADR-0144 D5 — `currencyConfig.homeAmount: { field, rateDate, rateField }` is read only under `record` (`refuseRecordModeKeyMismatch` refuses it under `dynamic` / `fixed`), and all three pointers are REQUIRED whenever it is declared, each non-empty (`HomeAmountSchema`, a closed shape). Do NOT make `rateField` or `rateDate` optional, and do NOT give either a default (`created_at`, an injected `AMOUNT_rate` column): D1 stores the home amount \"with the rate used\" and D8 rule 2 lets that rate and the document date live only in author-declared fields named by pointer. What the pointers NAME 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 76300265b17..df3a912578a 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 @@ -5,5 +5,5 @@ "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).\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." + "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.\n\nADR-0144 D5 — a `record`-mode amount's `currencyConfig.homeAmount` is judged on the parse (`refuseDishonestHomeAmount`, beside `refuseDishonestRecordCurrencyField`): the amount itself is a stored `currency` field (never a `formula`); `field` names ANOTHER declared `currency` field in `dynamic` mode (D8 rule 5: metadata never names the home currency) that no other amount already stores into; `rateDate` a declared `date` / `datetime` field; `rateField` a declared `number` field, shared only by amounts with the same `currencyField` and `rateDate`. The home and rate fields are platform-written and say so with the EXISTING mark, `readonly: true`, and carry no `expression`. Do NOT invent a new platform-computed key for them, and do NOT relax `readonly`: it is what drops a client's write of an engine-stored value." } diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 019b2a657d4..f81eebb3240 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -6,8 +6,6 @@ - [Common Field Configurations](#common-field-configurations) - [Incorrect vs Correct](#incorrect-vs-correct) -Quick reference for choosing the right field type from the available options. - ## Two spellings: `Field.*` factory or object literal The tables below give the **object-literal** spelling. Real apps overwhelmingly @@ -144,16 +142,6 @@ Stored as JSON on the parent row — no separate table / FK: ## Common Field Configurations -### Text with Max Length - -```typescript -{ - type: 'text', - maxLength: 255, - required: true, -} -``` - ### Email with Uniqueness ```typescript @@ -174,7 +162,8 @@ 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' } +// per record: { currencyMode: 'record', currencyField: 'currency_code', +// homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' } } ``` **Currency resolution.** The value is a bare number in every mode, never @@ -187,6 +176,11 @@ it may only restate the code, else `DATASET_INVALID` (delete it), as is a derive measure over two different `'fixed'` codes (derive from one currency or drop an operand). A `'fixed'` code is not judged against the tenant's. +`homeAmount` (`'record'` only, all three required): the engine stores the +home-currency amount in `field` (a `'dynamic'` `currency`) at the `rateDate` +(`date`) rate, and that rate in `rateField` (`number`), both `readonly: true`. +Cross-currency totals sum `field`. + ### Select with Default ```typescript @@ -208,8 +202,6 @@ operand). A `'fixed'` code is not judged against the tenant's. type: 'lookup', reference: 'account', required: true, - // Structured, picker-honoured filter — the former string[] `referenceFilters` - // was removed (ADR-0049): it filtered nothing. lookupFilters: [ { field: 'status', operator: 'eq', value: 'active' }, ], @@ -227,17 +219,6 @@ operand). A `'fixed'` code is not judged against the tenant's. } ``` -### Master-Detail with Cascade - -```typescript -{ - type: 'master_detail', - reference: 'invoice', - deleteBehavior: 'cascade', - required: true, -} -``` - ### Formula ```typescript