Skip to content

Commit df5caa9

Browse files
committed
Merge origin/main (5b9402d) into claude/issue-19995-engine-door-scope-residue
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
2 parents 8df4157 + 5b9402d commit df5caa9

37 files changed

Lines changed: 573 additions & 63 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(spec,objectql)!: `scale` is retired from the `currency` field type — refused at parse, and no longer enforced on currency writes (#19629)
7+
8+
Clause-②: no (narrowing)
9+
10+
**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`.
11+
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.
13+
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. Studio's object editor no longer offers `scale` on a currency field: the fields grid of the `objectForm` this package registers in `METADATA_FORM_REGISTRY` now shows it only for `number` and `percent`.
15+
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.
17+
18+
## FROM → TO
19+
20+
| you wrote | write instead |
21+
|:--|:--|
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' } }` |
24+
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.
26+
27+
What an upgrade changes beyond the refusal:
28+
29+
- **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`.
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.
31+
32+
## Who is affected, measured
33+
34+
AST sweep on `origin/main` `1f89ba0d70`: 15 `Field.currency` declarations in `examples/` (app-crm 4, app-showcase 11) and 13 documentation code examples carried `scale`, every one `scale: 2`. All were deleted in this change. No platform object, seed or JSON fixture in the tree declares it. Seven test fixtures pinned the old shape and were re-judged. One of them, a flow oracle that needed a live `scale` gate, moved its field from `currency` to `number`.
35+
36+
<!-- adr-0087: registered field-currency-scale-refused -->

‎content/docs/concepts/architecture.mdx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export const Customer = ObjectSchema.create({
118118

119119
annual_revenue: Field.currency({
120120
label: 'Annual Revenue',
121-
scale: 2,
122121
}),
123122

124123
primary_contact: Field.lookup('contact', {
@@ -426,7 +425,6 @@ export const Opportunity = ObjectSchema.create({
426425

427426
amount: Field.currency({
428427
label: 'Amount',
429-
scale: 2,
430428
}),
431429

432430
customer: Field.lookup('customer', {

‎content/docs/concepts/metadata-driven.mdx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,6 @@ owner: Field.lookup({
450450
```typescript
451451
annual_revenue: Field.currency({
452452
label: 'Annual Revenue',
453-
scale: 2,
454453
min: 0,
455454
}),
456455
```
@@ -515,7 +514,6 @@ export const ExampleObject = ObjectSchema.create({
515514
// Currency field
516515
price: Field.currency({
517516
label: 'Price',
518-
scale: 2,
519517
min: 0,
520518
}),
521519

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ notes: Field.markdown({ label: 'Notes' }),
6262

6363
```typescript
6464
quantity: Field.number({ label: 'Quantity', min: 0, max: 10000, step: 1 }),
65-
price: Field.currency({ label: 'Price', scale: 2, min: 0 }),
65+
price: Field.currency({ label: 'Price', min: 0 }),
6666
// percent stores a fraction: 0.15 = 15% (the UI renders it as a percentage)
6767
discount: Field.percent({ label: 'Discount', scale: 2, min: 0, max: 1 }),
6868
```

‎content/docs/data-modeling/objects.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const Account = ObjectSchema.create({
2929
{ label: 'Healthcare', value: 'healthcare' },
3030
],
3131
}),
32-
annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2 }),
32+
annual_revenue: Field.currency({ label: 'Annual Revenue' }),
3333
owner: Field.user({ label: 'Owner', required: true }),
3434
},
3535

‎content/docs/data-modeling/schema-design.mdx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,6 @@ export const Account = ObjectSchema.create({
436436

437437
annual_revenue: Field.currency({
438438
label: 'Annual Revenue',
439-
scale: 2,
440439
min: 0,
441440
}),
442441

‎content/docs/protocol/kernel/plugin-spec.mdx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,6 @@ export const Account = ObjectSchema.create({
290290

291291
annual_revenue: Field.currency({
292292
label: 'Annual Revenue',
293-
scale: 2,
294293
}),
295294

296295
primary_contact: Field.lookup('contact', {

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ fields:
114114
budget:
115115
type: currency
116116
label: Budget
117-
scale: 2
118117
precision: 18
119118
account_id: # Snake case for field name
120119
type: lookup
@@ -161,7 +160,6 @@ export const Project = ObjectSchema.create({
161160
162161
budget: Field.currency({
163162
label: 'Budget',
164-
scale: 2,
165163
min: 0,
166164
}),
167165
@@ -291,7 +289,7 @@ fields:
291289
| `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). |
292290
| `min` | `number` | `number`, `currency` | Minimum numeric value. |
293291
| `max` | `number` | `number`, `currency` | Maximum numeric value. |
294-
| `scale` | `number` | `number`, `currency` | Decimal places (e.g., `2` for cents). |
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. |
295293
| `precision` | `number` | `number`, `currency` | Total digits (including scale). |
296294
| `options` | `array` | `select`, `multiselect` | List of valid values. |
297295
| `multiple` | `boolean` | `select`, `lookup` | Allow multiple selections. |
@@ -383,7 +381,6 @@ discount_rate:
383381
revenue:
384382
type: currency
385383
label: Annual Revenue
386-
scale: 2
387384
precision: 18
388385
defaultValue: 0 # currency stores a bare number — never { value, currency }
389386
```
@@ -914,7 +911,6 @@ fields:
914911
annual_revenue:
915912
type: currency
916913
label: Annual Revenue
917-
scale: 2
918914
precision: 18
919915

920916
# Relationships

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ CREATE TABLE customer (
2424
revenue:
2525
type: currency
2626
label: Annual Revenue
27-
scale: 2
2827
precision: 18
2928
# Automatically knows:
3029
# - Store amount + currency code

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const result = CurrencyConfigSchema.parse(data);
6868
| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. |
6969
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
7070
| **precision** | `integer` | optional | Total digits (non-negative integer) |
71-
| **scale** | `integer` | optional | Decimal places (integer 0-100). OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. Every other numeric type is allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
71+
| **scale** | `integer` | optional | Decimal places (integer 0-100). Applies to `number`, `percent`, `rating` and `slider` fields, where it is enforced on writes, and to a `formula` field, whose computed result is rounded to it. REFUSED on a `currency` field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a `percent` field ⇒ 0 decimal places, so a stored 0.25 reads `25%` on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED `scale: 0` (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling `resolveFieldScale` from `@objectstack/spec/data`, the single source for an absent `scale`: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read `25%` on the read-only cell and `25.00%` in the edit widget. On a `percent` field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — `scale: 2` means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no `max`, or a `max` at or below 1) stores 12.34% as 0.1234 and is allowed `scale + 2` decimal places at the write seam, while a whole-percent field (`max` above 1) stores the displayed number itself and is allowed exactly `scale`. `number`, `rating` and `slider` are allowed exactly `scale`. The upper bound is the platform's, not a policy: renderers turn `scale` into fraction digits through `toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits`, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
7272
| **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. |
7373
| **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. |
7474
| **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. |

0 commit comments

Comments
 (0)