From 3a39df54527302b2ee326cade84385f4c156e75a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:34:27 +0000 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 | |---|---| From 1fb66e7cfbed88475f3e0f1bd1a401ed6f26640a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:06:04 +0000 Subject: [PATCH 07/11] wip(service-settings): write predicate and the home-currency lock Claude-Session: https://claude.ai/code/session_01Utp6CbTZrFqRYNgjSaSbN5 Co-authored-by: Claude --- .../services/service-settings/src/index.ts | 4 + .../src/localization-currency-lock.ts | 300 ++++++++++++++++++ .../service-settings/src/settings-routes.ts | 24 +- .../src/settings-service-plugin.ts | 6 + .../service-settings/src/settings-service.ts | 75 ++++- .../src/settings-service.types.ts | 66 +++- 6 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 packages/services/service-settings/src/localization-currency-lock.ts diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index b5e1ddce19d..5bbf1c372f0 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -37,6 +37,10 @@ export { type SettingsActionHandler, type SettingsLiveStatus, type SettingsLiveStatusProvider, + // ADR-0144 D4 — the write-side rule a package registers on one key. + type SettingsWritePredicate, + type SettingsWritePredicateInput, + type SettingsWriteRefusal, SETTINGS_LIVE_STATUS_BANNER_ID, type SettingsAuditSink, type SettingsContext, diff --git a/packages/services/service-settings/src/localization-currency-lock.ts b/packages/services/service-settings/src/localization-currency-lock.ts new file mode 100644 index 00000000000..935c6da06dc --- /dev/null +++ b/packages/services/service-settings/src/localization-currency-lock.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The home-currency lock (ADR-0144 D4, multi-currency M1-3). + * + * The organization's home currency is `localization.currency`, and it is + * locked "once a rate row or a home-amount declaration exists". Changing it + * after that relabels every amount already converted into it: a stored home + * amount and its rate were computed against the old code, and nothing would + * recompute them. So once the currency is used: + * + * - **The write is refused.** {@link localizationCurrencyWritePredicate} is the + * key's settings write predicate. A change to a set value — another code, + * or a clear — is refused while any `sys_exchange_rate` row of the + * organization exists, or any registered object declares + * `currencyConfig.homeAmount` on a field. The refusal names both. + * - **The page says so.** {@link localizationCurrencyLiveStatus} heads the + * Localization page with what uses the currency, in the caller's language. + * + * Setting the currency while it is UNSET is not a change of home currency: + * nothing has been converted into a currency that does not exist, so that + * write passes, and the page asks for it (ADR-0144 Open item 4, this card's + * half: the lock never invents a default, and never blocks choosing one). + * + * "Used" reads the engine at each ask, never a flag recorded at boot: the lock + * lifts by itself once the last rate row is deleted and the last `homeAmount` + * declaration is removed. + */ + +import { resolveLocale } from '@objectstack/core'; +import type { + SettingsLiveStatus, + SettingsLiveStatusProvider, + SettingsWritePredicate, +} from './settings-service.types.js'; + +/** The rate table of multi-currency M1-1, owned by `@objectstack/platform-objects`. */ +export const EXCHANGE_RATE_OBJECT = 'sys_exchange_rate'; + +/** The engine surface the lock reads: a count, and the object registry. */ +export interface CurrencyUsageEngine { + count( + objectName: string, + query?: { where?: Record; context?: Record }, + ): Promise; + registry?: { + getAllObjects?(): ReadonlyArray; + getObject?(name: string): unknown; + }; +} + +/** What uses the home currency of one organization. */ +export interface CurrencyUsage { + /** Objects declaring `currencyConfig.homeAmount` on a field, in registry order. */ + objects: Array<{ name: string; label: string }>; + /** The organization's `sys_exchange_rate` rows. */ + exchangeRates: number; +} + +const SYSTEM_CONTEXT = Object.freeze({ isSystem: true as const }); + +type AnyRecord = Record; +const isRecord = (v: unknown): v is AnyRecord => typeof v === 'object' && v !== null && !Array.isArray(v); + +/** Does any field of this object declare a home amount? */ +function declaresHomeAmount(schema: AnyRecord): boolean { + const fields = isRecord(schema.fields) ? schema.fields : {}; + return Object.values(fields).some( + (def) => isRecord(def) && isRecord(def.currencyConfig) && isRecord(def.currencyConfig.homeAmount), + ); +} + +/** + * Read what uses the home currency. `organizationId` is the organization whose + * rate rows count: an id, or `null` in a composition with no organization + * object, whose rows carry none. Objects are deployment metadata, so every + * organization sees the same ones. + * + * A composition that does not register `sys_exchange_rate` holds no rate rows. + * Any other failure of the count propagates: the lock does not guess. + */ +export async function readCurrencyUsage( + engine: CurrencyUsageEngine, + organizationId: string | null, +): Promise { + const objects: CurrencyUsage['objects'] = []; + for (const schema of engine.registry?.getAllObjects?.() ?? []) { + if (!isRecord(schema) || typeof schema.name !== 'string' || !declaresHomeAmount(schema)) continue; + objects.push({ name: schema.name, label: typeof schema.label === 'string' ? schema.label : schema.name }); + } + const registered = typeof engine.registry?.getObject === 'function' + ? Boolean(engine.registry.getObject(EXCHANGE_RATE_OBJECT)) + : true; + const exchangeRates = registered + ? await engine.count(EXCHANGE_RATE_OBJECT, { + // The organization filter is explicit: a system read carries no + // organization, so nothing else scopes it. + ...(organizationId !== null ? { where: { organization_id: organizationId } } : {}), + context: SYSTEM_CONTEXT, + }) + : 0; + return { objects, exchangeRates }; +} + +const isUsed = (usage: CurrencyUsage): boolean => usage.objects.length > 0 || usage.exchangeRates > 0; + +const isUnset = (value: unknown): boolean => value === null || value === undefined || value === ''; + +// ── Copy ───────────────────────────────────────────────────────────────── + +/** How many object labels a sentence lists before it elides the rest. */ +const LISTED_OBJECTS = 5; + +interface CurrencyLockCopy { + objects(count: number, labels: string): string; + rates(count: number): string; + and: string; + listSeparator: string; + refusal(current: string, usage: string): string; + lockedLabel(current: string): string; + lockedText(usage: string): string; + unsetLabel: string; + unsetText(usage: string): string; +} + +/** + * The lock's copy, one entry per locale the settings bundle ships + * (`settingsBuiltinTranslations`). Kept here and not in that bundle: the + * sentences are computed per request with counts and names, and the console's + * banner lookup would replace a computed live-status text with a fixed one + * (`SETTINGS_LIVE_STATUS_BANNER_ID`). + */ +export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', CurrencyLockCopy> = { + en: { + objects: (n, labels) => `${n} ${n === 1 ? 'object' : 'objects'} (${labels})`, + rates: (n) => `${n} ${n === 1 ? 'exchange rate' : 'exchange rates'}`, + and: ' and ', + listSeparator: ', ', + refusal: (current, usage) => + `The default currency is locked at ${current} because it is in use by ${usage}. ` + + 'Changing it would relabel every amount already converted into it. It can change again only once ' + + 'no exchange rate is recorded and no object declares a home-currency amount.', + lockedLabel: (current) => `Default currency locked: ${current}`, + lockedText: (usage) => + `In use by ${usage}. Converted amounts are stored in this currency, so it can no longer be changed.`, + unsetLabel: 'No default currency set', + unsetText: (usage) => + 'Exchange rates and home-currency amounts need a default currency, and none is set. ' + + `In use: ${usage}. Choose it below; once saved, it cannot be changed.`, + }, + 'zh-CN': { + objects: (n, labels) => `${n} 个对象(${labels})`, + rates: (n) => `${n} 条汇率`, + and: '和', + listSeparator: '、', + refusal: (current, usage) => + `默认币种已锁定为 ${current}:它已被${usage}使用。修改它会给所有已换算成该币种的金额换上错误的币种。` + + '只有在不再有任何汇率记录、也没有任何对象声明本位币金额之后,才能修改。', + lockedLabel: (current) => `默认币种已锁定:${current}`, + lockedText: (usage) => `已被${usage}使用。换算后的金额都以该币种存储,因此不能再修改。`, + unsetLabel: '尚未设置默认币种', + unsetText: (usage) => + `汇率和本位币金额都需要默认币种,但目前尚未设置。使用情况:${usage}。请在下方选择;保存后将无法修改。`, + }, + 'ja-JP': { + objects: (n, labels) => `${n} 件のオブジェクト(${labels})`, + rates: (n) => `${n} 件の為替レート`, + and: 'と', + listSeparator: '、', + refusal: (current, usage) => + `既定の通貨は ${current} でロックされています。${usage}で使用されているためです。` + + '変更すると、この通貨に換算済みのすべての金額に誤った通貨が付きます。' + + '為替レートが 1 件もなく、基準通貨の金額を宣言するオブジェクトもなくなった場合にのみ変更できます。', + lockedLabel: (current) => `既定の通貨はロックされています: ${current}`, + lockedText: (usage) => `${usage}で使用中です。換算後の金額はこの通貨で保存されるため、変更できません。`, + unsetLabel: '既定の通貨が設定されていません', + unsetText: (usage) => + '為替レートと基準通貨の金額には既定の通貨が必要ですが、まだ設定されていません。' + + `使用状況: ${usage}。下で選択してください。保存後は変更できません。`, + }, + 'es-ES': { + objects: (n, labels) => `${n} ${n === 1 ? 'objeto' : 'objetos'} (${labels})`, + rates: (n) => `${n} ${n === 1 ? 'tipo de cambio' : 'tipos de cambio'}`, + and: ' y ', + listSeparator: ', ', + refusal: (current, usage) => + `La moneda predeterminada está bloqueada en ${current} porque está en uso en ${usage}. ` + + 'Cambiarla volvería a etiquetar todos los importes ya convertidos a ella. Solo podrá cambiarse ' + + 'cuando no quede ningún tipo de cambio y ningún objeto declare un importe en la moneda principal.', + lockedLabel: (current) => `Moneda predeterminada bloqueada: ${current}`, + lockedText: (usage) => + `En uso en ${usage}. Los importes convertidos se guardan en esta moneda, por lo que ya no se puede cambiar.`, + unsetLabel: 'No hay moneda predeterminada', + unsetText: (usage) => + 'Los tipos de cambio y los importes en la moneda principal necesitan una moneda predeterminada, ' + + `y no hay ninguna. En uso: ${usage}. Elíjala abajo; una vez guardada, no se podrá cambiar.`, + }, +}; + +const COPY_LOCALES = Object.keys(CURRENCY_LOCK_COPY); + +/** The copy for a caller's locale: exact, then by language, else English. */ +export function currencyLockCopy(locale: string | undefined): CurrencyLockCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return CURRENCY_LOCK_COPY[(picked ?? 'en') as keyof typeof CURRENCY_LOCK_COPY]; +} + +/** "2 objects (Invoice, Order) and 14 exchange rates", in the copy's language. */ +export function describeCurrencyUsage(copy: CurrencyLockCopy, usage: CurrencyUsage): string { + const parts: string[] = []; + if (usage.objects.length > 0) { + const listed = usage.objects.slice(0, LISTED_OBJECTS).map((o) => o.label); + const labels = listed.join(copy.listSeparator) + (usage.objects.length > LISTED_OBJECTS ? '…' : ''); + parts.push(copy.objects(usage.objects.length, labels)); + } + if (usage.exchangeRates > 0) parts.push(copy.rates(usage.exchangeRates)); + return parts.join(copy.and); +} + +/** The discrete values a refusal carries (`FieldError.constraint`). */ +function usageConstraint(usage: CurrencyUsage): Record { + return { + lockedBy: 'in_use', + objects: usage.objects.map((o) => o.name), + exchangeRates: usage.exchangeRates, + }; +} + +// ── The predicate and the statement ────────────────────────────────────── + +/** + * `localization.currency`'s write predicate: a change to a SET currency is + * refused while the currency is used. Setting it while unset passes. + */ +export function localizationCurrencyWritePredicate(engine: CurrencyUsageEngine): SettingsWritePredicate { + return async ({ current, organizationId, ctx }) => { + if (isUnset(current)) return undefined; + const usage = await readCurrencyUsage(engine, organizationId ?? null); + if (!isUsed(usage)) return undefined; + const copy = currencyLockCopy(ctx.locale); + return { + message: copy.refusal(String(current), describeCurrencyUsage(copy, usage)), + constraint: usageConstraint(usage), + }; + }; +} + +/** + * The Localization page's statement: what uses the home currency, and that it + * is locked — or, while it is unset, that it is needed. Nothing to state while + * nothing uses it. + * + * Reads the currency the caller's page resolves through `currentCurrency`, so + * the statement and the form below it name the same value. + */ +export function localizationCurrencyLiveStatus( + engine: CurrencyUsageEngine, + currentCurrency: (ctx: Parameters[0]['ctx']) => Promise, +): SettingsLiveStatusProvider { + return async ({ ctx, organizationId }): Promise => { + const organization = await organizationId(); + if (organization === undefined) return undefined; + const usage = await readCurrencyUsage(engine, organization); + if (!isUsed(usage)) return undefined; + const copy = currencyLockCopy(ctx.locale); + const described = describeCurrencyUsage(copy, usage); + const current = await currentCurrency(ctx); + if (isUnset(current)) { + return { label: copy.unsetLabel, bannerText: copy.unsetText(described), bannerSeverity: 'warning' }; + } + return { label: copy.lockedLabel(String(current)), bannerText: copy.lockedText(described), bannerSeverity: 'info' }; + }; +} + +/** The service surface the lock registers on. */ +export interface CurrencyLockHost { + getManifest(namespace: string): unknown; + registerWritePredicate(namespace: string, key: string, predicate: SettingsWritePredicate): void; + registerLiveStatus(namespace: string, provider: SettingsLiveStatusProvider): void; + get(namespace: string, key: string, ctx?: Parameters[0]['ctx']): Promise<{ value: unknown }>; +} + +/** + * Register the lock on a settings service bound to `engine`. Returns `false`, + * registering nothing, when the service carries no `localization` namespace + * (a host that replaced the built-in manifests). + */ +export function registerLocalizationCurrencyLock(host: CurrencyLockHost, engine: CurrencyUsageEngine): boolean { + try { + host.getManifest('localization'); + } catch { + return false; + } + host.registerWritePredicate('localization', 'currency', localizationCurrencyWritePredicate(engine)); + host.registerLiveStatus( + 'localization', + localizationCurrencyLiveStatus(engine, async (ctx) => (await host.get('localization', 'currency', ctx)).value), + ); + return true; +} diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 2461be95f03..aeaac5fdfcb 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -44,6 +44,7 @@ import { shouldDenyAnonymous, } from '@objectstack/core'; import type { ErrorCode } from '@objectstack/spec/api'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; // The declared envelope is written in ONE place for the whole platform (#3973). // Its `extra` is `ApiError`'s own optional fields, so the undeclared siblings // #4224 retired from this module cannot come back through it either. @@ -200,6 +201,20 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined) return true; } +/** + * The caller's display language, read the way REST's metadata door reads it: + * the top `Accept-Language` tag, else `?locale`. It only picks the language of + * prose a write predicate or a live-status provider composes + * (`SettingsContext.locale`); it authorizes nothing. + */ +function requestLocaleOf(req: IHttpRequest): string | undefined { + const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language']; + const preferred = preferredLocaleFromHeader(typeof header === 'string' ? header : undefined); + if (preferred) return preferred; + const query = req.query?.locale; + return typeof query === 'string' && query.length > 0 ? query : undefined; +} + export function registerSettingsRoutes( http: IHttpServer, service: SettingsService, @@ -280,7 +295,7 @@ export function registerSettingsRoutes( // `getNamespace`, which in-process consumers call to read values: the // mail plugin reads `getNamespace('mail')` while rebuilding the very // transport its statement waits on. - const liveStatus = await service.liveStatusOf(ns, ctx); + const liveStatus = await service.liveStatusOf(ns, { ...ctx, locale: requestLocaleOf(req) }); const manifest = liveStatus ? { ...payload.manifest, specifiers: [liveStatus, ...payload.manifest.specifiers] } : payload.manifest; @@ -335,7 +350,12 @@ export function registerSettingsRoutes( // namespace — the same 404 `setMany` would have raised one line later, in // the same order relative to the 403 authz check. const secretKeys = service.secretKeysOf(ns); - const result = await service.setMany(ns, dropEchoedSecretMasks(body, secretKeys), ctx); + const result = await service.setMany( + ns, + dropEchoedSecretMasks(body, secretKeys), + // The language a write predicate's refusal is written in. + { ...ctx, locale: requestLocaleOf(req) }, + ); // The write response carries resolved values too — including cascade // entries the caller never submitted (an upper-scope secret it may not // have set). Same boundary, same redaction. diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index eef084c1802..628b86b4d9a 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -34,6 +34,7 @@ import type { SettingsAuditWriter, SettingsEngine, SettingsSecretStore } from '. import type { CryptoAdapter } from './crypto-adapter.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { buildConfigChangeAuditSink } from './config-change-audit.js'; +import { registerLocalizationCurrencyLock, type CurrencyUsageEngine } from './localization-currency-lock.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { registerSettingsRoutes } from './settings-routes.js'; import { registerSettingsReadDoor } from './settings-read-door.js'; @@ -345,6 +346,11 @@ export class SettingsServicePlugin implements Plugin { resolveGuestOrganization(engine, await this.resolveAdmissionTenancyPosture(ctx)), }, ); + // [ADR-0144 D4] `localization.currency` is the home currency, locked + // once an exchange rate or a `homeAmount` declaration uses it: the + // key's write predicate and the page's statement both read this + // engine (see `localization-currency-lock.ts`). + registerLocalizationCurrencyLock(this.service!, engine as unknown as CurrencyUsageEngine); // Each namespace's `readPermission`, applied at the generic data // API's read of the settings stores too (see `settings-read-door.ts`). if (!registerSettingsReadDoor(engine, this.service!)) { diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 148247002ea..939acd74b3b 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -22,6 +22,7 @@ import { type SettingsActionHandler, type SettingsLiveStatus, type SettingsLiveStatusProvider, + type SettingsWritePredicate, SETTINGS_LIVE_STATUS_BANNER_ID, type SettingsAuditSink, type SettingsContext, @@ -881,6 +882,8 @@ export class SettingsService { private organizationSource?: SettingsOrganizationSource; /** Live-status providers by namespace — see {@link registerLiveStatus}. */ private readonly liveStatusProviders = new Map(); + /** Write predicates by namespace, then key — see {@link registerWritePredicate}. */ + private readonly writePredicates = new Map>(); /** Change subscribers, optionally scoped to a namespace. */ private readonly subscribers = new Set<{ ns?: string; @@ -1587,6 +1590,21 @@ export class SettingsService { this.liveStatusProviders.set(namespace, provider); } + /** + * Register (or replace) the write predicate of one manifest key — a rule + * over engine state that may refuse a CHANGE to the key. See + * {@link SettingsWritePredicate}. The last registration wins, the same + * override rule as {@link registerAction}. + */ + registerWritePredicate(namespace: string, key: string, predicate: SettingsWritePredicate): void { + const reg = this.registry.get(namespace); + if (!reg) throw new UnknownNamespaceError(namespace); + if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key); + let byKey = this.writePredicates.get(namespace); + if (!byKey) this.writePredicates.set(namespace, (byKey = new Map())); + byKey.set(key, predicate); + } + /** * The namespace's live-status statement as an `info_banner` specifier, ready * to head the page, or `undefined` when no provider is registered or the @@ -1606,7 +1624,11 @@ export class SettingsService { if (!provider) return undefined; let status: SettingsLiveStatus | undefined; try { - status = await provider({ namespace, ctx }); + status = await provider({ + namespace, + ctx, + organizationId: async () => organizationReachRowOwner(await this.organizationReachOf(ctx)), + }); } catch (err: any) { this.logger?.warn?.( `SettingsService: the live status of settings namespace '${namespace}' could not be read — ` @@ -2339,6 +2361,12 @@ export class SettingsService { // validatePatch for the exact semantics. await this.validatePatch(namespace, patch, ctx); + // The keys' write predicates (ADR-0144 D4): rules over engine state that + // may refuse a change the declarations above accept. After them, so a + // malformed value is told about its own fault first; before the crypto + // pass and every write, so a refused batch writes nothing. + await this.refuseByWritePredicates(reg, namespace, patch, ctx, reach); + // #8026 — a declared-encrypted key with nothing able to encrypt it fails // the WHOLE batch, here, rather than part-way down the write loop below. // The persist site enforces the same rule (that is the load-bearing half); @@ -2536,6 +2564,51 @@ export class SettingsService { return out; } + /** + * Ask each patched key's {@link SettingsWritePredicate}, when the patch + * changes the key, and refuse the whole batch with every refusal at once. + * + * "Changes" compares the submitted value (`null` / `undefined` read as a + * clear) with the value the caller's cascade resolves today, so re-saving + * the value in force — the console posts a form's dirty keys, and a form + * can post back what it read — is never refused. + */ + private async refuseByWritePredicates( + reg: RegisteredManifest, + namespace: string, + patch: Record, + ctx: SettingsContext, + reach: OrganizationReach | undefined, + ): Promise { + const byKey = this.writePredicates.get(namespace); + if (!byKey) return; + const refusals: FieldError[] = []; + for (const [key, submitted] of Object.entries(patch)) { + const predicate = byKey.get(key); + if (!predicate) continue; + const next = submitted ?? null; + const current = (await this.get(namespace, key, ctx)).value ?? null; + if (stableStringify(next) === stableStringify(current)) continue; + const scope = reg.scopes.get(key)!; + const organizationId = scope === 'global' || !reach ? undefined : organizationReachRowOwner(reach) ?? null; + const refusal = await predicate({ namespace, key, next, current, organizationId, ctx }); + if (!refusal) continue; + const spec = ((reg.manifest.specifiers ?? []) as Array>) + .find((s) => s.key === key); + const label = typeof spec?.label === 'string' ? spec.label : key; + refusals.push({ + field: key, + // ADR-0114's slot for "rejected for a reason no other member names": + // the value is well-formed; the system's state refuses the change. + code: 'invalid_value', + message: refusal.message, + label, + ...(refusal.constraint ? { constraint: refusal.constraint } : {}), + }); + } + if (refusals.length > 0) throw new SettingsValidationError(namespace, refusals); + } + /** * Save-time validation for `setMany`, fulfilling the spec promise that * `required` is enforced server-side and hidden specifiers are not diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index dba6d0e0e7f..d93f2cb8c52 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -82,6 +82,14 @@ export interface SettingsContext { * resolver sets it to have its held sessions refused too. */ authGate?: AuthGate; + /** + * The caller's display language (BCP-47), for prose a + * {@link SettingsWritePredicate} refusal or a {@link SettingsLiveStatusProvider} + * statement composes. The settings routes set it from the request + * (`Accept-Language`, else `?locale`). Never an authorization input and never + * read by the resolver; absent, such prose is written in English. + */ + locale?: string; } /** Storage row shape used by both the engine and the in-memory store. */ @@ -321,6 +329,56 @@ export type SettingsActionHandler = (input: { ctx: SettingsContext; }) => Promise | SettingsActionResult; +/** + * What a {@link SettingsWritePredicate} is asked about one key of a write. + * + * Asked only when the write CHANGES the key: `next` differs from `current`. + * A write that stores the value already in force is never refused by one. + */ +export interface SettingsWritePredicateInput { + namespace: string; + key: string; + /** The value the write would store; `null` clears the key. */ + next: unknown; + /** The value the caller's cascade resolves for the key before the write. */ + current: unknown; + /** + * The organization the row lands in: its id, `null` in a composition with no + * organization object, `undefined` for a `global` key. + */ + organizationId: string | null | undefined; + ctx: SettingsContext; +} + +/** + * A predicate's refusal. `message` is the sentence the caller reads under the + * field, written in `ctx.locale`; `constraint` carries its discrete values + * (`FieldError.constraint`, ADR-0114), so a client can compose its own text. + */ +export interface SettingsWriteRefusal { + message: string; + constraint?: Record; +} + +/** + * The settings write predicate: a rule over ENGINE STATE that may refuse a + * change to one manifest key. The key's declaration (`options`, `pattern`, + * `valueDomain`, …) judges the value by itself; a predicate judges whether + * the value may change at all, given what the rest of the system already holds + * (ADR-0144 D4: `localization.currency` is locked once used). + * + * Registered by the package that owns the state it reads, with + * `SettingsService.registerWritePredicate`. `setMany` asks it after the + * batch's declared constraints have passed and before any row is written; a + * refusal refuses the WHOLE batch with `SettingsValidationError` + * (`invalid_value`, at the key). `undefined` lets the change through. A + * predicate that throws fails the write: the state it guards could not be + * read, so the change is not made. + */ +export type SettingsWritePredicate = ( + input: SettingsWritePredicateInput, +) => Promise | SettingsWriteRefusal | undefined; + /** * What a namespace's page states about the RUNNING system, as opposed to what * is stored. The stored values say what an operator chose; they do not say what @@ -348,7 +406,13 @@ export interface SettingsLiveStatus { export type SettingsLiveStatusProvider = (input: { namespace: string; ctx: SettingsContext; -}) => Promise | SettingsLiveStatus | undefined; + /** + * The organization the caller's page reads: its id, `null` in a composition + * with no organization object, `undefined` when the caller has none. + * Resolved only when called. + */ + organizationId: () => Promise; +}) =>Promise | SettingsLiveStatus | undefined; /** * The `info_banner` id that carries a namespace's live status. Reserved: a From 45bcf4a28960f2c80f3542b08e02abcd9ed5db06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:08:43 +0000 Subject: [PATCH 08/11] test(service-settings): pin the write predicate and the home-currency lock Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude --- .../src/localization-currency-lock.test.ts | 336 ++++++++++++++++++ .../src/localization-currency-lock.ts | 8 +- .../src/settings-write-predicate.test.ts | 96 +++++ 3 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 packages/services/service-settings/src/localization-currency-lock.test.ts create mode 100644 packages/services/service-settings/src/settings-write-predicate.test.ts diff --git a/packages/services/service-settings/src/localization-currency-lock.test.ts b/packages/services/service-settings/src/localization-currency-lock.test.ts new file mode 100644 index 00000000000..807ef704af1 --- /dev/null +++ b/packages/services/service-settings/src/localization-currency-lock.test.ts @@ -0,0 +1,336 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The home-currency lock (ADR-0144 D4, multi-currency M1-3): + * `localization.currency` saves freely until an exchange rate row or a + * `homeAmount` declaration uses it, and is refused after either; the refusal + * and the Localization page name what uses it, in every locale the settings + * bundle ships. + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; +import { mailSettingsManifest } from './manifests/mail.manifest.js'; +import { settingsBuiltinTranslations } from './translations/index.js'; +import { SETTINGS_LIVE_STATUS_BANNER_ID, SettingsValidationError } from './settings-service.types.js'; +import { + CURRENCY_LOCK_COPY, + EXCHANGE_RATE_OBJECT, + readCurrencyUsage, + registerLocalizationCurrencyLock, + type CurrencyUsageEngine, +} from './localization-currency-lock.js'; + +const ORG = 'org_alpha'; +const OTHER_ORG = 'org_beta'; +const ctx = { tenantId: ORG, userId: 'usr_admin' }; + +/** An invoice whose `amount` declares its home-currency projection (#27191's shape). */ +const INVOICE = { + name: 'invoice', + label: 'Invoice', + fields: { + amount: { + type: 'currency', + currencyConfig: { + currencyMode: 'record', + currencyField: 'currency_code', + homeAmount: { field: 'amount_home', rateDate: 'invoice_date', rateField: 'amount_rate' }, + }, + }, + amount_home: { type: 'currency', readonly: true }, + }, +}; +/** A record-mode amount with no home projection: it does not use the home currency. */ +const QUOTE = { + name: 'quote', + label: 'Quote', + fields: { total: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'code' } } }, +}; + +/** The engine the lock reads: `sys_exchange_rate` rows and the object registry. */ +function fakeEngine(opts: { rateObjectRegistered?: boolean } = {}) { + const rates: Array<{ organization_id: string | null; currency: string }> = []; + const objects: unknown[] = [QUOTE]; + const count = vi.fn(async (name: string, query?: { where?: Record }) => { + if (name !== EXCHANGE_RATE_OBJECT) throw new Error(`unexpected count of ${name}`); + const org = query?.where?.organization_id; + return rates.filter((r) => org === undefined || r.organization_id === org).length; + }); + const engine: CurrencyUsageEngine = { + count, + registry: { + getAllObjects: () => objects, + getObject: (name: string) => + name === EXCHANGE_RATE_OBJECT && opts.rateObjectRegistered !== false ? { name } : undefined, + }, + }; + return { engine, rates, objects, count }; +} + +function rig(opts: { rateObjectRegistered?: boolean } = {}) { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + const fake = fakeEngine(opts); + expect(registerLocalizationCurrencyLock(svc, fake.engine)).toBe(true); + return { svc, ...fake }; +} + +async function refusalOf(p: Promise): Promise { + const err = await p.then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(SettingsValidationError); + return err as SettingsValidationError; +} + +describe('localization.currency — saves freely until used', () => { + it('saves and changes before the first rate row and before any homeAmount declaration', async () => { + const { svc } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + await svc.set('localization', 'currency', null, ctx); + await svc.set('localization', 'currency', 'CNY', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('CNY'); + }); + + it('a record-mode amount without `homeAmount` does not lock it', async () => { + const { svc } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); + + it("another organization's rate rows do not lock this one", async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: OTHER_ORG, currency: 'EUR' }); + await svc.set('localization', 'currency', 'EUR', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); +}); + +describe('localization.currency — locked once used', () => { + it('is refused after the first rate row, naming it', async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + expect(err.fields).toHaveLength(1); + expect(err.fields[0]).toMatchObject({ + field: 'currency', + code: 'invalid_value', + label: 'Default currency', + constraint: { lockedBy: 'in_use', objects: [], exchangeRates: 1 }, + }); + expect(err.fields[0].message).toMatch(/^The default currency is locked at USD because it is in use by 1 exchange rate\. /); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('is refused after a homeAmount declaration, naming the object', async () => { + const { svc, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + expect(err.fields[0].constraint).toEqual({ lockedBy: 'in_use', objects: ['invoice'], exchangeRates: 0 }); + expect(err.fields[0].message).toContain('in use by 1 object (Invoice).'); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('names both users when both exist', async () => { + const { svc, rates, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE, { ...INVOICE, name: 'order', label: 'Order' }); + rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: ORG, currency: 'JPY' }); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + expect(err.fields[0].message).toContain('in use by 2 objects (Invoice, Order) and 2 exchange rates.'); + }); + + it('refuses a clear too, and the namespace reset with it', async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + await refusalOf(svc.set('localization', 'currency', null, ctx)); + await refusalOf(svc.setMany('localization', { currency: null, timezone: null }, ctx)); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('lets a re-save of the value in force through, and the batch beside it', async () => { + const { svc, rates } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + await svc.setMany('localization', { currency: 'USD', timezone: 'Asia/Tokyo' }, ctx); + expect((await svc.get('localization', 'timezone', ctx)).value).toBe('Asia/Tokyo'); + }); + + it('lets the first value through while the currency is unset (ADR-0144 Open item 4: never blocks choosing one)', async () => { + const { svc, rates, objects } = rig(); + rates.push({ organization_id: ORG, currency: 'EUR' }); + objects.push(INVOICE); + await svc.set('localization', 'currency', 'USD', ctx); + await refusalOf(svc.set('localization', 'currency', 'GBP', ctx)); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('lifts by itself once the last rate row and the last declaration are gone', async () => { + const { svc, rates, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + objects.push(INVOICE); + await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + rates.length = 0; + await refusalOf(svc.set('localization', 'currency', 'EUR', ctx)); + objects.pop(); + await svc.set('localization', 'currency', 'EUR', ctx); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); + + it('counts no rate rows where the composition does not register the rate table', async () => { + const { svc, count } = rig({ rateObjectRegistered: false }); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + expect(count).not.toHaveBeenCalled(); + }); + + it('fails the write when the rate count fails, rather than guess', async () => { + const { svc, count } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + count.mockRejectedValueOnce(new Error('driver down')); + await expect(svc.set('localization', 'currency', 'EUR', ctx)).rejects.toThrow('driver down'); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); +}); + +describe('readCurrencyUsage', () => { + it("counts the organization's own rows, under a system read", async () => { + const { engine, rates, count } = fakeEngine(); + rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: OTHER_ORG, currency: 'EUR' }); + expect((await readCurrencyUsage(engine, ORG)).exchangeRates).toBe(1); + expect(count).toHaveBeenLastCalledWith(EXCHANGE_RATE_OBJECT, { + where: { organization_id: ORG }, + context: { isSystem: true }, + }); + }); + + it('counts every row in a composition with no organization object', async () => { + const { engine, rates } = fakeEngine(); + rates.push({ organization_id: null, currency: 'EUR' }, { organization_id: null, currency: 'JPY' }); + expect((await readCurrencyUsage(engine, null)).exchangeRates).toBe(2); + }); +}); + +describe('the copy — every locale the settings bundle ships', () => { + it('covers exactly the locales of settingsBuiltinTranslations', () => { + expect(Object.keys(CURRENCY_LOCK_COPY).sort()).toEqual(Object.keys(settingsBuiltinTranslations).sort()); + }); + + it.each([ + ['zh-CN', '默认币种已锁定为 USD:它已被 1 个对象(Invoice)和 2 条汇率使用。'], + ['ja', '既定の通貨は USD でロックされています。1 件のオブジェクト(Invoice)と 2 件の為替レートで使用されているためです。'], + ['es-MX', 'La moneda predeterminada está bloqueada en USD porque está en uso en 1 objeto (Invoice) y 2 tipos de cambio.'], + ['fr-FR', 'The default currency is locked at USD because it is in use by 1 object (Invoice) and 2 exchange rates.'], + ])('writes the refusal in %s', async (locale, opening) => { + const { svc, rates, objects } = rig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE); + rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: ORG, currency: 'JPY' }); + const err = await refusalOf(svc.set('localization', 'currency', 'EUR', { ...ctx, locale })); + expect(err.fields[0].message.startsWith(opening)).toBe(true); + }); +}); + +// ── The page: GET /api/settings/localization ────────────────────────────── + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { + this.routes.set(`${method} ${path}`, handler); + } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +function pageRig() { + const base = rig(); + const http = new MockHttp(); + registerSettingsRoutes(http, base.svc, { + contextFromRequest: () => ({ ...ctx, enforced: true, permissions: ['setup.access', 'setup.write'] }), + }); + const call = async (verb: 'GET' | 'PUT', headers: Record, body?: unknown) => { + const req: IHttpRequest = { + params: { namespace: 'localization' }, query: {}, body, headers, method: verb, path: '/', + }; + const state: { status: number; body?: any } = { status: 200 }; + const res: IHttpResponse = { + json: vi.fn((data) => { state.body = data; }) as any, + send: vi.fn() as any, + status: vi.fn((code: number) => { state.status = code; return res; }) as any, + header: vi.fn(() => res) as any, + }; + await http.routes.get(`${verb} /api/settings/:namespace`)!(req, res); + return state; + }; + return { ...base, call }; +} + +describe('the Localization page states what uses the currency', () => { + it('states nothing while nothing uses it', async () => { + const { svc, call } = pageRig(); + await svc.set('localization', 'currency', 'USD', ctx); + const { body } = await call('GET', {}); + expect(body.data.manifest.specifiers).toEqual(localizationSettingsManifest.specifiers); + }); + + it('heads the page with the lock and its users once used, in the request language', async () => { + const { svc, rates, objects, call } = pageRig(); + await svc.set('localization', 'currency', 'USD', ctx); + objects.push(INVOICE); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const en = (await call('GET', {})).body.data.manifest.specifiers[0]; + expect(en).toMatchObject({ + type: 'info_banner', + id: SETTINGS_LIVE_STATUS_BANNER_ID, + label: 'Default currency locked: USD', + bannerText: 'In use by 1 object (Invoice) and 1 exchange rate. Converted amounts are stored in this currency, so it can no longer be changed.', + bannerSeverity: 'info', + }); + const zh = (await call('GET', { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' })).body.data.manifest.specifiers[0]; + expect(zh.label).toBe('默认币种已锁定:USD'); + expect(zh.bannerText).toBe('已被 1 个对象(Invoice)和 1 条汇率使用。换算后的金额都以该币种存储,因此不能再修改。'); + }); + + it('warns while the currency is unset and something already needs it', async () => { + const { rates, call } = pageRig(); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const banner = (await call('GET', { 'accept-language': 'ja-JP' })).body.data.manifest.specifiers[0]; + expect(banner).toMatchObject({ label: '既定の通貨が設定されていません', bannerSeverity: 'warning' }); + expect(banner.bannerText).toContain('使用状況: 1 件の為替レート。'); + }); + + it('answers a refused save 400 SETTINGS_VALIDATION, the refusal in the request language', async () => { + const { svc, rates, call } = pageRig(); + await svc.set('localization', 'currency', 'USD', ctx); + rates.push({ organization_id: ORG, currency: 'EUR' }); + const { status, body } = await call('PUT', { 'accept-language': 'es-ES' }, { currency: 'EUR' }); + expect(status).toBe(400); + expect(body.error.code).toBe('SETTINGS_VALIDATION'); + expect(body.error.details.fields[0]).toMatchObject({ field: 'currency', code: 'invalid_value' }); + expect(body.error.details.fields[0].message).toMatch(/^La moneda predeterminada está bloqueada en USD porque está en uso en 1 tipo de cambio\./); + }); +}); + +describe('registerLocalizationCurrencyLock', () => { + it('registers nothing on a service without the localization namespace', () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(mailSettingsManifest); + expect(registerLocalizationCurrencyLock(svc, fakeEngine().engine)).toBe(false); + }); +}); diff --git a/packages/services/service-settings/src/localization-currency-lock.ts b/packages/services/service-settings/src/localization-currency-lock.ts index 935c6da06dc..54f836fd634 100644 --- a/packages/services/service-settings/src/localization-currency-lock.ts +++ b/packages/services/service-settings/src/localization-currency-lock.ts @@ -151,13 +151,13 @@ export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Curr 'zh-CN': { objects: (n, labels) => `${n} 个对象(${labels})`, rates: (n) => `${n} 条汇率`, - and: '和', + and: '和 ', listSeparator: '、', refusal: (current, usage) => - `默认币种已锁定为 ${current}:它已被${usage}使用。修改它会给所有已换算成该币种的金额换上错误的币种。` + + `默认币种已锁定为 ${current}:它已被 ${usage}使用。修改它会给所有已换算成该币种的金额换上错误的币种。` + '只有在不再有任何汇率记录、也没有任何对象声明本位币金额之后,才能修改。', lockedLabel: (current) => `默认币种已锁定:${current}`, - lockedText: (usage) => `已被${usage}使用。换算后的金额都以该币种存储,因此不能再修改。`, + lockedText: (usage) => `已被 ${usage}使用。换算后的金额都以该币种存储,因此不能再修改。`, unsetLabel: '尚未设置默认币种', unsetText: (usage) => `汇率和本位币金额都需要默认币种,但目前尚未设置。使用情况:${usage}。请在下方选择;保存后将无法修改。`, @@ -165,7 +165,7 @@ export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Curr 'ja-JP': { objects: (n, labels) => `${n} 件のオブジェクト(${labels})`, rates: (n) => `${n} 件の為替レート`, - and: 'と', + and: 'と ', listSeparator: '、', refusal: (current, usage) => `既定の通貨は ${current} でロックされています。${usage}で使用されているためです。` + diff --git a/packages/services/service-settings/src/settings-write-predicate.test.ts b/packages/services/service-settings/src/settings-write-predicate.test.ts new file mode 100644 index 00000000000..788f4cba158 --- /dev/null +++ b/packages/services/service-settings/src/settings-write-predicate.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The settings write predicate (ADR-0144 D4): a rule over engine state that a + * package registers on one manifest key, asked by `setMany` when a write + * CHANGES that key. The home-currency lock is its first user + * (`localization-currency-lock.test.ts`); this suite pins the mechanism. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { SettingsService } from './settings-service.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; +import { + SettingsValidationError, + UnknownKeyError, + UnknownNamespaceError, + type SettingsWritePredicate, +} from './settings-service.types.js'; + +const ORG = 'org_alpha'; +const ctx = { tenantId: ORG, userId: 'usr_admin' }; + +function rig(predicate: SettingsWritePredicate) { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + const spy = vi.fn(predicate); + svc.registerWritePredicate('localization', 'currency', spy); + return { svc, spy }; +} + +const refuseAll: SettingsWritePredicate = () => ({ message: 'No.', constraint: { why: 'test' } }); + +describe('SettingsService write predicate', () => { + it('is asked with the value in force, the value to store and the organization the row lands in', async () => { + const { svc, spy } = rig(() => undefined); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.set('localization', 'currency', 'EUR', ctx); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0][0]).toMatchObject({ + namespace: 'localization', key: 'currency', current: null, next: 'USD', organizationId: ORG, + }); + expect(spy.mock.calls[1][0]).toMatchObject({ current: 'USD', next: 'EUR' }); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('EUR'); + }); + + it('is not asked when the write stores the value already in force', async () => { + const { svc, spy } = rig(() => undefined); + await svc.set('localization', 'currency', 'USD', ctx); + spy.mockClear(); + await svc.set('localization', 'currency', 'USD', ctx); + await svc.setMany('localization', { timezone: 'Europe/Paris' }, ctx); + expect(spy).not.toHaveBeenCalled(); + }); + + it('refuses the whole batch, before any write, with the refusal at the key', async () => { + const { svc } = rig(refuseAll); + const err = await svc + .setMany('localization', { currency: 'EUR', timezone: 'Europe/Paris' }, ctx) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(SettingsValidationError); + expect((err as SettingsValidationError).fields).toEqual([ + { field: 'currency', code: 'invalid_value', message: 'No.', label: 'Default currency', constraint: { why: 'test' } }, + ]); + expect((await svc.get('localization', 'currency', ctx)).value ?? null).toBeNull(); + expect((await svc.get('localization', 'timezone', ctx)).value).toBe('UTC'); + }); + + it('asks about a clear too: clearing a set value is a change', async () => { + const { svc, spy } = rig(({ current }) => (current ? { message: 'Locked.' } : undefined)); + await svc.set('localization', 'currency', 'USD', ctx); + await expect(svc.set('localization', 'currency', null, ctx)).rejects.toBeInstanceOf(SettingsValidationError); + expect(spy.mock.calls.at(-1)![0]).toMatchObject({ current: 'USD', next: null }); + expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); + }); + + it('judges the declared constraints first: a malformed value is told about its own fault', async () => { + const { svc, spy } = rig(refuseAll); + const err = (await svc.set('localization', 'currency', 'ZZZ', ctx).catch((e: unknown) => e)) as SettingsValidationError; + expect(err.fields[0].code).toBe('value_domain'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('fails the write when the predicate throws: the state it guards could not be read', async () => { + const { svc } = rig(() => { + throw new Error('rate table unreachable'); + }); + await expect(svc.set('localization', 'currency', 'USD', ctx)).rejects.toThrow('rate table unreachable'); + expect((await svc.get('localization', 'currency', ctx)).value ?? null).toBeNull(); + }); + + it('refuses a predicate for an unregistered namespace or an undeclared key', () => { + const { svc } = rig(() => undefined); + expect(() => svc.registerWritePredicate('nope', 'currency', () => undefined)).toThrow(UnknownNamespaceError); + expect(() => svc.registerWritePredicate('localization', 'nope', () => undefined)).toThrow(UnknownKeyError); + }); +}); From efa072b0af0b97bdcac4d4c87e4dc2ca26259cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:12:09 +0000 Subject: [PATCH 09/11] refactor(service-settings): the display language travels beside the settings context, not in it Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude --- .../services/service-settings/src/index.ts | 1 + .../src/localization-currency-lock.test.ts | 2 +- .../src/localization-currency-lock.ts | 8 +++---- .../service-settings/src/settings-routes.ts | 8 ++++--- .../service-settings/src/settings-service.ts | 12 +++++++++-- .../src/settings-service.types.ts | 21 ++++++++++++++----- .../src/settings-write-predicate.test.ts | 2 +- 7 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 5bbf1c372f0..92f254a4ef0 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -41,6 +41,7 @@ export { type SettingsWritePredicate, type SettingsWritePredicateInput, type SettingsWriteRefusal, + type SettingsDisplayOptions, SETTINGS_LIVE_STATUS_BANNER_ID, type SettingsAuditSink, type SettingsContext, diff --git a/packages/services/service-settings/src/localization-currency-lock.test.ts b/packages/services/service-settings/src/localization-currency-lock.test.ts index 807ef704af1..2fef4ade1e3 100644 --- a/packages/services/service-settings/src/localization-currency-lock.test.ts +++ b/packages/services/service-settings/src/localization-currency-lock.test.ts @@ -235,7 +235,7 @@ describe('the copy — every locale the settings bundle ships', () => { await svc.set('localization', 'currency', 'USD', ctx); objects.push(INVOICE); rates.push({ organization_id: ORG, currency: 'EUR' }, { organization_id: ORG, currency: 'JPY' }); - const err = await refusalOf(svc.set('localization', 'currency', 'EUR', { ...ctx, locale })); + const err = await refusalOf(svc.setMany('localization', { currency: 'EUR' }, ctx, { locale })); expect(err.fields[0].message.startsWith(opening)).toBe(true); }); }); diff --git a/packages/services/service-settings/src/localization-currency-lock.ts b/packages/services/service-settings/src/localization-currency-lock.ts index 54f836fd634..b9a116e1486 100644 --- a/packages/services/service-settings/src/localization-currency-lock.ts +++ b/packages/services/service-settings/src/localization-currency-lock.ts @@ -233,11 +233,11 @@ function usageConstraint(usage: CurrencyUsage): Record { * refused while the currency is used. Setting it while unset passes. */ export function localizationCurrencyWritePredicate(engine: CurrencyUsageEngine): SettingsWritePredicate { - return async ({ current, organizationId, ctx }) => { + return async ({ current, organizationId, locale }) => { if (isUnset(current)) return undefined; const usage = await readCurrencyUsage(engine, organizationId ?? null); if (!isUsed(usage)) return undefined; - const copy = currencyLockCopy(ctx.locale); + const copy = currencyLockCopy(locale); return { message: copy.refusal(String(current), describeCurrencyUsage(copy, usage)), constraint: usageConstraint(usage), @@ -257,12 +257,12 @@ export function localizationCurrencyLiveStatus( engine: CurrencyUsageEngine, currentCurrency: (ctx: Parameters[0]['ctx']) => Promise, ): SettingsLiveStatusProvider { - return async ({ ctx, organizationId }): Promise => { + return async ({ ctx, organizationId, locale }): Promise => { const organization = await organizationId(); if (organization === undefined) return undefined; const usage = await readCurrencyUsage(engine, organization); if (!isUsed(usage)) return undefined; - const copy = currencyLockCopy(ctx.locale); + const copy = currencyLockCopy(locale); const described = describeCurrencyUsage(copy, usage); const current = await currentCurrency(ctx); if (isUnset(current)) { diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index aeaac5fdfcb..19b90512ee2 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -205,7 +205,8 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined) * The caller's display language, read the way REST's metadata door reads it: * the top `Accept-Language` tag, else `?locale`. It only picks the language of * prose a write predicate or a live-status provider composes - * (`SettingsContext.locale`); it authorizes nothing. + * (`SettingsDisplayOptions`); it authorizes nothing, and it never enters the + * caller's context. */ function requestLocaleOf(req: IHttpRequest): string | undefined { const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language']; @@ -295,7 +296,7 @@ export function registerSettingsRoutes( // `getNamespace`, which in-process consumers call to read values: the // mail plugin reads `getNamespace('mail')` while rebuilding the very // transport its statement waits on. - const liveStatus = await service.liveStatusOf(ns, { ...ctx, locale: requestLocaleOf(req) }); + const liveStatus = await service.liveStatusOf(ns, ctx, { locale: requestLocaleOf(req) }); const manifest = liveStatus ? { ...payload.manifest, specifiers: [liveStatus, ...payload.manifest.specifiers] } : payload.manifest; @@ -353,8 +354,9 @@ export function registerSettingsRoutes( const result = await service.setMany( ns, dropEchoedSecretMasks(body, secretKeys), + ctx, // The language a write predicate's refusal is written in. - { ...ctx, locale: requestLocaleOf(req) }, + { locale: requestLocaleOf(req) }, ); // The write response carries resolved values too — including cascade // entries the caller never submitted (an upper-scope secret it may not diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 939acd74b3b..627600abd3b 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -23,6 +23,7 @@ import { type SettingsLiveStatus, type SettingsLiveStatusProvider, type SettingsWritePredicate, + type SettingsDisplayOptions, SETTINGS_LIVE_STATUS_BANNER_ID, type SettingsAuditSink, type SettingsContext, @@ -1619,6 +1620,7 @@ export class SettingsService { async liveStatusOf( namespace: string, ctx: SettingsContext = {}, + display: SettingsDisplayOptions = {}, ): Promise { const provider = this.liveStatusProviders.get(namespace); if (!provider) return undefined; @@ -1628,6 +1630,7 @@ export class SettingsService { namespace, ctx, organizationId: async () => organizationReachRowOwner(await this.organizationReachOf(ctx)), + locale: display.locale, }); } catch (err: any) { this.logger?.warn?.( @@ -2277,11 +2280,15 @@ export class SettingsService { * naming each such key (`invalid_value`, `constraint: { scope: 'user' }`): * the row it would store names no owner, and the user rung answers only its * owner, so no reader would ever see it while the write reported success. + * + * `display` picks the language of a write predicate's refusal, and nothing + * else (see {@link refuseByWritePredicates}). */ async setMany( namespace: string, patch: Record, ctx: SettingsContext = {}, + display: SettingsDisplayOptions = {}, ): Promise> { // The pre-bind window: refuse rather than resolve against a store nothing // will ever read. See `assertEngineBound`. @@ -2365,7 +2372,7 @@ export class SettingsService { // may refuse a change the declarations above accept. After them, so a // malformed value is told about its own fault first; before the crypto // pass and every write, so a refused batch writes nothing. - await this.refuseByWritePredicates(reg, namespace, patch, ctx, reach); + await this.refuseByWritePredicates(reg, namespace, patch, ctx, reach, display); // #8026 — a declared-encrypted key with nothing able to encrypt it fails // the WHOLE batch, here, rather than part-way down the write loop below. @@ -2579,6 +2586,7 @@ export class SettingsService { patch: Record, ctx: SettingsContext, reach: OrganizationReach | undefined, + display: SettingsDisplayOptions, ): Promise { const byKey = this.writePredicates.get(namespace); if (!byKey) return; @@ -2591,7 +2599,7 @@ export class SettingsService { if (stableStringify(next) === stableStringify(current)) continue; const scope = reg.scopes.get(key)!; const organizationId = scope === 'global' || !reach ? undefined : organizationReachRowOwner(reach) ?? null; - const refusal = await predicate({ namespace, key, next, current, organizationId, ctx }); + const refusal = await predicate({ namespace, key, next, current, organizationId, ctx, locale: display.locale }); if (!refusal) continue; const spec = ((reg.manifest.specifiers ?? []) as Array>) .find((s) => s.key === key); diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index d93f2cb8c52..1d5231f8518 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -82,12 +82,19 @@ export interface SettingsContext { * resolver sets it to have its held sessions refused too. */ authGate?: AuthGate; +} + +/** + * How a settings call's computed prose is written — never who calls or what + * they may do, which is {@link SettingsContext}'s alone. + */ +export interface SettingsDisplayOptions { /** * The caller's display language (BCP-47), for prose a * {@link SettingsWritePredicate} refusal or a {@link SettingsLiveStatusProvider} - * statement composes. The settings routes set it from the request - * (`Accept-Language`, else `?locale`). Never an authorization input and never - * read by the resolver; absent, such prose is written in English. + * statement composes. The settings routes pass it from the request + * (`Accept-Language`, else `?locale`). Absent, such prose is written in + * English. */ locale?: string; } @@ -348,11 +355,13 @@ export interface SettingsWritePredicateInput { */ organizationId: string | null | undefined; ctx: SettingsContext; + /** The language the refusal is written in — {@link SettingsDisplayOptions.locale}. */ + locale?: string; } /** * A predicate's refusal. `message` is the sentence the caller reads under the - * field, written in `ctx.locale`; `constraint` carries its discrete values + * field, written in the input's `locale`; `constraint` carries its discrete values * (`FieldError.constraint`, ADR-0114), so a client can compose its own text. */ export interface SettingsWriteRefusal { @@ -412,7 +421,9 @@ export type SettingsLiveStatusProvider = (input: { * Resolved only when called. */ organizationId: () => Promise; -}) =>Promise | SettingsLiveStatus | undefined; + /** The language the statement is written in — {@link SettingsDisplayOptions.locale}. */ + locale?: string; +}) => Promise | SettingsLiveStatus | undefined; /** * The `info_banner` id that carries a namespace's live status. Reserved: a diff --git a/packages/services/service-settings/src/settings-write-predicate.test.ts b/packages/services/service-settings/src/settings-write-predicate.test.ts index 788f4cba158..fbb96bb354c 100644 --- a/packages/services/service-settings/src/settings-write-predicate.test.ts +++ b/packages/services/service-settings/src/settings-write-predicate.test.ts @@ -69,7 +69,7 @@ describe('SettingsService write predicate', () => { const { svc, spy } = rig(({ current }) => (current ? { message: 'Locked.' } : undefined)); await svc.set('localization', 'currency', 'USD', ctx); await expect(svc.set('localization', 'currency', null, ctx)).rejects.toBeInstanceOf(SettingsValidationError); - expect(spy.mock.calls.at(-1)![0]).toMatchObject({ current: 'USD', next: null }); + expect(spy.mock.calls[spy.mock.calls.length - 1][0]).toMatchObject({ current: 'USD', next: null }); expect((await svc.get('localization', 'currency', ctx)).value).toBe('USD'); }); From 5e49fa73b3b40666dfcd0bd22116cdea5ccc1a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:15:14 +0000 Subject: [PATCH 10/11] chore(changeset): the home-currency lock Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude --- .changeset/27198-home-currency-lock.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/27198-home-currency-lock.md diff --git a/.changeset/27198-home-currency-lock.md b/.changeset/27198-home-currency-lock.md new file mode 100644 index 00000000000..bf86c04d280 --- /dev/null +++ b/.changeset/27198-home-currency-lock.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-settings': patch +--- + +feat(service-settings): the default currency (`localization.currency`) is locked once an exchange rate or a home-currency amount uses it, and the Localization page names what uses it (ADR-0144 D4) + +Clause-②: yes (widening) +Metadata-protocol: none + +⛔ Not released alone: this ships in the same release as the exchange-rate table and the home-currency amount it guards (ADR-0144 D9, release R2). + +The organization's default currency is its home currency: exchange rates are entered against it, and home-currency amounts are converted into it and stored. Changing it afterwards would relabel every amount already converted. So: + +- **The save is refused once the currency is used.** A change to a set default currency, to another code or to empty, is refused with `400 SETTINGS_VALIDATION` while the organization holds any exchange rate row, or any object declares `currencyConfig.homeAmount` on a field. The refusal sits on the `currency` field (`invalid_value`), names what uses it ("in use by 2 objects (Invoice, Order) and 14 exchange rates"), and carries the same facts as `constraint: { lockedBy: 'in_use', objects, exchangeRates }`. Re-saving the value in force is never refused, so the rest of the page still saves. +- **Choosing a first currency is never blocked.** While the currency is unset, nothing has been converted into it, so the first save passes; the page then asks for one. +- **The page says so.** Settings → Localization opens with a banner naming the lock and what uses the currency, or a warning while it is unset and already needed, in English, 简体中文, 日本語 or Español, following the request's language. +- **It lifts by itself** once the last exchange rate row is deleted and the last `homeAmount` declaration is removed. Nothing is recorded at boot; every save and every page read asks the engine. + +New for packages that own state a setting depends on: `SettingsService.registerWritePredicate(namespace, key, predicate)`, a rule over engine state asked when a write changes that key. A refusal refuses the whole batch before any row is written. Exported with its types `SettingsWritePredicate`, `SettingsWritePredicateInput`, `SettingsWriteRefusal` and `SettingsDisplayOptions` (the display language `setMany` and `liveStatusOf` now accept beside the caller's context). From e4dc4e97fed4d6f1f988961a789e4275168e5fea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:47:14 +0000 Subject: [PATCH 11/11] fix(skills): drop the homeAmount paragraph the main merge doubled; the file is main's again Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude --- skills/objectstack-data/rules/field-types.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 1bcf225fe43..fee7a516257 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -181,11 +181,6 @@ 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`. -`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