Skip to content

Commit 5b9402d

Browse files
fix(spec,objectql)!: retire scale from the currency field type — refused at parse, no longer enforced on writes (#19909)
Fixes #19629 Clause-②: no (narrowing) Executes maintainer ruling `5791803339` (batch #215 item 1, letter B, 「215 同意」): `scale` is retired from the `currency` field type. Its refusal remedy and its ADR-0087 migration entry are worded by maintainer ruling 乙 `5805782503` (batch #218 item 2, 「其他同意」), item 1: > PR #19909's refusal remedy and ADR-0087 migration entry are reworded: delete `scale` on a currency field; the currency's ISO 4217 minor unit decides its display, and its write allowance stays unconstrained (today's contract). ⛔ No pointer to `currencyConfig.precision`. The consumer half is objectstack-ai/objectui#10221 and is not in this diff: the field designer, the grid summary footer, and, under ruling 乙 item 2, the dashboard `ObjectMetricWidget`. B′ (enforcing a currency width on writes) was not taken and is not here. ## Landing order (ruling 乙 item 2) This PR lands **after** objectstack-ai/objectui#10221 has landed (widened to `ObjectMetricWidget`) **and after** this repository's `.objectui-sha` pin bump has moved past it. That order keeps any zero-decimal window from opening on the grid summary footer or the dashboard metric widget. At this writing the pin is `62597c588072` and objectui#10221 is open with no PR (both re-read in round 3). Holding the landing to that order is the seat's. ## What changes 1. **`@objectstack/spec`, `FieldSchema`** (`packages/spec/src/data/field.zod.ts`): `scale` on a `type: 'currency'` field is refused at parse, with one `custom` issue at `scale` (the file's per-type `superRefine` house pattern). No alias and no grace window. The remedy's first sentence is "`scale` is not valid on a `currency` field — delete the key." It goes on to say that the currency's ISO 4217 minor unit decides how the amount displays, and that the field's write allowance stays unconstrained. It names no other key to carry the value. The `scale` describe names the set the key still applies to: `number`, `percent`, `rating` and `slider`, enforced on writes, and `formula`, rounded. On `currency` it now reads "REFUSED on a `currency` field — delete it there". 2. **`@objectstack/objectql`, record validator** (`packages/objectql/src/validation/record-validator.ts`): the `max_scale` branch no longer reads `def.scale` for `currency`, so the type leaves the enforced set. `min`, `max` and the finite-number check still apply to currency. `percent`, `number`, `rating` and `slider` are unchanged. Nothing else is read in its place: a currency's write allowance stays unconstrained. 3. **ADR-0087 semantic entry** `field-currency-scale-refused` (`packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts`). Its replacement is "DELETE the key — that is the whole migration", and it says nothing replaces the key. It is deliberately not a mechanical conversion: a conversion that dropped the key would accept it on every load, which is the grace window ruling B refused. `registry.ts` is regenerated with `gen:migration-registry`, never hand-edited. 4. **Shipped examples** that the refusal would reject. `scale: 2` was deleted from each, and nothing was added: - `examples/app-crm/src/objects/account.object.ts`, `opportunity.object.ts`, `opportunity-line-item.object.ts` (×2) - `examples/app-showcase/src/data/objects/account.object.ts`, `client-brief.object.ts`, `expense-report.object.ts`, `external/customer.object.ts`, `external/order.object.ts`, `field-zoo.object.ts`, `invoice.object.ts` (×3), `project.object.ts` (×2) 5. **Documentation examples.** `scale: 2` was deleted from 13 currency examples in `content/docs/concepts/architecture.mdx` (×2), `concepts/metadata-driven.mdx` (×2), `data-modeling/fields.mdx`, `data-modeling/objects.mdx`, `data-modeling/schema-design.mdx`, `protocol/kernel/plugin-spec.mdx`, `protocol/objectql/schema.mdx` (×4) and `protocol/objectql/types.mdx`. The `schema.mdx` property table's `scale` row no longer lists `currency`. It now says "Refused on `currency` — delete it there". 6. **Generated**, never hand-edited: `content/docs/references/data/field.mdx`, `content/docs/references/data/object.mdx` and `content/docs/references/system/migration.mdx` (the `scale` describe). `authorable-surface`, `api-surface`, `json-schema.manifest`, `spec-changes.json` and the upgrade guide did not move, because no key or export was added or removed. The nine `*.metadata-forms.generated.ts` i18n bundles did not move either: round 3 changes a `visibleWhen`, not a label or help text, and those bundles carry only label and help text. 7. **`packages/spec/src/data/field-scale.ts`**: the docblock's `currency` bullet records the retirement and ruling 乙's remedy. It no longer names `CurrencyConfigSchema.precision` as the money faces' override; that claim was false at the pin. 8. **`packages/spec/src/data/object.form.ts`** (round 3): the object designer's quick-add fields grid, `objectForm`, is the form `METADATA_FORM_REGISTRY` serves for the `object` metadata type. Its `scale` row's `visibleWhen` was `"data.type in ['number','currency','percent']"` and is now `"data.type in ['number','percent']"`, with a one-line comment citing this card and ruling `5791803339` B. The `precision`, `min` and `max` rows are unchanged. ## Measured first, on `origin/main` `1f89ba0d70` (built `dist`) - A currency field with `scale: 2` **parsed**: `FieldSchema.safeParse` succeeded. Control: the same with `type: 'number'` also succeeded. - The record validator **refused** a currency write over `scale`: `scale: 2`, write `1.234` → `max_scale {"scale":2,"actual":3}`. A currency field without `scale` accepted `1.23456`. - `max_scale` read `def.scale` on **all four** controls. Each was given `scale: 2` and the write `1.23456`. `number`, `rating` and `slider` answered `{scale:2, actual:5}`. `percent` answered `{scale:4, actual:5}`, because of the fraction-storage derivation. - **Population census**, a TypeScript AST sweep over every tracked `.ts`/`.tsx`/`.mjs`/`.js` file (7,036 files): **15 example sites**, all listed above, and **7 test fixtures**, listed below. A JSON walk over 556 tracked `.json` files found 0 currency-typed objects with `scale`. A windowed scan of 831 `.md`/`.mdx` files found the 13 documentation examples. The one surviving AST hit is `packages/spec/src/data/inline-related-columns.test.ts:103`. It is `InlineGridColumnSchema.scale`, a different schema that the ruling does not cover. ## Readings the round measured, and where each went 1. **`currencyConfig.precision` has no reader.** No face in the pinned console (`.objectui-sha` `62597c588072`) reads it, and no non-test code in this repository does. The cell uses the currency's ISO 4217 minor unit, and the edit widget reads the **field-level** `precision`. → Ruling 乙 item 1: the remedy and the migration entry no longer point to that key. Two findings follow from ruling 乙 item 3: `currencyConfig.precision` is declared and read by nothing, and objectui's `CurrencyField` reads the field-level `precision` as decimal places. Both are filed by the `domain:spec` seat, as #19992 and objectstack-ai/objectui#10276, ⛔ not riders here. 2. **Ruling B's control reading undercounted the examples.** Fifteen `Field.currency` declarations in `examples/` declared `scale: 2`. → Corrected on the card by `5805294161`. 3. **A fourth face.** The dashboard `ObjectMetricWidget`'s currency arm reads `valueFieldDef.scale ?? 0`. → Ruling 乙 item 2: objectui#10221 is widened to it and lands first (see the landing order above). 4. **Direction of the validator half.** It moves writes from refused to accepted. → Ruling 乙 item 4: `Clause-②: no (narrowing)` stays as ruled. The widening reading from the #19320 precedent is noted and ⛔ not re-declared. ## Round 3, on top of `40ef043003` **Why.** The at-tier contract-review record `5818558748` on this PR (head `40ef043003`) read **VERDICT: FAIL** with one required fix: `packages/spec/src/data/object.form.ts:190` still offered `scale` on a currency row of the object designer's fields grid, the form registered in `packages/spec/src/system/metadata-form-registry.ts`. After this PR an author using that form is shown a `scale` input on a currency field and is refused at publish, which is the offer-vs-door shape this retirement removes. - **`d7740c324e`**: the fix exactly as the record states it. The `scale` row's `visibleWhen` is now `"data.type in ['number','percent']"`, with the one-line comment. The row was not widened to any other type, and the `precision`, `min` and `max` rows were not touched. The same commit adds the offer-side pin and one changeset sentence (both below). `origin/main` was not merged: the PR reads `mergeable_state: clean`, and no file main changed since the merge base `c8399867b8` is a file this branch changes (`comm` of the two name lists is empty), so CI's merge ref needs nothing from a merge here. **Sweep of `packages/spec/src/**` for any other surface offering `scale` on `currency`.** All 17 `*.form.ts` files were read for `scale` rows, and every non-test source file naming both `scale` and `currency` was read at the hit. | file:line (HEAD `d7740c324e`) | what it is | changed? | |:--|:--|:--| | `data/object.form.ts:191` | the `objectForm` fields-grid `scale` row (it was `:190`) | **yes**, the record's fix | | `data/field.form.ts:79` | the `fieldForm` `scale` row, `"data.type == 'number'"` | no; it does not offer the key on currency (the record's own control) | | `data/field.zod.ts:1235-1236` | `FieldSchema.scale` and its describe, "REFUSED on a `currency` field" | no; that is the door, from round 2 | | `data/field.zod.ts:1025` | `FieldSchema` aliases `decimals` / `decimalPlaces` → `scale` | no; on currency the renamed key lands on the same refusal (acceptance note below) | | `data/field.zod.ts:420` | `CurrencyConfigSchema` alias `scale` → `precision`, inside `currencyConfig` | no; a different schema, pre-existing, and the surface of #19992 (acceptance note below) | | `data/field.zod.ts:931` | `InlineGridColumnSchema.scale` | no; a different schema, left alone as instructed | | `ui/view.zod.ts:3338` | `FormFieldSchema.scale`, a form-view row's widget override with no type gate by design (a form row usually omits `type`) | no; a different schema, and its describe names no field type | | `ui/view.zod.ts:1600` | the timeline view's `scale` (`hour` … `year`) | no; unrelated | | `ui/bulk-action.zod.ts:157` | `scale` in `BULK_PARAM_WIDGET_CONFIG_KEYS`, a list of keys bulk params REFUSE | no; a refusal list, not an offer | | `data/field-scale.ts:101` | `ABSENT_SCALE_BY_TYPE`, whose one row is `percent` | no; there is no currency row | | `data/numeric-column-representation.ts:49-52` | a dated measurement docblock that names a `currency` field with `scale: 2` refused at the write seam | no; a record of a reading, not an offer (acceptance note below) | | `studio/**` | designer hints | 0 `currency` hits | | `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts` (outside `packages/spec/src`) | the generated form bundles; `fields.scale` carries only its label and help text (`en` `:147-150`) | no; no predicate lives there, and `check:i18n` reads all nine packages' bundles in sync | A quoted-exact `git grep -n -F "['number','currency','percent']"` over the whole tree now answers one line, the `precision` row at `object.form.ts:189`, which the record says to leave. Before the fix it answered that row and the `scale` row. **The new pin** (`packages/spec/src/data/field-currency-scale-refused.test.ts`, final block). It evaluates each row's `visibleWhen` the way `form-delete-behavior-options.test.ts` already does: a fail-closed reader of the `data.type` spellings these forms use (`==` and `in [...]`, joined by `||`) that throws on anything else, so a predicate it cannot read fails the test instead of being assumed visible or hidden. - **CONTROLS** (lit): the walk over every form in `METADATA_FORM_REGISTRY` finds exactly two `scale` rows, `field:scale` and `object:fields.scale`. `METADATA_FORM_REGISTRY.object` is `objectForm`. The reader lights on a row that does name `currency`. - **Firing case**: the `objectForm` `scale` row is not offered when `data.type` is `currency`. - **Dark controls**: it is still offered for `number` and `percent`, and the door agrees on both types (`FieldSchema.safeParse` with `scale: 2` succeeds). - **Class guard**: no registered form offers `scale` on a currency field. **Old-row pins.** Nothing pinned the old row: before the fix, the quoted-exact `git grep` above found the predicate only in `object.form.ts` itself, and none of the tests that read `objectForm` or `METADATA_FORM_REGISTRY` mention `scale`. There was nothing to reverse. ## Round 2, on top of `7c0a33c6ad` - **`07c9be36bf`**: a merge of `origin/main` `c8399867b8`, run through `scripts/pm/os-regen-merge.sh`. `registry.ts` text-merged, and `gen:migration-registry` then wrote no change. `check:generated` read "All 15 generated artifacts are up to date". - Main changed none of the three reference pages this branch changes, so the script kept the branch's bytes for them. The three reference pages main did change (`api/package-api.mdx`, `security/permission.mdx`, `system/translation.mdx`) are byte-identical to `origin/main` (`git diff` empty). - Quoted-exact `git grep` survival checks: - Each of the 7 semantic entries main added since the merge base appears exactly once in both `origin/main`'s and HEAD's `registry.ts`: `admin-scope-business-unit-blank-refused`, `filter-equality-array-comparand-refused`, `flow-predicate-slot-blank-string-refused`, `package-api-contracts-unmounted-entries-retired`, `rls-predicate-array-comparand-refused`, `translation-per-app-settings-platform-only` and `view-filter-rule-absent-value-refused`. - Main's added reference line "Required and non-blank: an empty or whitespace-only value names no business unit" appears 2 times in `permission.mdx` on both sides. - Main's deleted line "POST /api/v1/packages/upgrade" appears 0 times on both sides. - **`e2c5077421`**: the rewording to ruling 乙, across the refusal message, the describe, the migration entry (with `registry.ts` regenerated), the validator's comments, the `field-scale.ts` docblock, the `schema.mdx` row, the changeset and the spec pins. What is refused and what is accepted did not change. - **`40ef043003`**: `gen:docs` regenerated the three reference pages. A later `gen:schema && gen:docs` produced no further diff, and `check:docs` read "225 generated files in sync with packages/spec". ## Tests, on HEAD `d7740c324e` Every command went through `scripts/pm/os-verify-lock.sh`. Each count is the runner's own summary line. - Build: `turbo run build` over `./packages/*`, `./packages/*/*` and `./examples/*` gave **73 successful, 73 total**. - `@objectstack/spec`: `vitest run --project local` gave **532 files, 15667 passed, 2 todo** (round 2: 15663; the 4 new offer-side tests). `--project repo` gave **35 files, 602 passed**. - `field-currency-scale-refused.test.ts` alone: **12 passed** (8 from round 2, 4 new). - `@objectstack/spec` `typecheck` exited 0. `check:test-typecheck` held 53 files, 255 errors and 142 pinned signatures, the same as rounds 1 and 2. `check:type-check-coverage` exited 0, so the package's `tsconfig` reaches the edited test file. - Consumers that read the registered forms: `@objectstack/platform-objects` translation suites gave 6 files, 137 passed; `@objectstack/lint` `validate-predicate-path-refs.test.ts` gave 1 file, 54 passed; `@objectstack/metadata-protocol` `protocol.meta-types-*` gave 3 files, 38 passed. - NOT re-run this round: `@objectstack/objectql` (on `40ef043003`: 309 files, 5196 passed; 1 file, 5 passed; `typecheck` exit 0 with 40 files, 234 errors and 65 pinned signatures held), `@objectstack/example-crm`, `@objectstack/example-showcase`, and round 1's `platform-objects`, `metadata-core`, `driver-sql` and `service-automation` suites. No file in those packages changed since `40ef043003`. They are declared to CI. ## Reverse verification All legs are one-shot proofs run from the committed state, each with a trap on EXIT/INT/TERM. No rebuild was needed, because the test file resolves `./object.form`, `./field.zod` and `../system/metadata-form-registry` through relative `src` imports. **Round 3, the offer.** `scripts/ablation-replace.mjs` restored the old row, `visibleWhen: "data.type in ['number','currency','percent']"`, on `object.form.ts`. The landing was proved on disk: new-row anchor 1 → 0, old row 0 → 1, blob `6e87b93283` → `295afbe69f`. Then `field-currency-scale-refused.test.ts` ran: **2 failed | 10 passed**. The two failures are the firing case ("expected true to be false") and the class guard (it received `object:fields.scale` where it expected none). The lit CONTROLS, the `number` and `percent` dark controls and all 8 round-2 tests stayed green. Restored: blob `6e87b93283` == HEAD, `git diff HEAD` empty, and `git status --porcelain` empty. Round 2's two legs, on `40ef043003`: - **The ruled wording.** `field.zod.ts` was restored to its round-1 blob `0c24baa382`, which carries the old remedy naming the other key. Main changed nothing in this file across the merge, so that blob differs from HEAD only by the round-2 rewording. The landing was proved by blob hash, with the old-remedy anchor ×1 and the new one ×0. Then `field-currency-scale-refused.test.ts` ran: **4 failed | 4 passed**. The three message pins went red on the first-sentence assertion, and the describe pin went red on its new clause. The firing control (every declared value refused) and the three dark controls stayed green. Restored: blob `d6ada97894` == HEAD, and `git diff HEAD` is empty. - **The absence half.** `scripts/ablation-replace.mjs` appended "Move the value to `currencyConfig.precision` instead." to the new message. Anchor 1 → 0, blob `d6ada97894` → `017fdba420`. Result: **3 failed | 5 passed**. All three failures read "not to match /currencyConfig|precision/", and the describe pin and every control stayed green. Restored: blob == HEAD, and `git diff HEAD` is empty. ## Test pins of the old remedy, reversed A repo-wide grep finds the old remedy pinned in one file only, `packages/spec/src/data/field-currency-scale-refused.test.ts`. It had a message `toContain` on the other key and a control named "the remedy target parses". Here is what replaces them: - One helper asserts the message's first sentence verbatim, the two ruled clauses, and that the message names neither `currencyConfig` nor `precision`. It runs on all three firing paths: the designer shape, the shape beside a `currencyConfig` block, and the `Field.currency()` + `ObjectSchema` path. - The control is now "following the remedy parses". It takes each refused shape, deletes `scale`, adds nothing, and the result is accepted. - The describe pin asserts the new clause and the absence of `currencyConfig`. - `packages/spec/src/data/currency-precision-iso4217.test.ts:293` names that key only as `CurrencyConfigSchema`'s ISO-contradiction issue path. It is not a remedy pin and is unchanged. ## `currencyConfig` hits that remain (`git grep` on HEAD `40ef043003`, unchanged at `d7740c324e`) - **In the branch's added lines** (`c8399867b8..HEAD`): 7 hits. None names that key as a remedy or as a decimal-places carrier. - The changeset's FROM → TO row 2 shows a field that declares a `currencyConfig` block. Its fix is still the deletion. - `field-zoo.object.ts:53` is in the diff only because `scale: 2` was deleted from that line. Its `currencyConfig: { …, precision: 2 }` is `origin/main`'s, unchanged. - `field-currency-scale-refused.test.ts` has 5 hits: two absence assertions, one test title, and two fixtures with a `currencyConfig` block and no `precision` (the firing case and the remedy-follow control). Round 3 added none. - **In the touched files, pre-existing** (the same count on `c8399867b8` and HEAD): - `fields.mdx` 1, `types.mdx` 2 and the three reference pages 2 each. The reference hits are generated from `CurrencyConfigSchema`. - Showcase `account.object.ts` 2, `field-zoo.object.ts` 1. - `field.test.ts` 34 and `field.zod.ts` 10: the `CurrencyConfigSchema` definition and tests, the ISO check, and the `currency`-is-not-a-field-key refusal. - `registry.ts` 1, in another entry. - These stay because this PR did not write them. The key's own disposition is ruling 乙 item 3's first finding, for the `domain:spec` seat. ## Test fixtures re-judged (round 1) These fixtures pinned a currency field with `scale`, or `max_scale` on currency: - `packages/spec/src/data/field.test.ts`: "should accept **number** field with precision and scale" spelled `type: 'currency'`. It now spells `number`, a spelling change only. - `packages/spec/src/data/object.test.ts`: the account fixture's `annual_revenue` lost `scale: 2`. - `packages/spec/src/data/field-scale.test.ts`: the resolver's "declared scale wins" example moved from `currency` to `slider`. - `packages/objectql/src/validation/record-validator.test.ts`: the #19320 CONTROLS loop dropped `currency`. A new #19629 block holds the three ruled pins, and its header now cites ruling 乙 for the unconstrained write allowance. - `packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts`: the #11060 oracle needs a live `scale` gate, so its `total` field moved from `currency` to `number`. - `packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts` and `packages/metadata-core/test/injected-column-provenance.test.ts` mirror the showcase external customer. Both lost `scale: 2`. ## Changeset `.changeset/19629-currency-scale-retired.md` sets `@objectstack/spec: minor` and `@objectstack/objectql: minor`, with a **BREAKING** banner. Per AGENTS.md, `Clause-②: no (narrowing)` is breaking, and `check-changeset-no-major` refuses `major`. Round 2 reworded it to ruling 乙: - Its remedy sentence and FROM → TO table now prescribe deletion only. - Its write sentence says the allowance stays unconstrained. - Its console bullet says both faces derive from the currency in the console this release bundles, because of the landing order above. Round 3 added one sentence to its `@objectstack/spec` paragraph, because the form change is visible in Studio: "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`." It names no key to carry the value, so it holds under ruling 乙. It carries the ADR-0087 marker `registered field-currency-scale-refused`. `check-adr-0087-registration` reads "`[BREAKING+bang+clause-②-narrowing]` registered field-currency-scale-refused". The test-only packages (`driver-sql`, `metadata-core`, `service-automation`) ship nothing that changed, and the examples are private. ## Acceptance notes - `docs/qa/platform-checklist/areas/records-forms.json`: the field-zoo fixture line still reads "f_currency scale 2". Its `knownGaps` text, which says `scale` is not enforced on write, has been stale since #7501. This is checklist-owned text and is left for that seat. - Four documentation YAML examples keep `precision: 18` on a currency field (`schema.mdx` ×3, `types.mdx`). They are meant as DECIMAL(18,2) total digits, but the pinned edit widget reads field-level `precision` on currency as fraction digits. This predates the PR. It is the surface of ruling 乙 item 3's second finding. - `data-modeling/fields.mdx` ("Currency Configuration") and `protocol/objectql/types.mdx` show `currencyConfig: { precision: 2, … }` as a currency's configuration. This predates the PR and is the surface of ruling 乙 item 3's first finding. - `FieldSchema`'s alias table renames `decimals` / `decimalPlaces` to `scale`. On a currency field that rename now leads to this refusal, and its remedy tells the author to delete the key. - hotcrm declares `scale: 2` on a currency quote field (hotcrm#1206, cited by the flow oracle). That becomes a refused parse downstream, and the migration entry prescribes the deletion. - objectui#10221's title still says "derive the summary footer's fraction digits from `currencyConfig.precision` / ISO minor units", which is ruling B's wording. Under ruling 乙 only the ISO half holds. Rewording that card belongs to the `domain:ui` seat. - Round 3 sweep: `CurrencyConfigSchema` (`field.zod.ts:420`) accepts `currencyConfig: { scale: … }` and renames it to `currencyConfig.precision`, the key #19992 records as read by nothing. This predates the PR and is not `FieldSchema.scale`; it goes wherever #19992's enforce-or-remove decision takes that key. - Round 3 sweep: the field editor form's `precision` row (`field.form.ts:78`) is shown on currency with the help text "Decimal places (e.g., 2 for $10.50)", while `FieldSchema.precision`'s describe (`field.zod.ts:1211`) says "Total digits" and `objectForm`'s own `precision` row says "Total digits". That is a `precision` row, which this PR does not touch; it is the spec-side neighbour of objectstack-ai/objectui#10276. - Round 3 sweep: `numeric-column-representation.ts:49-52` records a write-seam measurement in which a `currency` field declaring `scale: 2` was refused. After this PR the seam no longer reads `scale` on currency, so the docblock describes a past reading. It is an internal comment, not an offer, and it is left alone. ## Gates, on HEAD `d7740c324e` - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **120** families from this worktree. Every one was run with its exit code captured before any pipe, and **all 120 exited 0**. The `--ran` reconciliation read: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED (a DERIVED zero — all 120 recorded an exit code and none of them is 3)". The derivation warned of a stale tree: `origin/main` `e8f163fc3a` is 23 commits past the merge base, and 18 derivation inputs changed in that range (among them `.github/workflows/lint.yml` and `package.json`). CI runs the PR's merge ref with the current copies. - `pnpm --filter @objectstack/spec check:generated`: "All 15 generated artifacts are up to date". - `check:i18n`: "OK (9 package(s) — all bundles in sync, no undeclared authoring keys)". `check:nul-bytes`: OK, no raw ASCII control bytes. - `check-changeset-no-major`: no `major` bump. The level axis reads this PR body, so it is NOT MEASURED locally. - CI on `d7740c324e`, read at 2026-09-24T18:25Z: 35 check-runs, 33 `success` and 2 `skipped` (`Console Pin Gate`, `Packed-tarball smoke (opt-in)`), none failed. All seven required contexts and `Check Changeset` read `success`. - Lint was narrowed to the diff and was not run repo-wide. `eslint --no-inline-config --format json` over the 25 changed `.ts`/`.mjs` files (`c8399867b8..HEAD`, round 3 adds `object.form.ts`) reported 25 files, 0 errors, 0 warnings and no ignore notices. The narrowing holds because `eslint.config.mjs` never enables type-aware linting (its own comment at lines 326-328: no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. The full `pnpm lint` belongs to CI. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3557f85 commit 5b9402d

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)