Skip to content

Contract question: should publish-time validation reject a declared currency precision that contradicts the currency's ISO 4217 digits? #7918

Description

@yinlianghui

The question

A currency field can declare a precision. A currency also has a fraction-digit count of its own, fixed by ISO 4217 and carried in CLDR. Nothing today stops an author from declaring a precision that contradicts it:

type: currency
currency: JPY
precision: 2      # the yen has no minor unit at all

Is that combination something publish-time validation should reject, or is it a legitimate authored display choice that overrides the currency's own convention?

This is a spec question, not a renderer question, which is why it is filed here rather than answered downstream. It came out of objectui#4361 (currency formatting overrode each currency's own fraction-digit convention); the PM ruling on that card is quoted at the bottom.

Measured — the two precision keys are not the same key

Measured against @objectstack/spec@17.0.0-rc.6 as consumed by objectui:

key schema default
a currency FIELD's own precision z.ZodOptional(z.ZodNumber) none — absent stays absent
CurrencyConfigSchema.precision (packages/spec/src/data/field.zod.ts, around lines 112-114) z.number().int().min(0).max(10).default(2) materialized 2

Worth stating plainly because it decides whether a consumer can even tell the two cases apart: a parsed FIELD carries no materialized 2, so a renderer can distinguish "the author declared 2" from "the author declared nothing". The currencyConfig block cannot be distinguished that way — its default is baked in at parse time, so by the time anyone reads it, an unset precision and an authored precision: 2 are the same value.

Note also that CurrencyConfigSchema aliases both decimals and scale onto precision, so three spellings reach one key.

The digit counts in question

ICU, node 22, full ICU (icu 78.2), identical across every display locale tested (en-US, de-DE, ja-JP, ar-KW, zh-CN, fr-FR, pl-PL, es-ES) — the count comes from CLDR currencyData, keyed by the currency and not by the reader:

digits currencies
0 JPY, KRW, CLP, ISK, VND
2 USD, EUR, CNY, GBP
3 KWD, BHD, OMR, TND

So precision: 2 on a JPY field asks for two digits of a minor unit that does not exist, and precision: 2 on a KWD field silently drops the third fils digit that does.

Options

A. Reject at publish time — a declared precision that disagrees with the currency's ISO 4217 digits is a validation error, when the currency is statically known (a field-level currency, or currencyConfig in fixed mode).

  • Real business need: the pull is authoring correctness rather than a new capability. The contradiction is not expressible as a deliberate intent anyone has asked for — nobody has a business reason to display two decimal places of yen.
  • Long-term soundness: declared = enforced, and the contradiction cannot reach a renderer at all. Costs a rule that only fires when the currency is statically known; a dynamic currencyMode field has no single currency to check against, so the rule is necessarily partial.
  • Hard to get wrong: strongest of the three. An AI-authored metadata app that writes precision: 2 next to currency: JPY is told at publish time, once, instead of shipping money rendered with digits it does not have.

B. Treat an authored precision as an intentional display override — document it as winning over the currency convention, and leave validation alone.

  • Real business need: no measured case. I could not find a scenario where showing sub-unit precision on a 0-digit currency is what someone wants; if one exists (accounting displays that carry fractional yen internally?), that is the argument for B and it should be written down, because right now it is only implied by the absence of a rule.
  • Long-term soundness: cheapest today, and it fossilizes "the author may contradict ISO 4217" as contract by silence rather than by decision.
  • Hard to get wrong: weakest. The failure is invisible — the metadata publishes, the app renders, and the money is wrong.

C. Warn, do not reject. Splits the difference and, in an AI-authoring pipeline, mostly means the warning is not read.

Recommendation

A, scoped to the case where the currency is statically known, with the message naming both numbers ("currency JPY has 0 fraction digits; precision: 2 contradicts it"). B is defensible only if someone can name the business case that A would block — and that case should be captured in the spec's own docs either way, since today the behavior is undefined rather than chosen.

Deciding this does not block anything: objectui already ships the renderer half.

What objectui does meanwhile (no coupling)

objectui#4361 fixes the rendering defect independently. Its PM ruling, quoted verbatim:

CurrencyField.formatAmount: explicitly authored precision wins — it is authored metadata, and the repo's convention is authored-metadata-keeps-priority (same shape as #4397's param.placeholder). Absent precision derives from the currency instead of defaulting to 2.

The contract question goes upstream, contract-first: whether publish-time validation should reject a declared precision that contradicts the currency's ISO 4217 digits is a spec question. The dev files the objectstack card (contract-first shape, citing this ruling and the measured table), links it here, and the renderer ruling above stands as objectui's behavior pending the upstream answer — no waiting, no coupling.

So an authored precision currently wins in the renderer, and an absent one derives from the currency. If this card is answered A, the contradicting combination stops reaching the renderer at all and the objectui behavior is unaffected. If it is answered B, objectui's behavior is already the documented one.

Adjacent but different: objectstack#7501 asks whether a number field's declared scale is enforced on write. This card is about a declared display width contradicting a fixed property of the currency, at publish time.


Generated by Claude Code

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in packages/spec/src/data/field.zod.ts + the publish-time validation path; rationale: adding a rejection rule changes the accept/reject behavior of the authorable face → domain:spec (acceptance-change red line), and a NEW publish-time rejection on a public contract is a protocol change → human floor, so needs-user-decision rather than auto-adjudication.

    Four-lens block (①–④ per #7498):

    • ① Platform long-term coherence — today the precision-vs-ISO-4217 contradiction is undefined rather than chosen; option A closes it where the currency is statically known (necessarily partial: dynamic currencyMode has no single currency to check). Option B fossilizes "the author may contradict ISO 4217" as contract-by-silence. A shrinks the undefined surface.
    • ② Measured business pull — the pull is authoring correctness, not capability: no business case was found for showing minor-unit digits a currency does not have (JPY precision: 2), and the card came out of a real rendering defect (objectui#4361). Zero measured demand for B's override semantics.
    • ③ AI-agent error-resistance — strongest lens, favors A: an AI author writing precision: 2 next to currency: JPY gets told loudly once at publish time, versus silently wrong money in production (KWD silently drops a real fils digit). C (warn) mostly means unread warnings in an AI pipeline.
    • ④ Startup scope discipline — A is one narrow validation rule; B is a documented override capability nobody asked for, maintained forever.

    All four lenses point at A (reject when statically known, message naming both digit counts) — matching the filer's recommendation. Withheld from the auto-adjudication lane only because it is a protocol/accept-set change (human floor class); this is a one-word confirmation for the maintainer. Nothing downstream is blocked: objectui#4361's renderer ruling stands under either answer.

    Size/model suggestion once ruled: S–M, mode:subagent acceptable, model: sonnet–opus.


    Generated by Claude Code

  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Maintainer ruling — 2026-08-12

    Ruled: Option A — publish-time validation rejects a declared precision that contradicts the currency's ISO 4217 fraction digits, when the currency is statically known.

    Provenance: maintainer, triage PM session chat (session_01NKGoRBFZELVKivDkiAswdz), 2026-08-12, verbatim: 「7918 A,7917 ②,7900 收敛两扇门,7929 来源标记」. Recorded by the triage seat; needs-user-decision → pm:queue swapped in the same write.

    Implementation boundaries, per the card as ruled:

    • Rule fires only when the currency is statically known (field-level currency, or currencyConfig in fixed mode); dynamic currencyMode is out of reach by design — the rule is deliberately partial.
    • Error message names both numbers ("currency JPY has 0 fraction digits; precision: 2 contradicts it").
    • Mind the card's measured trap: CurrencyConfigSchema.precision materializes a default 2 at parse time — the check must distinguish authored-vs-defaulted there, or scope itself to where the distinction exists (the field-level key has no default). The decimals/scale aliases funnel into the same check.
    • Rejection tests carry code + status (or the publish-validation equivalent) per the rejection-case minimum; docs paragraph states the rule.
    • objectui behavior is unaffected (its renderer ruling stands; the contradiction now simply cannot reach it).

    Generated by Claude Code

  3. self-assigned this
    on Aug 12, 2026
  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 2 (spec seat, seat post #6017; maintainer acceleration directive in force)
    Session: session_0123k4cam2jEAkPmbJeoaY3r
    Branch: claude/issue-7918-currency-precision-iso4217
    Worktree: objectstack-issue-7918
    Domain: domain:spec
    File surface: packages/spec/src/data/field.zod.ts (currency field + CurrencyConfigSchema region), its tests, a docs paragraph stating the rule, regenerated references/**/baselines ride. (stop on breach; explain in the report)
    Container & model: S–M, mode:subagent, model: fable (mandatory clause — accept/reject change; triage's sonnet–opus suggestion not adopted for that reason)
    Serial constraints cleared: in-flight claims hold contracts/security-service.test.ts (#7831), data/external-lookup.zod.ts + system/message-queue.zod.ts (#8075 phase 2), the filter face + driver allowlists (#7872) — all disjoint from field.zod.ts. No open PR touches it.

    Dispatching against the maintainer's Option-A ruling on this thread (comment 5264125285, provenance 「7918 A…」): reject a declared precision contradicting the currency's ISO 4217 fraction digits, only where the currency is statically known; message names both numbers; the CurrencyConfigSchema materialized-default trap and the decimals/scale alias funnel are in the ruling's implementation boundaries.


    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator
    {
      "issue": 7918,
      "status": "done",
      "branch": "claude/issue-7918-currency-precision-iso4217",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8232",
      "premise_still_valid": true,
      "summary": "Option A implemented at both anchors with one shared verdict: FieldSchema-level `precision` (no default, checked in the existing superRefine chain) and CurrencyConfigSchema.precision, whose `.default(2)` was relocated into a post-check `.overwrite()` (per the #6926 precedent) so the refinement sees authored-vs-absent — authored-only firing, parse output byte-identical to before (pinned against origin/main 37b82ed5b literals). Deliberately partial per the ruling: fires only under currencyMode 'fixed'; dynamic mode and non-CLDR codes (BTC) fail open. Digit source is a checked-in CLDR 48.0 currencyData snapshot (162 codes, provenance + regen snippet in packages/spec/src/data/currency-fraction-digits.ts) — deterministic, no Intl in the validation path. One premise refinement: the ruling's parenthetical 'field-level `currency`' key does not exist in the spec's strict FieldSchema (it is an objectui-side read of looser configs; already an unrecognized-key rejection), so statically-known = fixed-mode currencyConfig — both precision keys are judged against it, including when defaultCurrency itself is defaulted (CNY). Docs paragraph added (data-modeling/field-types.mdx); changeset @objectstack/spec minor argued per #3746 in the PR body; in-repo census clean (3 currencyConfig declarations, all USD+2 agreeing, zero fixes needed). CI on the draft PR was in_progress at report time per the report-at-draft-PR contract.",
      "tests": "packages/spec full suite: 386 files / 10216 tests passed; typecheck green; check:generated 'All 13 generated artifacts are up to date' (re-verified after merging origin/main fa4897335, which moved spec contracts/*). 21 new tests in currency-precision-iso4217.test.ts: JPY+2 and KWD+2 refused at both anchors (code 'custom', paths ['precision'] / ['currencyConfig','precision'], message contains 'currency JPY has 0 fraction digits' + '`precision: 2` contradicts it'); USD+2 / JPY+0 / KWD+3 / absent-precision / dynamic / BTC+8 accepted, agreeing combos byte-identical to pre-change stringified outputs; decimals/scale alias funnel pinned as strict rejection naming 'precision'. Reverse verification A (predicted red): `const n: number = cfg.precision` vs rebuilt dist .d.ts -> TS2322, control clean. Reverse verification B (predicted 3 red, OBSERVED 4): restoring `.default(2)` flipped the noisy-shape guard, its byte-identity row, the field-level agreeing-acceptance test, PLUS the field-level JPY+2 rejection via issue-order displacement (config-level over-fire on the baked default lands first) — same mechanism, one more red than predicted; restored from committed state. Downstream after building closures: metadata-protocol 75 files / 1094 passed; platform-objects 16 files / 347 passed (first-lap exit-1 was the fresh-worktree unbuilt-closure false red). objectstack validate PASS in app-showcase, app-crm, app-todo. Gates all PASS: adr-anchors, changeset-gate-self-tests, cross-package-test-inputs, doc-formula-expressions, docs-audit-scope, i18n, i18n-coverage (12/12 configs), merge-driver, release-body, spec-parsed-alias, nul-bytes.",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #8163: field-level `currency` is rejected with no pointer to `currencyConfig.defaultCurrency` — the natural spelling gets the history line but no prescription (guidance-entry fix shape, same class as #7887)"]
    }

    Generated by Claude Code


    Generated by Claude Code

  6. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    ACCEPT — PR #8232 (draft), reviewed per step 7 against GitHub.

    • Scope verified on the diff: 6 files, +485/−3 — field.zod.ts, the new checked-in CLDR 48 fraction-digit table (162 codes, provenance + regen snippet), one docs paragraph, tests, changeset. No generated faces moved ⇒ independent landing, no relay slot.
    • The ruling's hard part executed exactly: the materialized-default trap is dispatched by relocating .default(2) into a post-check .overwrite() (finding: FormViewSchema.groups is declared as "alias to sections" but nothing folds it — every consumer reads only sections #6926 precedent) so the refinement sees authored-vs-absent — authored-only firing, with the parse output pinned byte-identical against origin/main literals (the pin that proves the relocation changed nothing else). Deliberately partial per the ruling: dynamic mode and non-CLDR codes (BTC) fail open; alias funnel covered.
    • Premise refinement honestly reported, substance preserved: the ruling's parenthetical "field-level currency" names a key that does not exist on the strict FieldSchema — statically-known therefore = fixed-mode currencyConfig, judged at both precision anchors. That is reading the tree over the ruling's letter while keeping its intent, and reporting the delta rather than absorbing it.
    • Verification: 21 new tests both directions with message substance (both digit counts named); reverse verification B predicted 3 reds, observed 4, with the fourth's mechanism explained (issue-order displacement) — the honest kind of miss; in-repo census clean (3 declarations, all agreeing); consumer suites + examples ×3 green; full gate list PASS.
    • Out-of-scope finding verified filed: [finding] field-level currency is rejected with no pointer to currencyConfig.defaultCurrency — the natural spelling gets history, not a prescription #8163 (guidance-entry shape, already routed domain:spec-surface).

    Landing: flip + auto-merge PM-driven on verified gate-job conclusions at the PR head; no lap needed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions