From 3a39df54527302b2ee326cade84385f4c156e75a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:34:27 +0000 Subject: [PATCH 1/6] feat(spec): `currencyConfig.homeAmount: { field, rateDate, rateField }` on a record-mode amount A `record`-mode amount declares, by three required pointers into the same object, where the engine stores it converted into the home currency, which document date picks the rate, and where the rate used is stored (ADR-0144 D5). - `CurrencyConfigSchema` gains `homeAmount`, a closed shape whose three pointers are required and non-empty; it is refused outside `record`. - `ObjectSchema`'s superRefine gains `refuseDishonestHomeAmount`: the amount is a stored `currency` field; `field` names another `dynamic`-mode `currency` field no other amount stores into; `rateDate` a `date` / `datetime` field; `rateField` a `number` field, shared only by amounts with the same code field and document date. The home and rate fields declare `readonly: true` and no `expression`. Each refusal is located at its pointer and carries a remedy and the object's candidates. - ADR-0144 anchors on field.zod.ts and object.zod.ts gain the D5 invariant. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../data/currency-config-home-amount.test.ts | 248 ++++++++++++++++++ packages/spec/src/data/field.zod.ts | 78 +++++- packages/spec/src/data/object.zod.ts | 203 ++++++++++++++ ...ckages__spec__src__data__field.zod.ts.json | 2 +- ...kages__spec__src__data__object.zod.ts.json | 2 +- 5 files changed, 528 insertions(+), 5 deletions(-) create mode 100644 packages/spec/src/data/currency-config-home-amount.test.ts 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 ba36c8e6613..c512ac59f82 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 3e50383e5b5..bb901f145ec 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 @@ -3601,6 +3801,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/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." } From 75c7a09cfd7d1c5a27e4a5895ba543e22340b9e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:37:43 +0000 Subject: [PATCH 2/6] docs(skills): teach `currencyConfig.homeAmount`; changeset for the home-amount pointer The currency block of `objectstack-data/rules/field-types.md` gains the `homeAmount` example and one paragraph, paid for by deleting restatement in the same file (3207 -> 3181 tokens). The changeset declares `@objectstack/spec` and `@objectstack/skills` minor and marks the key as released with the write-time projection (ADR-0144 D9, R2). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27191-home-amount-pointer.md | 40 ++++++++++++++++++++ skills/objectstack-data/rules/field-types.md | 33 ++++------------ 2 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 .changeset/27191-home-amount-pointer.md 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/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 From 6db7ac858462cf064bc412d8013ba18dc49bb060 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:09:40 +0000 Subject: [PATCH 3/6] chore(spec): regenerate the authorable surface, the field references and the strictness ledger for `homeAmount` Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/field.mdx | 12 +++++++++++- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- .../data.md | 12 ++++++------ packages/spec/authorable-surface/data.json | 1 + 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 000b5c65642..95382a1a8f7 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`. | --- @@ -90,7 +99,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") | @@ -240,6 +249,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 750b3ad0b84..3a95dd76b70 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -268,7 +268,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") | @@ -631,7 +631,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 61d41509065..019723afe2a 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' \| '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") | @@ -533,7 +533,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 6d67334be3f..4af374d9628 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/` | 154 | 88 | 2 | 0 | 64 | +| `data/` | 155 | 89 | 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` | 13 | +| `field.zod.ts` | 14 | | `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** | **154** | +| **total** | **155** | ## `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 154**, in 10 file(s). +**64 strip of 155**, 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 | 13 | +| `field.zod.ts` | 1 | 14 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **154** | +| **total** | **64** | **155** | | Bucket | Sites | |---|---| diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9b3731b9d4e..48dd5728c87 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", From 361f8f25cca4b3635c31de3e6fa187483700dcff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:42:45 +0000 Subject: [PATCH 4/6] test(spec): the field form omits `currencyConfig.homeAmount` until the engine writes it The form/Zod reconciliation gate refuses a key the Zod accepts and the form does not offer. `homeAmount` is offered in no form yet, deliberately: nothing writes the home amount until the write-time projection lands in the same release, so the gate's ledger carries a `planned` omit, the precedent of the other spec-first keys. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../system/metadata-form-zod-reconciliation.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 9897e447d00..7f756f885ed 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", + }, { kind: 'omit', type: 'view', From 5b1430f663f3130854ec168a5822da08a35057f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:27:03 +0000 Subject: [PATCH 5/6] chore(spec): regenerate the authorable surface and references after merging main Main's side of the four regenerated files, re-derived from the merged tree so this branch's `homeAmount` rows sit beside main's new entries. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/field.mdx | 10 ++++++++++ content/docs/references/data/object.mdx | 7 ++++++- content/docs/references/system/migration.mdx | 4 ++++ packages/spec/authorable-surface/data.json | 2 ++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 95382a1a8f7..c8756a07779 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -70,6 +70,7 @@ const result = CurrencyConfigSchema.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -94,6 +95,7 @@ const result = CurrencyConfigSchema.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **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. | @@ -232,6 +234,14 @@ const result = CurrencyConfigSchema.parse(data); | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. | +### Nested Shape: `Field.lookupAutofill[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **target** | `string` | ✅ | Field of THIS object the pick fills (snake_case machine name). | +| **source** | `string` | ✅ | Field of the picked record — a field of this lookup's `reference` object — whose value is copied (snake_case machine name). | +| **overwrite** | `boolean` | optional | TRUE: the pick replaces whatever the target holds, a typed value included. Absent or FALSE: the pick fills the target only while it is empty or still holds the value an earlier pick filled in. | + ### Nested Shape: `Field.summaryOperations` | Property | Type | Required | Description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 3a95dd76b70..f44f781b478 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -239,6 +239,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -263,6 +264,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **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. | @@ -382,7 +384,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -502,6 +504,7 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | @@ -602,6 +605,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -626,6 +630,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **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. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 019723afe2a..04dfb509d58 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -73,6 +73,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -97,6 +98,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **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. | @@ -504,6 +506,7 @@ Add a new field to an existing object | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -528,6 +531,7 @@ Add a new field to an existing object | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **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. | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 48dd5728c87..d04a1e830f1 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -315,6 +315,7 @@ "data/Field:internal", "data/Field:label", "data/Field:language", + "data/Field:lookupAutofill", "data/Field:lookupColumns", "data/Field:lookupFilters", "data/Field:lookupPageSize", @@ -328,6 +329,7 @@ "data/Field:multiple", "data/Field:name", "data/Field:options", + "data/Field:percentDisplay", "data/Field:picklist", "data/Field:placeholder", "data/Field:precision", From cf13692214fde39ab2a9b113f1708485e382e6b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:32:12 +0000 Subject: [PATCH 6/6] chore(docs): recount the unknown-key strictness ledger after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../data.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 | |---|---|