Repository navigation
fix(spec): a currency field's precision is total digits — stop refusing precision: 18 on a fixed-USD field (#20011) - #20209
Conversation
…nst its currency's fraction digits The FieldSchema check compared the field-level `precision` (declared "Total digits") with the fixed currency's ISO 4217 fraction digits and refused `precision: 18` on a USD amount, prescribing `precision: 2`. Its premise, a renderer reading the key as the display width, is gone, and a currency's decimal places are the currency's. The `currencyConfig.precision` check is a different key and is unchanged. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…chor Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin be91d11c842b2224db031c9fb404dc9425399a26 && git checkout be91d11c842b2224db031c9fb404dc9425399a26
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c8b320a894b31dcfc76c33c8e7a62b8986d893a 0e006877963089d9c1f90b18a69614a15b3578b8 && git checkout -B drift-repro 1c8b320a894b31dcfc76c33c8e7a62b8986d893a && git merge --no-ff 0e006877963089d9c1f90b18a69614a15b3578b8
node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
… promises no default symbol (objectstack-ai#20045) (objectstack-ai#20223) Fixes objectstack-ai#20045 Clause-②: yes `Clause-②: yes` is the claim's line (comment `5854472496`), copied as it stands. The changeset carries `Clause-②: yes (narrowing)`, because AGENTS.md makes a narrowing BREAKING and `check:adr-0087-registration` reads the arm there. ⚠ This diff widens nothing: it only refuses a shape that parsed before. Read against `scripts/pm/clause2-line.mjs` (「本卡放宽接受集或扩大公开面吗」), the value that fits is `no (narrowing)`, which is ruling B's own declaration on objectstack-ai#19629. The dev report raises this as an open question. Neither line was changed on the dev's own judgement. Branch `claude/issue-20045-inline-currency-column-scale`, base `1c8b320a89`, merged with `origin/main` `b09ce67870` as `4a6e8ed61a` through `scripts/pm/os-regen-merge.sh`. Every reading below was taken at head `4a6e8ed61a` unless it says otherwise. ## What changes Triage direction: comment `5825687357`, option A. Triage read the card as inherited from ruling B `5791803339` on objectstack-ai#19629 (`scale` retired from the currency field) and ruling 乙 `5805782503` on objectstack-ai#19910 (「a currency's ISO 4217 minor unit decides its display」). 1. `InlineGridColumnSchema` (`packages/spec/src/data/field.zod.ts`) now refuses an authored `scale` on a column that declares `type: 'currency'`. The refusal is one custom issue at the column's `scale`, and it applies to any value, `scale: 0` included, computed or not. The message carries ruling B's shape: - first sentence: "`scale` is not valid on a `currency` inline grid column — delete the key." This is the field refusal's first sentence with only the subject swapped; - remedy: the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) decides how the cell displays the amount and the width a computed amount is rounded to. It names no other key. - No alias and no grace window. - `scale` on a `number` column, and on a column that declares no `type`, is unchanged. 2. The `prefix` describe no longer says 「(default '¥')」. It now reads: the symbol replaces the resolved currency's own symbol; there is no default; it replaces the symbol only. `prefix` is still accepted. The `scale` describe drops "numeric/currency" and names the currency refusal. 3. ADR-0087: the D3 semantic entry `18.inline-grid-column-currency-scale-refused` is added, and `registry.ts` was regenerated with `gen:migration-registry`. 4. The changeset makes `@objectstack/spec` `minor`, with a BREAKING banner, a FROM → TO table and the `registered` disposition marker. 5. `dropped-refinements.baseline.json` gains the new refinement's rows, exactly as the build's ratchet printed them: `data/InlineGridColumn` (root) plus one `…inlineColumns.element` site on each of the 12 published schemas that embed the column. The header totals moved with the body: 208 schemas, 590 sites after the merge. 6. `content/docs/references/data/field.mdx` was regenerated (`check:generated --fix`, which proved only `check:docs` stale). ## ADR-0087 disposition: why D3 only - **A D3 entry is owed.** Ruling B on objectstack-ai#17152 (`5615360777`, restated `5634031140`) says 「D3 每家族一条,D2 可无损表达者亦然;D2 只承载数据修复」. - **No D2 conversion.** The card inherits ruling B on objectstack-ai#19629, which says no alias and no grace window. Its own entry, `field-currency-scale-refused`, is "NOT mechanically converted, deliberately". A load-path conversion that dropped the key would accept it on every load, which is the grace window the ruling refused. This entry follows that precedent. - **No `retired-keys/` row.** The key is not removed. It stays in the walked shape and stays live on `number` columns, and a `RETIRED_KEYS_BY_MAJOR` row naming a live key reds gate (b2). ## Premise, measured - On `1c8b320a89`, `InlineGridColumnSchema` was a bare `strictObject` with no refinement, and `scale` was declared for a "computed numeric/currency result". - The ablation below is the executable half of that reading. With the new refinement short-circuited, all six refusal pins go red, because every currency-plus-`scale` shape parses again. - The objectui side was read by content: - at objectui `origin/main` `25c7d58`, `GridField.tsx` has `function currencyAdornment` ×1 and `function currencyWidth` ×1, and `c.prefix || '¥'` ×0; - at this repo's `.objectui-sha` pin `f8a9d0fb0596`, it has `c.prefix || '¥'` ×2 and the `currency ? 2` default ×1 (see Acceptance notes). ## Mechanism hypothesis H1, measured - `InlineGridColumnSchema` was at `:895`, a `strictObject`, with `prefix` at `:924` and `scale` at `:938`. **Confirmed.** - "Reuse the refusal sentence or the shared helper" was **partly falsified**. No helper exists: ruling B's sentence is a string literal inside `FieldSchema`'s `superRefine` (`:2295–2307` on base). That block is outside this claim's file surface. So the column carries the sentence and remedy in its own constant, `INLINE_GRID_CURRENCY_SCALE_REFUSAL`. - A parity pin keeps the two refusals from drifting apart. It reads both refusals and asserts that the first sentences match (subject swapped), that both carry the same minor-unit clause, and that neither names `currencyConfig` or `precision`. - `FieldSchema`'s currency-precision anchor was not touched. It changed on `main` during this run (objectstack-ai#20011, PR objectstack-ai#20209). It merged cleanly and is disjoint from this diff. ## Authored instances (H2), census on `1c8b320a89` - `git grep inlineColumns` over the whole tree gives 30 files. Exactly **one** authored block: `examples/app-showcase/src/data/objects/invoice.object.ts`, with 7 identity-only `{ name }` columns, 0 declaring `type` and 0 declaring `scale`. This is the control: the same instrument counts 7 column entries in that block. - Platform objects, `skills/**`, `content/docs/**` prose and examples, and JSON/YAML fixtures: 0 inline grid columns. - Objectui at the pin: 0 authored `inlineColumns` fixtures. Its only hits are the two renderer files. - Test fixtures: 1 carried `scale: 2` on a currency column (`inline-related-columns.test.ts`, the "every renderer-read key" case). It was re-judged: `scale` was deleted there, and the key stays covered by the identity-only computed `amount` column in the same fixture. - Deployed metadata: **NOT MEASURED**, because no instrument reaches it. ## Tests, at head `4a6e8ed61a` All heavy runs went through `scripts/pm/os-verify-lock.sh`, and each verdict below is read from its VERDICT line. - `@objectstack/spec` build: exit 0. - `check:generated`: "All 15 generated artifacts are up to date". - spec `vitest --project local`: **542 files passed; 15932 passed, 2 todo**. - spec `typecheck`: exit 0 (`check:test-typecheck: OK — 53 file(s) / 255 error(s) / 142 pinned signature(s) held`). - spec `vitest --project repo`: 32 files / 586 passed, at `4dbb609b1a` before the merge. It was not re-run on the merged head, because the run took 15m23s on a box under load 30 to 49. It is declared to CI. - The new file `inline-grid-column-currency-scale-refused.test.ts` covers: - refusal through `FieldSchema`, `ObjectSchema` and the standalone column, located at `…inlineColumns[i].scale`, with `code: 'custom'`; - every value, computed or not; - controls: a `number` column, an untyped column, `prefix` accepted, and the remedy parsing and re-parsing unchanged; - field/column shape parity; - the two describes; - the D3 entry being registered. **Ablation**, from committed state `aa4b6ca14f`, run with `scripts/ablation-replace.mjs`: - The anchor `if (column.type === 'currency' && column.scale !== undefined) {` was hit ×1 and went ×1 → ×0. The replacement `if (false && …` went ×0 → ×1. The blob changed from `147220988d04` to `ddf1ebbf0437`. - Result: **6 failed | 22 passed** (28). All six refusal/parity pins went red, and the controls stayed green, which is the expected direction. - Restore: blob `147220988d04` == HEAD, and `git diff HEAD` is empty. - The subject is imported from `src` (`./field.zod`), so no dist rebuild was involved. **Gates.** `dispatch-gates.mjs --commands` was re-derived on `4a6e8ed61a` and gave 111 families, the same list as before the merge. `--ran` reconciliation: "111 derived famil(ies) accounted for — 109 run, 2 NOT-MEASURED". - `NOT MEASURED: check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET. 33 packages have no `dist/`, and only a full-repo build satisfies that. This diff changes no entry point, export map or build config. - `NOT MEASURED: check:type-check-debt`. Before the merge it exited 3 (`@objectstack/driver-turso` unbuilt). On the merged head, with that package built, `--re-measure` began a workspace-closure `turbo build` outside the verify lock. It was stopped by its recorded PID after 9 minutes, with no verdict. This diff changes no exported type: `check:api-surface` is green, and `superRefine` keeps `InlineGridColumnSchema` a `ZodObject`. It touches none of the four DEBT-ledgered packages. ## Deviations - **File surface.** `packages/spec/dropped-refinements.baseline.json` is outside the claim's listed surface. The build refuses a new refinement without it. The file is hand-edited on purpose and has no generator, and its rows were applied exactly as the gate printed them, including the merge-time header recount. The claim's surface names "its own refinement", so this is reported as that refinement's mechanical consequence, ⛔ not as scope growth. - **Merge.** `origin/main` moved the same ledger's header totals (objectstack-ai#20204), which caused a textual conflict. It was resolved by recounting the merged body. ## Acceptance notes - **Reach of the refusal.** Only a DECLARED column `type` is judged. A column that declares no `type` gets its type from the child field when objectui hydrates it (`deriveMasterDetail.ts` `hydrateColumns`, which spreads the authored column). So `{ name: 'amount', scale: 2 }` over a currency child field still parses. The column schema cannot see the child object. Once the objectui follow-up stops reading an authored `scale` on currency columns, that key is ignored on such a column. That lands on the objectui follow-up card (triage note 4), not here. - **Pinned console vs this describe.** The new `prefix` describe ("No default") is true of objectui `main` (`25c7d58`). The console bundled at `.objectui-sha` `f8a9d0fb0596` still falls back to `¥` and rounds a computed currency with no `scale` to 2. The describe becomes true of the bundled console when the pin moves past objectui PR objectstack-ai#10405. Deleting `scale` loses no decimals under either console: the pin falls back to 2, and `main` to the ISO minor unit. So no zero-decimal window opens. - **Stale notes outside this surface**, recorded only (carrier: none): - `packages/spec/liveness/field.json` → `inlineColumns.children.prefix` / `.scale` notes still cite `c.prefix || '¥'` and `c.scale ?? currency default 2`; - the `packages/spec/src/data/field-scale.ts` docblock sentence "The one `2` that looks like a currency default belongs to an inline grid COLUMN's rounding" describes the pre-objectui#10355 grid. - **The objectui `GridField` follow-up** (drop the authored-`scale` read on currency columns) is objectui's own card, per triage note 4. Nothing in objectui changes here. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20011
Clause-②: yes
FieldSchemastops comparing a currency field's field-levelprecisionwith the ISO 4217 fraction digits of its fixed currency.precision: 18on a fixed-USD field now parses.currencyConfig.precisionis a different key, and its check is untouched.What was wrong (reproduced on
origin/maine4471e64, before any edit)FieldSchema.safeParsethrough the source, one field per row:type: 'currency')e4471e64precision: 18, fixed USD['precision']precision: 2, fixed USDprecision: 1, fixed USD['precision']precision: 18, fixed JPY['precision']precision: 0, fixed JPYprecision: 18, dynamicprecision: 18, nocurrencyConfigtype: 'number',precision: 18The refusal text for the first row, which this PR removes from the field-level path:
The key is declared
precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'). A DECIMAL(18,2) amount isprecision: 18, and the remedy asked for a total-digit count of 2. Ruling 乙 on #19910 (5805782503): 「## Ruled — 乙: a currency's decimal places are the currency's, not a setting」.Real producer: objectui's Studio field designer offers a "Precision" control on every numeric type, currency included (
packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx:917-919at the pinf8a9d0fb,patchDef({ precision: v })). An admin who types 18 on a fixed-USD currency field gets this refusal at save.The premise, re-measured (it is false everywhere)
The check's own comment named its premise: 「the FIELD-level
precisionkey doubles as the currency display width — objectui's CurrencyField reads it」. Before removing it, every face was checked for a read of field-levelprecisionas decimal places. None was found.objectui at the console pin
f8a9d0fb0596(.objectui-shaonmain).git grep -w precisionover non-testpackages/**andapps/***.ts/*.tsxgives 83 lines. Each one is one of these:CurrencyField.tsx:64-78says 「⛔ NOT the field-levelprecision」.types/src/field-types.ts:407,436,446.ObjectGrid.tsx:2863,3084,3250,RelatedList.tsx:1607,RecordDetailPanel.tsx:167,sectionFields.ts:321,ObjectGallery.tsx:367,useColumnSummary.ts:492.formatPercent's own parameter of that name. Its callers passscale.The faces that format numbers:
CurrencyFieldderives its width fromcurrencyFractionDigits(currency).CurrencyCellRenderercallsformatCurrency(num, currency, locale)with no width argument.NumberCellRendererandPercentCellRendererreadscale.formatCurrencyand reads neither key.ObjectMetricWidgetresolves only the currency.scale.objectstack. No
packages/**source reads the field-level key.NUMERIC_COLUMN_REPRESENTATION.currencyis a fixed(65, 30)column.driver-sql/src/builtin-column-collision.ts:77saysprecisionis 「this driver does not read it yet」. objectql has no write-seam total-digit check.So the check guarded a reading that no longer exists. What it still did was refuse the ruled contract.
Decision: delete the check, keep no residual
Zone 2 of the dispatch asked whether some residual stays true, for example
precisionbelow the currency's fraction digits. Measured: no numeric type has a total-digit coherence rule.{ type: 'number', precision: 1, scale: 2 }parses onmain, and so does{ type: 'percent', precision: 0, scale: 3 }. Four-axis reading:precision: 2meaning "decimals". Refusing that would narrow the accept set, which neither this card nor the dispatch authorizes; the dispatch requires thatprecision: 2keep parsing. The larger trap is that the key is inert everywhere. That goes to the seat as a finding (Acceptance notes), not a new rule here.A comment at the old site records why the comparison is absent, so it is not reinstated as an "improvement".
Changes
packages/spec/src/data/field.zod.ts: the field-levelcurrencyPrecisionContradictioncall insideFieldSchema's.superRefineis deleted, and a comment replaces it. The import comment is updated. Region fence: hunks only at the import comment and the old anchor. There is no hunk inCurrencyConfigSchema(its.superRefine/.overwriteand theprecisiondefault, [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992's region), inSelectOptionSchema, or in therequiredWhen/readonlyWhenregion.packages/spec/src/data/currency-fraction-digits.ts: doc comments only. The helper keeps both of its readers, inCurrencyConfigSchema's.superRefineand.overwrite. The docblock no longer says "shared by BOTH anchors", and it now states that the verdict is ⛔ not for the field-level key. The message text is unchanged.packages/spec/src/data/currency-precision-iso4217.test.ts: the field-level block is rewritten (pins below). The header docblock is updated.content/docs/data-modeling/field-types.mdxandvalidation-rules.mdx: both claimed a declaredprecisionmust agree with the currency. This PR makes that false for the field-level key, so the text now namescurrencyConfig.precision. It also says the field-level key is total digits and is never compared. New text infield-types.mdx:The same section's table also listed
scale("Decimal places") as a currency property. That contradicts the new paragraph and the landed spec refusal (field.zod.ts,scaleoncurrencyis refused, with the same wording inprotocol/objectql/schema.mdx), so the row is deleted..changeset/20011-currency-field-precision-total-digits.md:@objectstack/specminor,Clause-②: yes (widening). Nothing is narrowed, so there is no ADR-0087 entry (check:adr-0087-registrationexit 0).Pins (
currency-precision-iso4217.test.ts, block#20011 — field-level precision is total digits…)precision: 18on fixed USD parses, anddata.precisionis still 18.precision: 2on fixed USD still parses.precision: 2on fixed JPY parses (DECIMAL(2,0)). This is the oldrejects … (JPY + 2)pin.precision: 1parses, asnumberwithprecision: 1, scale: 2does.currencyConfig, dynamic authored or defaulted), nothing changes. Non-currency types are untouched.ObjectSchema.createwith a fixed-USDprecision: 18field, thenObjectSchema.safeParseof the JSON round-trip, succeeds and keeps 18.currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' }throughFieldSchemais refused at['currencyConfig', 'precision'], codecustom. The message containscurrency JPY has 0 fraction digitsand the prescriptionDeclare \precision: 0``.both anchors fire independentlypin. With both keys authored, exactly one issue is raised, at['currencyConfig', 'precision'](code + path + prescription asserted). The field-level key raises nothing.#7918currencyConfig-level block, the#11423materialization block and the digit-table block are untouched and green.Pin sweep. Searched for
fraction digits;andcontradicts it —across objectstack, objectui at the pin, and cloud48d70663. The only field-level pins were in this test file. The othercurrencyMode: 'fixed'fixtures (field.test.ts,field-currency-scale-refused.test.ts, threeservice-analyticstests) carry no field-levelprecision.Reverse verification
Run on the committed fix
accdab16withscripts/ablation-replace.mjsplus its owntraprestore. The mutation re-inserts the old field-level check under a marker. The marker count was 0, then 1 during the run, then 0 after. The blob went26f8ef39→5c392b24and back to26f8ef39.Expected direction: red. Observed: 6 failed, 24 passed, and the six are exactly the new acceptance pins. Restore was proven by blob == HEAD and an empty
git diff HEAD, not by an exit code.The first attempt was a no-op. My replacement contained the anchor text, so the anchor count could not drop, and the tool refused it before running the test. It was redone with a replacement that drops the anchor. The later merge of
maintouched neither mutated file.Producer census (authored currency fields with a field-level
precision)The detector parses
Field.currency({…})andtype: 'currency'object literals and reads their top-level keys.precisione4471e64(examples/**,packages/**)object.test.ts:800= 18,lint-liveness-properties.test.ts:780= 2)f8a9d0fb48d70663precisionanywhere inpackages/**The one live producer is the Studio designer control named above.
Verification (final head
0e006877, after mergingmainat172b4cf3)@objectstack/specsuite (vitest run --project local): 540 files, 15871 passed, 2 todo.@objectstack/spectypecheck (tsc --noEmit, scripts, test layer): exit 0, and the test-layer debt ledger held.check:generated: all 15 generated artifacts up to date.@objectstack/lint108 files / 4183 passed;@objectstack/platform-objects55 / 911 passed.dispatch-gates.mjs --commandsderived 104 commands for these 6 paths. All 104 exit 0 at0e006877.--ranreconciliation: 104 derived, 104 run, 0 NOT MEASURED, a derived zero because every line recorded its exit code.check:dual-build-cjs-loads) exited 3 (PREREQUISITE NOT MET). It was re-run to 0 once the packages it named were built.accdab16had seven such refusals, all cleared the same way..tsfiles:--print-config.--format jsonreports 3 files, 0 errors, 0 warnings.eslint.config.mjsnever enables type-aware linting (its own note at:326-328), so this diff cannot move any untouched file's verdict.pnpm lintover the whole repo is CI's run.precision.Acceptance notes
precisionkey has no reader at all. It is declared "Total digits (non-negative integer)" and the Studio designer writes it on every numeric type. Nothing enforces it: not DDL (fixed(65, 30)), not the write seam, and no display face.packages/spec/liveness/field.jsonprops.precisionstill readslive, citing objectui@11c1e71ereads (CurrencyField.tsx:45,PercentField.tsx:13,index.tsx:662,useColumnSummary.ts:261), all of which are gone atf8a9d0fb. This is the same family as [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992, which coverscurrencyConfig.precisionwith no reader. Details are in the dev report on the card.CurrencyConfigSchema.precisionanchor) is not addressed here and remains open. Its region is untouched.Generated by Claude Code