Skip to content

Commit e2c5077

Browse files
committed
fix(spec,objectql): word the currency scale refusal's remedy as ruled
The refusal of `scale` on a `currency` field, its ADR-0087 semantic entry `field-currency-scale-refused`, the `scale` describe, the changeset and the validator's comments now prescribe one thing: delete the key. A currency's decimal places are the currency's: its ISO 4217 minor unit decides its display, and its write allowance stays unconstrained (today's contract). No other key is named as a place to move the value. What is refused and what is accepted do not change. The spec pins now hold the ruled wording on the message (first sentence, both ruled clauses) and the absence of any other key, beside the firing and dark controls. The registry is regenerated with gen:migration-registry. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07c9be3 commit e2c5077

9 files changed

Lines changed: 172 additions & 142 deletions

File tree

‎.changeset/19629-currency-scale-retired.md‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,25 @@ Clause-②: no (narrowing)
99

1010
**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. A `currency` field that declares `scale` — any value, `scale: 0` included — no longer parses. The hand-migration prescription is registered under protocol major 18 as `field-currency-scale-refused`.
1111

12-
On a currency field the key was three-faced. The metadata-admin field designer offered it as stored metadata; the amount's cell never read it, because a currency amount's fraction digits come from its currency's own ISO 4217 minor unit; and the record validator's `max_scale` branch still refused writes carrying more decimals. An author who set `scale: 3` bought a narrower write contract and no visible change. The maintainer's ruling retires the key from the type rather than aligning the money faces to it.
12+
A currency's decimal places are the currency's, not a setting. On a currency field the key was three-faced. The metadata-admin field designer offered it as stored metadata; the amount's cell never read it, because a currency amount's fraction digits come from its currency's own ISO 4217 minor unit; and the record validator's `max_scale` branch still refused writes carrying more decimals. An author who set `scale: 3` bought a narrower write contract and no visible change. The maintainer's rulings retire the key from the type rather than aligning the money faces to it.
1313

14-
**`@objectstack/spec`** — `FieldSchema` refuses `scale` on `type: 'currency'` with a located issue at `scale` whose remedy names `currencyConfig.precision`. No alias and no grace window. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched, and the key's describe now names that set.
14+
**`@objectstack/spec`** — `FieldSchema` refuses `scale` on `type: 'currency'` with a located issue at `scale`. Its remedy: delete the key; the currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained. The remedy names no other key to carry the value. No alias and no grace window. `scale` on `number`, `percent`, `rating`, `slider` and `formula` is untouched, and the key's describe now names that set.
1515

16-
**`@objectstack/objectql`** — the record validator's `max_scale` branch no longer reads `scale` for `currency`, so the type leaves the enforced set. A field definition that reaches the validator without passing `FieldSchema` (stored before this release, or built by hand at runtime) therefore narrows nothing either. `min`, `max` and the finite-number check still apply to `currency`, and `number` / `percent` / `rating` / `slider` still refuse over-scale writes exactly as before. A currency write with more decimals than a former `scale` is now ACCEPTED. Enforcing `currencyConfig.precision` on writes instead was offered to the maintainer and not taken.
16+
**`@objectstack/objectql`** — the record validator's `max_scale` branch no longer reads `scale` for `currency`, so the type leaves the enforced set. A field definition that reaches the validator without passing `FieldSchema` (stored before this release, or built by hand at runtime) therefore narrows nothing either. `min`, `max` and the finite-number check still apply to `currency`, and `number` / `percent` / `rating` / `slider` still refuse over-scale writes exactly as before. A currency write with more decimals than a former `scale` is now ACCEPTED: the write allowance stays unconstrained, the contract every currency field without `scale` already had. Enforcing a currency width on writes instead was offered to the maintainer and not taken.
1717

1818
## FROM → TO
1919

2020
| you wrote | write instead |
2121
|:--|:--|
22-
| `Field.currency({ label: 'Amount', scale: 2 })` | `Field.currency({ label: 'Amount' })` — delete the key |
23-
| `{ type: 'currency', scale: 2, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | `{ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 } }` — or just delete `scale` |
24-
| `{ type: 'currency', scale: 2, currencyConfig: { precision: 2, … } }` | `{ type: 'currency', currencyConfig: { precision: 2, … } }` — delete `scale` |
22+
| `Field.currency({ label: 'Amount', scale: 2 })` | `Field.currency({ label: 'Amount' })` |
23+
| `{ type: 'currency', scale: 2, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` | `{ type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }` |
2524

26-
Delete the key by default. Move the value to `currencyConfig.precision` only on a field that already declares a `currencyConfig`. ⛔ Never add a `currencyConfig` just to carry the value: that block materializes `currencyMode: 'dynamic'` and `defaultCurrency: 'CNY'`, and the second changes the currency the field resolves. Under `currencyMode: 'fixed'` a moved value that contradicts the fixed currency's ISO 4217 digits is refused there in turn.
25+
The one-line fix: delete `scale` from every `currency` field. Nothing replaces it, so ⛔ do not re-declare the value under any other key. The currency's ISO 4217 minor unit decides how the amount displays.
2726

2827
What an upgrade changes beyond the refusal:
2928

3029
- **Writes.** A currency value with more decimals than the deleted `scale` is accepted where it used to answer `VALIDATION_FAILED` with field code `max_scale`.
31-
- **Two console faces, until the console catches up.** In the console version pinned when this landed, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`. A deleted `scale: 2` therefore renders those two faces with zero decimals until the console derives them from the currency the way the cell does. The cell and the edit widget are unaffected.
30+
- **Two console faces.** At the console pin measured when this change was written, the grid summary footer and the dashboard metric widget read a currency column's `scale ?? 0`. This change lands only after the console derives both faces from the currency, the way the cell does, and after this repository's console pin has moved past that console change. So in the console bundled with this release, deleting `scale` changes neither face.
3231

3332
## Who is affected, measured
3433

‎content/docs/protocol/objectql/schema.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ fields:
289289
| `valueDomain` | `string` | `text` | Standard the written value must belong to: `iana_time_zone`, `iso_4217_currency` or `iso_3166_alpha2` (the closed vocabulary shared with settings specifiers). |
290290
| `min` | `number` | `number`, `currency` | Minimum numeric value. |
291291
| `max` | `number` | `number`, `currency` | Maximum numeric value. |
292-
| `scale` | `number` | `number`, `percent`, `rating`, `slider` | Decimal places, enforced on writes. Refused on `currency`: a currency amount's decimal places come from its currency's ISO 4217 minor unit. |
292+
| `scale` | `number` | `number`, `percent`, `rating`, `slider` | Decimal places, enforced on writes. Refused on `currency` — delete it there: a currency amount's decimal places are its currency's, so its ISO 4217 minor unit decides the display. |
293293
| `precision` | `number` | `number`, `currency` | Total digits (including scale). |
294294
| `options` | `array` | `select`, `multiselect` | List of valid values. |
295295
| `multiple` | `boolean` | `select`, `lookup` | Allow multiple selections. |

‎packages/objectql/src/validation/record-validator.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,10 +1280,11 @@ describe('validateRecord — a fraction-stored percent derives `scale + 2` (#193
12801280
* a hand-built runtime schema) narrows nothing either.
12811281
*
12821282
* Three pins, per the ruling: a currency write with more decimals is ACCEPTED
1283-
* when nothing declares a width (today's contract, unchanged); the same write
1284-
* is accepted with a legacy `scale` on the def (the change); and `number` /
1285-
* `percent` still refuse the identical over-scale write (the controls that
1286-
* prove the branch is live in this harness, not deleted).
1283+
* when nothing declares a width (today's contract, unchanged — ruling
1284+
* 5805782503, letter 乙: a currency's write allowance stays unconstrained);
1285+
* the same write is accepted with a legacy `scale` on the def (the change);
1286+
* and `number` / `percent` still refuse the identical over-scale write (the
1287+
* controls that prove the branch is live in this harness, not deleted).
12871288
*/
12881289
describe('validateRecord — `currency` is outside the max_scale enforced set (#19629)', () => {
12891290
const fieldsOf = (

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -792,18 +792,21 @@ function validateOne(
792792
// ── `currency` is OUTSIDE the enforced set (#19629) ──
793793
// Maintainer ruling 5791803339 (batch #215 item 1, letter B): `scale` is
794794
// retired from the `currency` type. `FieldSchema` refuses the key there at
795-
// parse, with a remedy naming `currencyConfig.precision`, so no authored
796-
// currency field declares it any more — and this branch stops reading
797-
// `def.scale` for the type, so a declaration that reaches here anyway (a
798-
// stored field that predates the refusal, a hand-built runtime schema)
799-
// narrows nothing either. The key was the currency field's one enforced
800-
// effect: the amount's cell never read it, so an author who set it bought a
801-
// narrower write contract and no visible change. `min` / `max` and the
802-
// finite-number check above still apply to `currency` unchanged.
803-
// ⛔ Not replaced by a read of `currencyConfig.precision`: enforcing that
804-
// width on writes (the ruling's B′) was offered and NOT taken — a currency
795+
// parse, with the remedy ruling 5805782503 (batch #218 item 2, letter 乙)
796+
// words (delete the key; the currency's ISO 4217 minor unit decides its
797+
// display), so no authored currency field declares it any more — and this
798+
// branch stops reading `def.scale` for the type, so a declaration that
799+
// reaches here anyway (a stored field that predates the refusal, a
800+
// hand-built runtime schema) narrows nothing either. The key was the
801+
// currency field's one enforced effect: the amount's cell never read it,
802+
// so an author who set it bought a narrower write contract and no visible
803+
// change. `min` / `max` and the finite-number check above still apply to
804+
// `currency` unchanged.
805+
// ⛔ Not replaced by a read of any other key: a currency's write
806+
// allowance stays unconstrained (ruling 乙, today's contract) — a currency
805807
// write carries whatever decimals it carries, exactly as it always has on
806-
// a currency field that declared no `scale`.
808+
// a currency field that declared no `scale`. Enforcing a currency width on
809+
// writes (the first ruling's B′) was offered and NOT taken.
807810
if (
808811
t !== 'currency' &&
809812
def.scale !== undefined &&

‎packages/spec/src/data/field-currency-scale-refused.test.ts‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
/**
44
* #19629 — `scale` is RETIRED from the `currency` field type (maintainer ruling
55
* 5791803339, batch #215 item 1, letter B): a currency field carrying `scale`
6-
* is refused at parse, with a remedy naming `currencyConfig.precision`.
6+
* is refused at parse. The remedy is worded by ruling 5805782503 (batch #218
7+
* item 2, letter 乙 — a currency's decimal places are the currency's, not a
8+
* setting): delete the key; the currency's ISO 4217 minor unit decides its
9+
* display, and its write allowance stays unconstrained. It names no other key
10+
* to carry the value, and the remedy pins below hold both halves: the ruled
11+
* wording is present, and the retired pointer is absent.
712
*
813
* On a currency field the key was offered by the field designer, never read by
914
* the amount's cell, and still enforced on writes by `packages/objectql`'s
@@ -29,16 +34,28 @@ function scaleIssues(result: { success: boolean; error?: { issues: Issue[] } }):
2934
return result.success ? [] : result.error!.issues.filter((i) => i.path[i.path.length - 1] === 'scale');
3035
}
3136

37+
/**
38+
* The ruled remedy, held on the message itself: the wording is the contract
39+
* here (ruling 乙 rules the prescription, not only the refusal), so the pin
40+
* reads the first sentence verbatim, the two ruled clauses, and the ABSENCE of
41+
* any key the author could be sent to instead.
42+
*/
43+
function expectRuledRemedy(message: string): void {
44+
expect(message.startsWith('`scale` is not valid on a `currency` field — delete the key.')).toBe(true);
45+
expect(message).toContain('the currency\'s ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the amount displays');
46+
expect(message).toContain('the field\'s write allowance stays unconstrained');
47+
expect(message).not.toMatch(/currencyConfig|precision/);
48+
}
49+
3250
describe('#19629 — `scale` on a `currency` field is refused at parse', () => {
33-
it('refuses the designer-produced shape, located at `scale`, with the remedy naming `currencyConfig.precision`', () => {
51+
it('refuses the designer-produced shape, located at `scale`, with the ruled remedy: delete the key, and no other key named', () => {
3452
const result = FieldSchema.safeParse({ name: 'amount', label: 'Amount', type: 'currency', scale: 3 });
3553
expect(result.success).toBe(false);
3654
const issues = scaleIssues(result);
3755
expect(issues).toHaveLength(1);
3856
expect(issues[0].code).toBe('custom');
3957
expect(issues[0].path).toEqual(['scale']);
40-
// The ruled remedy's named subject — the one knob the refusal points to.
41-
expect(issues[0].message).toContain('`currencyConfig.precision`');
58+
expectRuledRemedy(issues[0].message);
4259
});
4360

4461
it('refuses every declared value, `scale: 0` and `scale: 2` included — it is the key that is retired', () => {
@@ -49,16 +66,20 @@ describe('#19629 — `scale` on a `currency` field is refused at parse', () => {
4966
}
5067
});
5168

52-
it('refuses it beside a `currencyConfig` too — the width key is not an alias for it', () => {
69+
it('refuses it beside a `currencyConfig` too, with the same remedy — the block neither licenses the key nor receives its value', () => {
5370
const result = FieldSchema.safeParse({
5471
name: 'amount',
5572
label: 'Amount',
5673
type: 'currency',
5774
scale: 2,
58-
currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
75+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
5976
});
6077
expect(result.success).toBe(false);
61-
expect(scaleIssues(result)).toHaveLength(1);
78+
const issues = scaleIssues(result);
79+
expect(issues).toHaveLength(1);
80+
// The field that most invites a "move it into the block" remedy gets the
81+
// same prescription as every other: delete the key.
82+
expectRuledRemedy(issues[0].message);
6283
});
6384

6485
it('fires through the `Field.currency()` helper and `ObjectSchema` — the path an object document crosses', () => {
@@ -71,6 +92,7 @@ describe('#19629 — `scale` on a `currency` field is refused at parse', () => {
7192
const issues = scaleIssues(result);
7293
expect(issues).toHaveLength(1);
7394
expect(issues[0].path).toEqual(['fields', 'amount', 'scale']);
95+
expectRuledRemedy(issues[0].message);
7496
});
7597
});
7698

@@ -81,14 +103,20 @@ describe('#19629 — CONTROLS: what the refusal must leave alone', () => {
81103
expect(FieldSchema.parse(once)).toEqual(once);
82104
});
83105

84-
it('the remedy target parses: `currencyConfig.precision` on a currency field', () => {
85-
const result = FieldSchema.safeParse({
86-
name: 'amount',
87-
label: 'Amount',
88-
type: 'currency',
89-
currencyConfig: { precision: 3, currencyMode: 'fixed', defaultCurrency: 'KWD' },
90-
});
91-
expect(result.success).toBe(true);
106+
it('following the remedy parses: each refused shape, with `scale` deleted and nothing added, is accepted', () => {
107+
const refused: Record<string, unknown>[] = [
108+
{ name: 'amount', label: 'Amount', type: 'currency', scale: 3 },
109+
{ name: 'amount', label: 'Amount', type: 'currency', scale: 2, min: 0, currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'KWD' } },
110+
];
111+
for (const shape of refused) {
112+
expect(FieldSchema.safeParse(shape).success).toBe(false);
113+
const remedied = { ...shape };
114+
delete remedied.scale;
115+
expect(Object.keys(remedied)).toEqual(Object.keys(shape).filter((k) => k !== 'scale'));
116+
const result = FieldSchema.safeParse(remedied);
117+
expect(result.success, JSON.stringify(remedied)).toBe(true);
118+
if (result.success) expect('scale' in result.data).toBe(false);
119+
}
92120
});
93121

94122
it('every type the key still applies to keeps it — number, percent, rating, slider', () => {
@@ -99,9 +127,11 @@ describe('#19629 — CONTROLS: what the refusal must leave alone', () => {
99127
}
100128
});
101129

102-
it('the describe names the type set the key still applies to, and the currency refusal', () => {
130+
it('the describe names the type set the key still applies to, and the currency refusal with the ruled remedy', () => {
103131
const description = (FieldSchema.shape as Record<string, { description?: string }>).scale?.description ?? '';
104132
expect(description).toContain('Applies to `number`, `percent`, `rating` and `slider`');
105-
expect(description).toContain('REFUSED on a `currency` field');
133+
expect(description).toContain('REFUSED on a `currency` field — delete it there');
134+
expect(description).toContain('the currency\'s ISO 4217 minor unit decides how the amount displays');
135+
expect(description).not.toContain('currencyConfig');
106136
});
107137
});

‎packages/spec/src/data/field-scale.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,18 @@
6868
* - `currency` — ruled since, and not by a row here: `scale` is RETIRED from
6969
* the `currency` type (#19629, ruling 5791803339 letter B) and refused at
7070
* parse, so a currency field declares no `scale` for this table to default.
71-
* The amount's cell resolves its fraction digits from the currency's own
72-
* ISO 4217 minor-unit count and never read the key; measured at
71+
* Ruling 5805782503 (letter 乙) words the refusal's remedy: a currency's
72+
* decimal places are the currency's, not a setting — its ISO 4217 minor
73+
* unit decides its display. The amount's cell resolves its fraction
74+
* digits from the currency's own ISO 4217 minor-unit count and never read
75+
* the key; measured at
7376
* `.objectui-sha` pin `62597c588072` (the pin when the ruling landed), the
7477
* grid summary footer and the dashboard metric widget did read it on a
7578
* currency column, as `scale ?? 0` — the ruling's consumer half is
76-
* objectui#10221. The one `2` that looks
77-
* like a currency default belongs to an inline grid COLUMN's rounding of a
78-
* computed result, not to a field's display width.
79+
* objectui#10221, which ruling 乙 widens to that metric widget and lands
80+
* first. The one `2` that looks like a currency default belongs to an
81+
* inline grid COLUMN's rounding of a computed result, not to a field's
82+
* display width.
7983
*
8084
* ⛔ Neither reading is an argument for a `0` row: `number` still awaits its
8185
* own ruling, and `currency` no longer has a declaration to resolve.

0 commit comments

Comments
 (0)