Skip to content

Commit f3d286c

Browse files
committed
fix(spec): record currency mode refuses an empty pointer and an inert defaultCurrency
Contract-review should-fixes on the record currency mode (ADR-0144 D2): - `currencyField` is never empty (`.min(1)`), so no record amount carries a pointer the object door names as nothing. - `defaultCurrency` is refused under `record`, at the key, with a prescription. `record` is new, so no stored row needs the tolerance `dynamic` keeps; the inert key is ADR-0078's shape. - The object door reads `VALUE_DOMAIN_FIELD_TYPES` instead of repeating the `'text'` literal. - ADR-0144 anchors on field.zod.ts and object.zod.ts. - The changeset lists `@objectstack/platform-objects`, which ships the regenerated metadata-form bundles. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
1 parent 9011464 commit f3d286c

6 files changed

Lines changed: 79 additions & 31 deletions

File tree

‎.changeset/27184-currency-record-mode.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@objectstack/spec': minor
33
'@object-ui/i18n': minor
4+
'@objectstack/platform-objects': minor
45
---
56

67
feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2)
@@ -17,7 +18,7 @@ fields: {
1718
}
1819
```
1920

20-
- `currencyField` is required under `record` and refused under `dynamic` and `fixed`.
21+
- `currencyField` is required under `record`, refused under `dynamic` and `fixed`, and never empty; `defaultCurrency` is refused under `record`.
2122
- The object refuses a `currencyField` that names no other declared field, a field that is not `text`, or a `text` field without `valueDomain: 'iso_4217_currency'`. Each refusal is located at `fields.FIELD.currencyConfig.currencyField` and lists the object's candidate code fields.
2223
- `dynamic` and `fixed` configs parse exactly as before; an app that never writes `record` sees no change.
2324

‎packages/spec/src/data/currency-config-record-mode.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* amount points at a sibling ISO 4217 code field on the same object.
66
*
77
* Two doors, one per half: `CurrencyConfigSchema` refuses the half one config
8-
* can see (`record` without a pointer, a pointer under another mode), and the
8+
* can see (`record` without a pointer or with a `defaultCurrency`, a pointer
9+
* under another mode, an empty pointer), and the
910
* object's `superRefine` refuses what the pointer names (nothing, a non-text
1011
* field, a text field with no ISO 4217 value domain). A `formula` declaring
1112
* `returnType: 'currency'` carries the same config, so it meets both doors.
@@ -59,6 +60,31 @@ describe('`CurrencyConfigSchema`: `record` ⇔ `currencyField`', () => {
5960
expect(r.error.issues[0]!.message).toMatch(/^`currencyField` is read only under `currencyMode: 'record'`/);
6061
});
6162

63+
it('refuses an empty `currencyField`, at the key, in every mode', () => {
64+
for (const mode of ['record', 'dynamic', 'fixed'] as const) {
65+
const config = mode === 'fixed' ? { currencyMode: mode, defaultCurrency: 'USD', currencyField: '' } : { currencyMode: mode, currencyField: '' };
66+
const r = CurrencyConfigSchema.safeParse(config);
67+
expect(r.success, mode).toBe(false);
68+
if (r.success) continue;
69+
// Under `dynamic` / `fixed` the out-of-mode refusal is told too, at the same key.
70+
expect(r.error.issues.length, mode).toBe(mode === 'record' ? 1 : 2);
71+
expect(r.error.issues.every((i) => i.path.join('.') === 'currencyField'), mode).toBe(true);
72+
expect(r.error.issues[0]!.message).toMatch(/^`currencyField` names a field: an empty name points at nothing/);
73+
}
74+
});
75+
76+
it('refuses `defaultCurrency` under `record`, at the key, with the prescription', () => {
77+
const r = CurrencyConfigSchema.safeParse({ ...RECORD, defaultCurrency: 'EUR' });
78+
expect(r.success).toBe(false);
79+
if (r.success) return;
80+
expect(r.error.issues).toHaveLength(1);
81+
expect(r.error.issues[0]!.code).toBe('custom');
82+
expect(r.error.issues[0]!.path).toEqual(['defaultCurrency']);
83+
expect(r.error.issues[0]!.message).toMatch(/^`defaultCurrency` is not read under `currencyMode: 'record'`/);
84+
// …and `dynamic` keeps tolerating its inert code, for the rows #27157 left behind.
85+
expect(CurrencyConfigSchema.safeParse({ currencyMode: 'dynamic', defaultCurrency: 'EUR' }).success).toBe(true);
86+
});
87+
6288
it('refuses `currencyField` under the defaulted mode (no `currencyMode` written ⇒ dynamic)', () => {
6389
expect(CurrencyConfigSchema.safeParse({ currencyField: 'currency_code' }).success).toBe(false);
6490
});

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -535,16 +535,29 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly<Record<string, string>>
535535
};
536536

537537
/**
538-
* [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`. A `record`-mode
539-
* amount has no currency until a pointer names the sibling code field, and a
540-
* pointer under `dynamic` / `fixed` is read by nothing — the declared-but-inert
541-
* shape ADR-0078 keeps out. One located issue at `['currencyField']` either
542-
* way; what the pointer NAMES is the object's to judge (object.zod.ts).
538+
* [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`, and no
539+
* `defaultCurrency` beside it. A `record`-mode amount has no currency until a
540+
* pointer names the sibling code field; a pointer under `dynamic` / `fixed` is
541+
* read by nothing, and neither is a `defaultCurrency` under `record` — each is
542+
* the declared-but-inert shape ADR-0078 keeps out. `record` is new, so no
543+
* stored row carries the second one (unlike `dynamic`, whose inert code is
544+
* tolerated for the rows #27157 left behind). One located issue per inert or
545+
* missing key; what the pointer NAMES is the object's to judge (object.zod.ts).
543546
*/
544-
function refuseCurrencyFieldOutsideRecordMode(
545-
config: { currencyMode: 'dynamic' | 'fixed' | 'record'; currencyField?: string },
547+
function refuseRecordModeKeyMismatch(
548+
config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string },
546549
ctx: z.RefinementCtx,
547550
): void {
551+
if (config.currencyMode === 'record' && config.defaultCurrency !== undefined) {
552+
ctx.addIssue({
553+
code: 'custom',
554+
path: ['defaultCurrency'],
555+
message:
556+
`\`defaultCurrency\` is not read under \`currencyMode: 'record'\`, where each record names its own currency in `
557+
+ 'the field `currencyField` names: delete `defaultCurrency`, or use `currencyMode: \'fixed\'` if the field has '
558+
+ 'one currency.',
559+
});
560+
}
548561
if (config.currencyMode === 'record' && config.currencyField === undefined) {
549562
ctx.addIssue({
550563
code: 'custom',
@@ -621,21 +634,22 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({
621634
aliases: { mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' },
622635
guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE,
623636
}, {
624-
currencyMode: z.enum(['dynamic', 'fixed', 'record']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is not read. The value is a bare number in every mode (ADR-0104 D1).'),
625-
defaultCurrency: z.string().length(3).optional().describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, nor under `record`, where each record names its own.'),
637+
currencyMode: z.enum(['dynamic', 'fixed', 'record']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1).'),
638+
defaultCurrency: z.string().length(3).optional().describe('The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own.'),
626639
// ADR-0144 D2 — the pointer of a `record`-mode amount. Optional at the key
627-
// level; `refuseCurrencyFieldOutsideRecordMode` below makes it required
628-
// under `record` and refuses it under the other two modes, and the object's
629-
// `superRefine` (`refuseDishonestRecordCurrencyField`, object.zod.ts) checks
630-
// what it names, because only the object holds the sibling field map.
631-
currencyField: z.string().optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`.'),
640+
// level and never empty; `refuseRecordModeKeyMismatch` below makes it
641+
// required under `record` and refuses it under the other two modes, and the
642+
// object's `superRefine` (`refuseDishonestRecordCurrencyField`,
643+
// object.zod.ts) checks what it names, because only the object holds the
644+
// sibling field map.
645+
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.'),
632646
// #19992 — no `.overwrite()` here any more: the #7918 ISO 4217 contradiction
633647
// check and the #11423 default-materializing `.overwrite()` both existed only
634648
// for the removed `precision` key (see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE
635-
// above). #27157 — `fixed` ⇒ a code; ADR-0144 D2 — `record` ⇔ a pointer, both
636-
// below. Parse output is `{ currencyMode }` plus the `defaultCurrency` and
649+
// above). #27157 — `fixed` ⇒ a code; ADR-0144 D2 — `record` ⇔ a pointer and
650+
// no code, both below. Parse output is `{ currencyMode }` plus the `defaultCurrency` and
637651
// `currencyField` the author wrote.
638-
}).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseCurrencyFieldOutsideRecordMode));
652+
}).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseRecordModeKeyMismatch));
639653

640654
/**
641655
* Address Schema — structured address for the `address` field type.

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { z } from 'zod';
4-
import { FieldSchema, FieldType } from './field.zod';
4+
import { FieldSchema, FieldType, VALUE_DOMAIN_FIELD_TYPES } from './field.zod';
55
import { ValidationRuleSchema } from './validation.zod';
66
import { ActionSchema, type ActionParam } from '../ui/action.zod';
77
import { ObjectListViewSchema } from '../ui/view.zod';
@@ -1857,8 +1857,11 @@ function refuseNonPictureImageField(
18571857
* prescription and the object's candidate fields:
18581858
*
18591859
* - the name is not a declared field (or is the amount itself);
1860-
* - the field is not `text`;
1861-
* - the field is `text` without `valueDomain: 'iso_4217_currency'`.
1860+
* - the field's type is not one that may declare a value domain
1861+
* (`VALUE_DOMAIN_FIELD_TYPES`, field.zod.ts — `text` alone today; the ADR's
1862+
* "text field" rests on that set, so this door reads it rather than
1863+
* repeating the literal);
1864+
* - the field is of such a type without `valueDomain: 'iso_4217_currency'`.
18621865
*
18631866
* Judged against the AUTHORED field map. Not judged on
18641867
* `ObjectExtensionSchema`: an extension's fields merge into an object it does
@@ -1872,15 +1875,17 @@ function refuseDishonestRecordCurrencyField(
18721875
if (fields === null || typeof fields !== 'object') return;
18731876
const objectName = typeof ownName === 'string' ? ownName : '<unnamed>';
18741877
const map = fields as Record<string, { type?: unknown; valueDomain?: unknown; currencyConfig?: unknown } | null>;
1878+
const isCodeType = (type: unknown): boolean => typeof type === 'string' && VALUE_DOMAIN_FIELD_TYPES.has(type);
1879+
const codeTypes = [...VALUE_DOMAIN_FIELD_TYPES].map((t) => `\`${t}\``).join(' or ');
18751880
const isCodeField = (f: (typeof map)[string]): boolean =>
1876-
f?.type === 'text' && f.valueDomain === 'iso_4217_currency';
1881+
isCodeType(f?.type) && f?.valueDomain === 'iso_4217_currency';
18771882
for (const [name, field] of Object.entries(map)) {
18781883
const config = field?.currencyConfig as { currencyMode?: unknown; currencyField?: unknown } | undefined;
18791884
if (config?.currencyMode !== 'record' || typeof config.currencyField !== 'string') continue;
18801885
const target = config.currencyField;
18811886
const candidates = Object.keys(map).filter((k) => k !== name && isCodeField(map[k]!));
18821887
const prescription =
1883-
'`currencyField` must name a `text` field of this object declaring `valueDomain: \'iso_4217_currency\'` — '
1888+
`\`currencyField\` must name a ${codeTypes} field of this object declaring \`valueDomain: 'iso_4217_currency'\` — `
18841889
+ (candidates.length > 0
18851890
? `candidates: ${candidates.map((c) => `\`${c}\``).join(', ')}.`
18861891
: 'the object has none; declare one, conventionally `currency_code: Field.text({ valueDomain: \'iso_4217_currency\' })`.');
@@ -1896,20 +1901,20 @@ function refuseDishonestRecordCurrencyField(
18961901
continue;
18971902
}
18981903
const type = map[target]?.type;
1899-
if (type !== 'text') {
1904+
if (!isCodeType(type)) {
19001905
ctx.addIssue({
19011906
code: 'custom',
19021907
path,
19031908
message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object `
1904-
+ `\`${objectName}\`; a record currency is a \`text\` ISO 4217 code. ${prescription}`,
1909+
+ `\`${objectName}\`; a record currency is an ISO 4217 code in a ${codeTypes} field. ${prescription}`,
19051910
});
19061911
continue;
19071912
}
19081913
if (!isCodeField(map[target]!)) {
19091914
ctx.addIssue({
19101915
code: 'custom',
19111916
path,
1912-
message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`text\` field on object \`${objectName}\` `
1917+
message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object \`${objectName}\` `
19131918
+ `that does not declare \`valueDomain: 'iso_4217_currency'\`, so a write could store any string as the `
19141919
+ `currency. Add \`valueDomain: 'iso_4217_currency'\` to \`${target}\`, or point at another field. ${prescription}`,
19151920
});
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"file": "packages/spec/src/data/field.zod.ts",
33
"adrs": [
4-
"ADR-0120"
4+
"ADR-0120",
5+
"ADR-0144"
56
],
6-
"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."
7+
"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."
78
}

0 commit comments

Comments
 (0)