You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit adbdbc5
Browse filesBrowse the repository at this point in the historyBrowse files
fix(objectql): a fraction-stored percent's max_scale allowance derives from its displayed scale (#19442)
Fixes#19320
Clause-②: yes (widening)
⭐ Declared from the **measurement**, ⛔ not from the shape of the change:
the accept-set delta over 1950 cells is **36 ADDED / 0 REMOVED**, so the
narrowing arm is empty. ⚠️ The seat rewrote this line from a backticked,
prose-trailing spelling that the repo’s own `readClause2Line` reads as
`{kind: near-miss, reason: describing}` — a near-miss is ⛔ not a
declaration, and `Check Changeset`’s level axis would have had no input
from this body.
✅ **Both halves of the ruling are now here.** The behaviour half (the
validator's percent arm) and the `packages/spec` docblock half landed in
the same branch; the docblock half needed a file outside the boundary
this card was dispatched with, was reported rather than taken, and was
then authorized by the dispatching seat. The closing keyword is
therefore a closing keyword.
## The ruling this makes live
Maintainer ruling batch #161 item 3 letter B (objectui#9810, comment
`5729749935`, 「其他同意」 2026-09-18T12:07Z). Quoted, not translated:
> - `packages/spec` `FieldSchema.scale` docblock (and the field
reference page): for `percent`, `scale` is the number of decimal places
of the percentage-point value as displayed and entered; stored precision
follows the storage scale (`fraction` ⇒ `scale + 2` places; `whole` ⇒
`scale`).
> - `record-validator.ts` `max_scale` branch: when `def.type ===
'percent'` and `percentScaleOf(def) === 'fraction'`, compare against
`def.scale + 2`; a pin per storage scale (fraction `scale: 2` accepts
`0.1234`, refuses `0.12345`; whole `scale: 2` unchanged).
## Premise re-verification — first-hand, on today's tip, with a lit
control
The card's premise was second-hand. Both halves were re-measured against
`origin/main` at base `24162f95`, through the **real** record validator
imported from the built `dist` of `@objectstack/objectql` — no harness,
no source shortcut.
| case | ruling B requires | measured BEFORE this PR |
| --- | --- | --- |
| fraction `scale: 2`, write `0.1234` (scale+2 places) | ACCEPT |
**REFUSE** `max_scale` `{scale:2, actual:4}` |
| fraction `scale: 2`, write `0.123` (scale+1) | ACCEPT | **REFUSE**
`max_scale` `{scale:2, actual:3}` |
| fraction `scale: 3`, write `0.33333` (the ruling's 33.333%) | ACCEPT |
**REFUSE** `max_scale` `{scale:3, actual:5}` |
| fraction `scale: 0`, write `0.33` | ACCEPT | **REFUSE** `max_scale`
`{scale:0, actual:2}` |
| fraction `scale: 2`, write `0.12345` (scale+3) | REFUSE | REFUSE
(agrees) |
| whole `max: 100, scale: 2`, write `12.34` / `12.345` | ACCEPT / REFUSE
| ACCEPT / REFUSE (agrees) |
**Lit control, same instrument, same run** — so the refusals above are a
reading and not a dead instrument: the validator ACCEPTED `0.12` and
`0.5` on the same field, and REFUSED for three *different* reasons —
`max_value` on `max: 1` with `5`, `min_value` on `min: 0` with `-0.5`,
and `invalid_number` on `'abc'`. `number` / `currency` / `slider` all
refused at `scale + 1` in the same run.
Docblock half, read at source: `FieldSchema.scale`'s `.describe()`
(`packages/spec/src/data/field.zod.ts`) states the 0-100 platform
ceiling and nothing about `percent`; `percentScaleOf`'s docblock
(`packages/spec/src/data/percent-scale.ts`) states the fraction/whole
rule and says nothing about `scale`. **Both halves of the card hold. The
premise is TRUE.**
## Accept-set delta — measured in BOTH directions
Both predicates (current and ruled) were run over an exhaustive corpus
of 1,950 cells: 5 numeric field types x 5 `max` declarations x 6 `scale`
values x 13 decimal-place counts.
```
corpus cells: 1950 unchanged: 1914 ADDED (accept set grows): 36 REMOVED (accept set shrinks): 0
declaration classes whose allowance moves: percent max=undefined, percent max=0.5, percent max=1
```
- The narrowing arm is **empty** — 0 of 1,950 cells. Nothing that writes
today stops writing; no stored value is re-read; no migration is
implied.
- Only fraction-stored `percent` moves. `percent` with `max` above 1,
and `number` / `currency` / `slider` / `rating` at every `max`, are
byte-identical in verdict.
- ⇒ `Clause-②: yes (widening)` is what the measurement supports. It was
dispatched as a claim to check; the claim survives the check.
⚠️ **One flag for the contract review, not a re-adjudication.** The
ruling's own Execution section declares `Clause-②: no`. The mechanical
criterion in `pm-dispatch` is 「本卡放宽接受集或扩大公开面吗」, and the accept set is
measurably relaxed, so the conservative routing arm is `yes`. The
declaration is by design provisional (「按设计临时…⛔ 非终审」), so this is a
routing difference to be recorded at review, not a change to the ruling.
## What this PR implements
`packages/objectql/src/validation/record-validator.ts` — the `max_scale`
branch gains its percent arm. The fraction/whole split is **read from
the spec's `percentScaleOf`**, not re-derived from `max` at this seam,
so the edit widget, the analytics wire and the validator keep answering
from one source.
The refusal envelope now reports the allowance that was **applied**: on
a fraction-stored `scale: 2` field, `0.12345` is still refused and
reports `constraint: { scale: 4, actual: 5 }`. Reporting the raw
declaration beside a stored fraction's place count would render "must
have at most 2 decimal places (got 5)" on a field that accepts four — a
true refusal described by a false constraint. This is the one detail the
ruling's letter leaves open; it is decided in the direction that keeps
the machine-readable surface honest, and it is pinned.
## The spec half — and the boundary that gated it
The ruling's first bullet is the `packages/spec` `FieldSchema.scale`
docblock **and the field reference page it generates**. That is
`packages/spec/src/data/field.zod.ts`, which was **outside** the
four-file boundary this card was dispatched with, so it was reported
before being touched rather than taken quietly. The two `packages/spec`
paths the dispatch originally named (`numeric-column-representation.ts`
and its test) are about the **DDL column** (`numeric_precision` /
`numeric_scale`) and mention `percentScaleOf` only inside a prose
comment — they are not part of this repair and are untouched.
What landed, after the seat authorized the corrected surface:
- `packages/spec/src/data/field.zod.ts` — `FieldSchema.scale`'s
`.describe()` now states **both** meanings: what the number counts on a
`percent` field (decimal places of the displayed percentage-point value)
and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒
`scale`, every other numeric type ⇒ `scale`). Both halves go in the
**describe**, not only in a source comment, because the reference page
is generated from the describe and an author who reads only that page is
the author the ruling is about.
- `content/docs/references/data/field.mdx`, `data/object.mdx`,
`system/migration.mdx` — regenerated by `pnpm --filter @objectstack/spec
gen:schema && gen:docs`, ⛔ never hand-edited. **Exactly those three
tracked files moved and nothing else**, which is what the pre-commit
source/regeneration split was arranged to make legible: the source edits
were committed first, so the regeneration commit's file list is the
regeneration's own output.
- `packages/spec/src/data/percent-scale.ts` — the optional
cross-reference, **taken**. The card's own measurement table named
*this* docblock as the one silent about `scale`, and `percentScaleOf` is
the function the validator calls, so a reader who lands here should find
the consequence rather than re-derive it. Written as a pointer, ⛔ not a
second copy: the rule is stated once on `FieldSchema.scale` and enforced
once in the validator.
**Serial constraint re-measured for those paths** before any of it was
written, same instrument as the dispatch used: 20 open PRs, `GET
/pulls/N/files` each, **0 unreadable file lists**, and no open PR
holding any of the eight paths. Lit control on the same run:
`packages/spec/src/**` matches 7 open PRs (#19398, #19374, #19373,
#19335, #19314, #19090, #18319), so the zeros are absences the
instrument could see.
## Verification
**Ablation** — `scripts/ablation-replace.mjs`, anchor `? def.scale + 2`
in the production file.
- ⚠️ The **first attempt was a no-op and its reading is void**: the
replacement string was a prefix of the anchor, so its occurrence count
could not rise, and the tool refused before running anything. Recorded
rather than quietly retried.
- Second attempt landed: anchor `x1 -> x0`, blob `2e2d3981d29a ->
7b082941b44f`, command executed, restore proved `blob == HEAD
(2e2d398)` with `git diff HEAD` empty.
- Under ablation: **8 failed / 100 passed (108)**. All 8 are in the new
block and fail for the right reason — the fraction-stored writes are
refused with `max_scale`, and the envelope/message read `2` where the
ruling requires the applied `4`.
- ⭐ **5 of the 13 new cases cannot discriminate, and are not counted as
evidence**: the scale+3 refusal, the whole-percent pin, the
other-numeric-types controls, the other-refusal-reasons control and the
no-declared-scale control pass on both trees **by design** — they are
anti-vacuity and lit-control pins, there so that a branch which simply
stopped enforcing `scale` on percent fails this block too.
**Gate family** — derived from the real changed paths with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, every command run with its exit code
captured before any pipe, reconciled with `--ran` carrying the recorded
codes:
```
111 derived · 111 run · 107 green · 0 red · 4 NOT MEASURED · 0 unrun
```
- One real red was found and repaired in this PR:
`check:error-code-casing` read the envelope pin's bare `code:
'max_scale'` as an ADR-0112 D1 emission because its recognizer window
held no field-addressed neighbour. Naming the field is the repair and a
stronger assertion. Re-run exit 0: "no unlisted lowercase error codes in
6371 scanned file(s)".
- NOT MEASURED, each with its reason, none of them a red:
- `check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: reads
built output, 66 packages have no `dist/` in this worktree.
- `check:type-check-debt` — exit 3, PREREQUISITE NOT MET: needs the
whole workspace closure built.
- `check-plugin-teardown-shape.mjs --self-test` — exit 3: its
positive-control fixture is pinned to a commit this shallow clone cannot
reach.
- `check-engine-split-ratio.mjs --days 90` — exit 2: refuses to compute
an ADR-0076 D7 ratio on a shallow clone whose oldest visible commit sits
inside the window. Counted as "run" by the reconciler (exit 2 is not the
prerequisite code) but it measured nothing, so it is reported here as
NOT MEASURED.
- One gate **refused its prerequisite while spelling it `exit 1`**:
`check:skill-examples` reported that `packages/client-react/dist` held
no declarations, which is a refusal and ⛔ not a finding. Rather than
report it as NOT MEASURED, the package closure was built and the gate
re-run to a real verdict: exit 0, **258 prose examples type-check across
3 surfaces**, including the 10 spec-source TSDoc blocks — the surface
this PR edits.
- The two remaining `exit 3` families were **deliberately left
unmeasured**: both need a whole-workspace build, and both are whole-tree
families unrelated to a describe string and a validator arm. CI builds
everything and measures them there. The `check:skill-examples` closure
was built because that gate reads the spec source surface this diff
touches — the choice is principled, ⛔ not a budget.
- The reconciler's own verdict, DERIVED from the recorded codes rather
than claimed: `111 derived famil(ies) accounted for — 108 run, 3
NOT-MEASURED (3 DERIVED from a recorded exit 3)`.
**Suites and lint**, at the final commit:
- `pnpm --filter @objectstack/objectql test` — **303 files / 5050 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec test` — **505 files / 14,752 tests
passed**, exit 0; `pnpm --filter @objectstack/spec typecheck` exit 0.
- `pnpm --filter @objectstack/objectql typecheck` — exit 0;
`check:test-typecheck` OK, ledger unchanged at 40 files / 234 errors /
65 pinned signatures.
- `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 15
generated artifacts up to date** against the edited `FieldSchema.scale`.
Both gates the ruling's docblock half puts at risk are green by name:
**`check:docs`** (the three regenerated reference pages) and
**`check:authorable-surface`** (authorable surface + JSON schemas).
`authorable-surface.base.json` did not move — a regular build never
writes it.
- `pnpm lint` — repo-wide `eslint . --no-inline-config`, exit 0 at
`bb9f9274`, the final commit. The full union ran; no narrowing was
needed, so no narrowing is claimed.
- **The ablation reading still describes the shipped file**: `git
hash-object packages/objectql/src/validation/record-validator.ts` is
`2e2d3981d29a…`, byte-identical to the blob the ablation restored to, so
nothing landed on the production file after it was proved able to fail.
**Import-side pins**: the public surface of `@objectstack/objectql` is
byte-unchanged (no export added, removed or retyped), so only behaviour
could move a consumer pin. Every non-`objectql` test file mentioning
`'percent'` was checked for a co-occurring `scale`; the six hits are
`packages/spec` schema tests and two `service-analytics` wire tests,
none of which exercises the record validator. The grep returning six
files is its own lit control.
## Acceptance notes
Noted, not filed — neither meets the three filing classes, and the
carrier for each is named:
- The `max_scale` message template
(`packages/spec/src/system/validation-message.ts`) reads "must have at
most N decimal places", which on a fraction-stored percent now describes
the STORED fraction rather than the number the author typed. It is
accurate and it is not what the author sees in the widget. Whether a
percent-specific sentence is wanted is a display decision that belongs
with the ruling's author, not a defect. Carrier: the contract review on
this PR.
- `packages/spec/src/data/numeric-column-representation.ts` already
carries an accurate prose account of the fraction storage rule in its
`percent` entry. It is documentation of the column, not of `scale`, and
needs no change under this ruling. Carrier: none needed — recorded so
the next reader does not re-derive the same dead end.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
A `percent` field's declared `scale` is the number of decimal places of the **percentage-point** value as displayed and entered; the **stored** allowance now derives from it. For a fraction-stored percent the record validator's `max_scale` branch accepts `scale + 2` decimal places in the stored fraction (#19320).
9
+
10
+
Maintainer ruling batch #161 item 3 letter B (2026-09-18) settles what one word means: `scale: 2` on a percent field is two displayed decimals, so the edit widget offers `12.34` and writes the fraction `0.1234`. The branch compared those four places against the raw declaration and refused the write — an author could declare two displayed decimals and then not write two displayed decimals.
11
+
12
+
-**Which fields move**: only a **fraction-stored** percent, i.e. one whose `percentScaleOf` is `fraction` — no declared `max`, or a `max` at or below 1. A **whole-percent** field (`max` above 1) stores the displayed number itself and keeps the declared `scale` exactly, as do `number`, `currency`, `slider` and `rating`. The split is read from the spec's `percentScaleOf`, not re-decided at this seam.
13
+
-**Direction, measured in both**: over a 1,950-cell corpus of declaration x written value, **36 cells move from refused to accepted and 0 move the other way**. Nothing that writes today stops writing; no stored value is re-read or re-judged; no migration is implied.
14
+
-**`FieldSchema.scale`'s describe states both meanings**, which is the half of the ruling that makes the derivation legible to an author: what the number counts (displayed percentage points) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`). The generated field reference page carries the same sentence, and `percentScaleOf`'s docblock points at it rather than restating it.
15
+
-**The refusal envelope names the allowance that was applied.** On a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }` — previously it would have read `{ scale: 2, actual: 5 }` on a field that accepts four places, a true refusal described by a false constraint. A consumer asserting the raw declaration back out of a percent field's `max_scale` envelope reads the derived number instead.
Copy file name to clipboardExpand all lines: content/docs/references/data/field.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -68,7 +68,7 @@ const result = CurrencyConfigSchema.parse(data);
68
68
| **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. |
69
69
|**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. |
70
70
|**precision**|`integer`| optional | Total digits (non-negative integer) |
71
-
|**scale**|`integer`| optional | Decimal places (integer 0-100). 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). 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. |
72
72
|**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. |
73
73
|**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. |
74
74
|**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. |
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -230,7 +230,7 @@ const result = ApiMethod.parse(data);
230
230
| **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. |
231
231
|**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. |
232
232
|**precision**|`integer`| optional | Total digits (non-negative integer) |
233
-
|**scale**|`integer`| optional | Decimal places (integer 0-100). 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. |
233
+
|**scale**|`integer`| optional | Decimal places (integer 0-100). 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. |
234
234
|**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. |
235
235
|**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. |
236
236
|**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. |
@@ -563,7 +563,7 @@ const result = ApiMethod.parse(data);
563
563
| **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. |
564
564
|**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. |
565
565
|**precision**|`integer`| optional | Total digits (non-negative integer) |
566
-
|**scale**|`integer`| optional | Decimal places (integer 0-100). 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. |
566
+
|**scale**|`integer`| optional | Decimal places (integer 0-100). 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. |
567
567
|**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. |
568
568
|**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. |
569
569
|**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