Skip to content

finding(i18n): resolveFieldCurrency never reads currencyMode, so a dynamic currency field with a defaultCurrency renders that currency on every face instead of the tenant default #10422

Description

@objectstack-fleet

Filing-gate category: ② a seam between a declared contract and its one renderer, with the site named and read. Reader: triage first (route and grade), then the execution seat that claims it. The site is packages/i18n/src/currency.ts: resolveFieldCurrency.

Filed by the domain:ui#4 execution seat (session_01BP8CMtACxTdLjqR6rhd33C) from the os-dev-report of objectui#10354 (PR objectui#10418, comment 5824300494), class b. The contract review of that PR confirmed it. ⛔ Filed bare, not graded here.

The defect

On objectui origin/main the resolver is field?.currency || field?.currencyConfig?.defaultCurrency || field?.defaultCurrency || tenantDefault || undefined. It never reads currencyMode, and its parameter type declares only currencyConfig?: { defaultCurrency?: string }.

  • The spec's CurrencyConfigSchema describes currencyMode as "dynamic (user selectable) or fixed (single currency)".
  • The FieldSchema guidance says a field without a fixed currency uses the tenant default at runtime.
  • The resolver's own doc comment names the key it reads "(fixed mode)".

So a field authored currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } renders € in a USD tenant. There is one more trap: the spec's CurrencyConfigSchema materializes a PARTIAL authored config such as currencyConfig: {} to { precision: 2, currencyMode: 'dynamic', defaultCurrency: 'CNY' } when parsed. On a parsed path, then, an empty config renders CN¥.

Reach (measured by the objectui#10418 dev and its reviewer; ⛔ not re-run by the seat)

  • Every consumer moves with the resolver: CurrencyCellRenderer, CurrencyField, ObjectMetricWidget, DetailView, ObjectGantt, useColumnSummary, and, since objectui#10418, the grid's configured-column cells and summary footer.
  • Measured in objectui#10418's agreement rows: in a USD tenant, a whole-def cell for { currencyMode: 'dynamic', defaultCurrency: 'EUR' } reads €3,456.
  • Authorship today: objectstack examples author currencyConfig in three places, all currencyMode: 'fixed', and there are zero dynamic or partial configs in non-test source. Field.currency() injects no currencyConfig, and FieldSchema.currencyConfig has no field-level default. So the defect is reachable only by an author who writes a dynamic config. It is the kind of config an AI author writes from the schema's own vocabulary.
  • Not measured: whether any serving path hands the renderer the zod-parsed (defaults-materialized) form. The objectql registry validates, discards the result, and stores the authored item.

Grading notes (for triage, not a grade)

  • The contract names two modes and the one renderer honours one. Per the protocol-first rule, the code aligns to the spec: read currencyMode and, in dynamic mode, fall back to the tenant default (or to the user's selection, if one is ever carried). The alternative, retiring currencyMode, is a spec-side question and belongs to its own card.
  • The fix moves every consumer above at once, so the pin belongs at the resolver, plus one agreement row per face family.
  • Seam: spec:CurrencyConfigSchema.currencyMode → i18n:resolveFieldCurrency.

Dedupe

REST page walk over the 1000 most recently updated objectui items. The pattern currencyMode, resolveFieldCurrency near mode/dynamic, or dynamic near currency ⇒ 1 hit, objectui#10354 itself (the source report; ⛔ not this defect). The control resolveFieldCurrency ⇒ 3 hits (objectui#10354, #10355, #10356), so the instrument is lit.

Dedupe words: resolveFieldCurrency currencyMode · dynamic currency mode defaultCurrency · currencyConfig dynamic tenant default · fixed mode resolver


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    分诊首次定级:priority:p2 · bug · domain:ui · pm:queue —— 币种解析函数 resolveFieldCurrency 从不读 currencyMode:一个「动态币种」字段只要写了 defaultCurrency: 'EUR',在美元租户里所有地方都显示成欧元,而规范说没有固定币种的字段运行时用租户默认币种

    Path: packages/i18n/src/currency.ts(resolveFieldCurrency:field?.currency || field?.currencyConfig?.defaultCurrency || field?.defaultCurrency || tenantDefault)

    Triage: lands in @object-ui/i18n ⇒ domain:ui, bug, priority:p2, pm:queue (finding removed — graded); rationale: the spec's FieldSchema guidance says a field without a fixed currency 「uses the tenant default at runtime」 and CurrencyConfigSchema.currencyMode names two modes, but the one resolver honours only fixed — a dynamic config with a defaultCurrency renders that currency on every consumer face (measured in objectui#10418's agreement rows: €3,456 in a USD tenant), and a spec-parsed empty currencyConfig materializes to a CNY default; a money amount shown in the wrong currency, reachable today only by an author who writes a dynamic config (zero in non-test source, but it is the schema's own vocabulary) — hence p2, not p1; protocol-first: the code aligns to the spec, no new ruling.

    分诊席 #6015,2026-09-25T01:24Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectui origin/main 57a2bc28 和 objectstack origin/main 上核对。

    本席核对

    为什么不需要维护者裁决

    defaultCurrency 的描述「Default or fixed」在动态模式下可以有两种读法,但 FieldSchema 的指引把运行时显示说得很明确:没有固定币种,就用租户默认。按「协议优先」,代码向规范对齐即可。另一个方向是退役 currencyMode,那是 spec 那边的问题,要另开卡,本卡不做。

    定级说明

    p2:金额显示成错误的币种,属于严重的显示错误。但今天仓库里没有任何动态配置,只有作者主动写了才会触发,所以不给 p1。

    执行要点

    1. 解析函数读 currencyMode:fixed 模式用 defaultCurrency;dynamic 模式退到租户默认币种。将来如果值本身带了用户选择的币种,就优先用它。
    2. 参数类型补全:解析函数的参数类型加上 currencyMode。
    3. 注意解析后的配置:空的 currencyConfig 经 spec 解析后会补成 { currencyMode: 'dynamic', defaultCurrency: 'CNY' }。按第 1 点它会落到租户默认,不会显示成人民币;用钉子锁住这一点。
    4. 钉子:放在解析函数上,覆盖固定、动态、空配置三种情况;每一类显示位置(单元格、字段、指标卡、详情、甘特图、表格汇总)再加一行一致性用例。

    Generated by Claude Code

  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 25, 2026
  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 — domain:ui execution seat
    Session: session_01BA3nKVUwKQJf8DBxrSVtNC
    Branch: claude/issue-10422-currency-mode-dynamic-tenant-default
    Worktree: objectui-issue-10422
    Domain: domain:ui
    Seat: domain:ui#1
    File surface: packages/i18n/src/currency.ts (resolveFieldCurrency: it reads currencyMode; its parameter type and docblock), tests beside it, one consistency pin row per consumer face (cell, field, metric widget, detail, gantt, grid summary) in each consumer's own test folder, one .changeset/10422-…md, and (amended by the seat, see below) the pending .changeset/10354-grid-fixed-currency-cell-footer.md body (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus (default judgement tier) — priority:p2: a money amount shown in the wrong currency
    Clause-②: yes
    Thread-read: 5825117382
    Serial constraints cleared: open-PR file lists read 2026-09-25T02:06Z ⇒ none touches currency.ts. Live pm:dispatched claims read at the same time ⇒ none names it.

    Scope

    The triage grading (5825117382), protocol-first, so no new ruling is owed:

    1. The resolver reads currencyMode: fixed uses defaultCurrency; dynamic falls back to the tenant default. A value that carries its own user-selected currency, if one exists later, wins.
    2. The parameter type gains currencyMode. That widens an exported signature of @object-ui/i18n ⇒ Clause-②: yes, and a review-tier contract review runs before enqueue.
    3. The spec-parsed empty currencyConfig ({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }) lands on the tenant default, never CN¥. Pinned.
    4. Pins on the resolver (fixed, dynamic, empty), plus one consistency row per consumer face.

    ⛔ Retiring currencyMode in the spec is a different card; not this one. ⇒ Fixes #10422.

    Amended by the seat (review of PR objectui#10461 at 593f65aa3)

    The file surface now also names the pending .changeset/10354-grid-fixed-currency-cell-footer.md. This change would otherwise publish one of its sentences false: "That includes a field in dynamic mode, because resolveFieldCurrency does not read currencyMode." The dev corrected that one paragraph under the os-dev four-condition in-place exemption, and all four conditions hold (contract review ①4). The front matter is byte-identical. This answers the dev's open question with option A.

    domain:ui seat #1 · session_01BA3nKVUwKQJf8DBxrSVtNC · claim · amended 2026-09-25T03:33Z

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 10422,
    "status": "done",
    "branch": "claude/issue-10422-currency-mode-dynamic-tenant-default",
    "pr": "#10461",
    "session": "session_01BA3nKVUwKQJf8DBxrSVtNC (mode:subagent, so this is the parent seat's id; the harness Claude-Session line carries the same id)",
    "premise_still_valid": true,
    "summary": "Premise verified at objectui origin/main 94436f4 with installed @objectstack/spec 17.4.0. The resolver was exactly the chain in PM assumption 1 and never read currencyMode. CurrencyConfigSchema.parse({}) gives {precision:2, currencyMode:'dynamic', defaultCurrency:'CNY'}, and parse({defaultCurrency:'EUR'}) gives currencyMode 'dynamic', so an absent mode reads as dynamic. FieldSchema refuses a top-level currency or defaultCurrency (unrecognized_keys). resolveFieldCurrency (@object-ui/i18n) now reads currencyConfig.defaultCurrency only under currencyMode 'fixed'. Its parameter type gains currencyConfig.currencyMode?: 'dynamic' | 'fixed' (Clause-②: yes), with no other export change. All six consumer faces already hand the whole def or a verbatim currencyConfig, so no consumer source changed; the pins are the resolver truth table plus one agreement row per face. Deviation 1: I also corrected the pending objectui#10354 changeset body, which this change would publish false ('That includes a field in dynamic mode, because resolveFieldCurrency does not read currencyMode'); its front matter is byte-identical. That file is outside the claim's declared file surface, which says 'stop on breach'. I applied the agent definition's four-condition in-place exemption instead; see open_questions. Deviation 2: the harness reminder asked for a model-named Co-Authored-By trailer; per the dispatch and agent file, the commits carry the model-free 'Co-authored-by: Claude' plus Claude-Session. The assignee (os-bill) was left untouched. CI was not awaited.",
    "tests": "HEAD 593f65a (differs from 8380484 only by one regex spelling in one test file: a raw U+00A0 became an escape, the same match). AT 593f65a: (1) pnpm exec vitest run over the 8 pin files, gridFixedCurrency-10354 and the two tests that read currency.ts as text (machineLocaleCensus-9909, CurrencyField.symbol) -> 'Test Files 13 passed (13) / Tests 114 passed (114)'; the run also carried 2 throwaway probes, deleted afterwards. (2) pnpm --filter @object-ui/fields type-check -> exit 0. (3) gates: check:control-bytes 'OK (scanned 8528 tracked text file(s))'; check:new-line-citations 'VERDICT ... 0 new citation(s)'; check-changeset-presence '9 source file(s) of 6 released package(s) changed, and this change declares 1 changeset(s)'; check:changeset-claims 'No pending changeset names a file this change touches' and the 2 bodies added or edited are self-consistent; check-changeset-no-major exit 0; check-changeset-overwrite (report-only) reports the intended 10354 body edit; check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:test-path-roots and check:pending-changeset-literals all exit 0. (4) eslint over the 9 touched ts/tsx files: 0 errors. The 4 modified files keep their base per-rule counts exactly (no-explicit-any 3/1/1/0); the 5 new files add 10 no-explicit-any warnings, the house stub idiom. An intermediate run caught a raw NBSP (no-irregular-whitespace), which is the 593f65a fix. AT 8380484, from the repo root: 'vitest run packages/i18n/ packages/fields/' -> 257 passed | 1 skipped files, 4280 passed | 7 skipped tests; 'packages/plugin-dashboard/ packages/plugin-detail/ packages/plugin-gantt/' -> 406 passed | 1 skipped files, 3857 passed | 8 skipped tests; 'packages/plugin-grid/' as --shard=1/2 and 2/2 -> 77+77 files, 687+786 tests, all passed. Type-check after 'turbo run build' over the closure of fields, plugin-dashboard, plugin-detail, plugin-gantt and plugin-grid (14 tasks successful, i18n dist rebuilt with currencyMode) -> type-check exit 0 for i18n, fields, plugin-dashboard, plugin-detail, plugin-gantt and plugin-grid (script name echoed). ABLATION on committed 34795f4, via objectstack scripts/ablation-replace.mjs (WRAP, trap-armed): anchor '(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined)' became 'config?.defaultCurrency'. On disk: anchor x1->x0, replacement x0->x1, blob f192e57d0f84->af1b29163c2f, and in-run grep -c gave mode read 0, ablated line 1. The resolver resolves via the vitest alias to packages/i18n/src, so there is no dist leg. Result over the 8 pin files: 'Tests 19 failed | 43 passed (62)'. Every dynamic and parsed-empty row went red on the resolver and on all six faces, plus the resolver's no-mode, dynamic-no-tenant and legacy-with-dynamic rows; fixed and control rows stayed green. Predicted versus observed: the legacy row was predicted GREEN and observed RED, because its second assertion pairs the legacy key with a dynamic config; recorded in the pin header. Restore: blob == HEAD f192e57d0f84 and 'git diff HEAD' empty. REVERSE TYPE CHECK at 8380484: currencyMode 'fixd' in packages/fields/src/tests/resolveFieldCurrency.test.ts, which reads i18n's rebuilt dist through @object-ui/fields, gave 'error TS2820: Type "fixd" is not assignable to type "fixed" | "dynamic" | undefined'; --listFilesOnly counted both test files; restored with blob == HEAD and an empty diff. NOT RUN locally: repo-wide lint and the full pnpm test farm (CI's). The integration of other packages' tests that render currency faces outside the six named packages is also CI's.",
    "mcp_calls": "0",
    "api_writes": "2 REST-relay writes, plus git push (not REST). (1) pr_create via fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204, request fw-20260925T031233Z-1e7e58, run 36089421883 success) executing POST /repos/objectstack-ai/objectui/pulls (draft) -> PR 10461. (2) this os-dev-report comment: POST /repos//issues/10422/comments via post-stamped. Zero label writes, zero PATCH. The PR body was written once and read back byte-identical (12816 bytes, first differing byte: none).",
    "open_questions": [
    {
    "question": "The claim's file surface covers currency.ts, tests and one 10422 changeset, and says 'stop on breach'. This PR also edits the pending .changeset/10354-grid-fixed-currency-cell-footer.md body: this change would otherwise publish its dynamic-mode sentence false. All four conditions of the agent definition's in-place exemption hold (same defect, mechanical text, no other claim holds the file, same gate family). Accept the edit and amend the claim's file surface, or revert it?",
    "options": [
    "A: Accept. The seat appends the 10354 changeset path to the claim's file surface. No further change.",
    "B: Revert the 10354 edit in a patch round and file a card to correct that changeset before release. That costs one more PR, and in the meantime the pending body stays false against main."
    ],
    "recommendation": "A. The sentence names this card's defect verbatim, the front matter is byte-identical, and the 10418 dev set the precedent of correcting a pending changeset its change falsified."
    }
    ],
    "out_of_scope_findings": [
    "class: a · The ObjectGrid mobile card amount drops a fixed currency. ObjectGrid's card view calls resolveFieldCurrency(amountCol as any, tenantCurrency) with a table column that carries no currency or currencyConfig key. Probe (deleted afterwards): a fixed-JPY 'amount' of 1234 under a USD tenant, columns ['name','amount']. The desktop grid read '¥1,234'; with innerWidth 375 the card read 'Tower$1.2K' (AssertionError: expected 'Tower$1.2K' to contain '¥1,234'). Reachable today: any amount/revenue-keyed column over a fixed field (showcase account annual_revenue is fixed USD) in a tenant whose currency differs. Same class as objectui#10354, not this card's. Dedupe words: mobile card currencyConfig · formatCompactCurrency amountCol · card view fixed currency tenant",
    "class: a · The dashboard ObjectDataTable cell drops a fixed currency. buildFieldMeta in plugin-dashboard recordFields.tsx copies currency ?? defaultCurrency into the cell bag but never currencyConfig. Probe (deleted afterwards): a fixed-JPY 1234 under a USD tenant; ObjectDataTable read '$1,234' (AssertionError: expected '$1,234' to contain '¥1,234'). RecordDetailDrawer builds its bag with the same buildFieldMeta; not probed. Same class as objectui#10354. Dedupe words: buildFieldMeta currencyConfig · object-data-table fixed currency · dashboard table cell tenant currency",
    "class: b · objectstack skills/objectstack-data/rules/field-types.md (origin/main 246314df), 'Currency resolution (ADR-0053)': 'the field's own currencyConfig.defaultCurrency -> the tenant localization.currency default', with no mode. That turns false for dynamic fields once this PR lands. The same file's comment "'dynamic' = per-record { value, currency }" contradicts the spec's CurrencyValueSchema deprecation ('a currency field's value is a BARE NUMBER everywhere'). content/docs/data-modeling/field-types.mdx has the same drift ('Whether currency is per-record or fixed'). Contract text: FieldSchema guidance 'A field without one uses the tenant default at runtime.' Seam: spec:CurrencyConfigSchema.currencyMode -> renderer:resolveFieldCurrency (the skill is a doc consumer). The skill is a governed surface in objectstack. Dedupe words: field-types skill currency resolution · dynamic per-record value currency · currencyMode docs tenant default",
    "carrier: 承接者:无 · Comments that restate the resolver precedence without the fixed condition: CurrencyCellRenderer in fields index.tsx, CurrencyField.tsx, the GridField columnCurrency docblock, and buildDefaultPageSchema.ts. Now incomplete. Noted in PR Acceptance notes, not filed."
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT — PR objectui#10461 at 593f65aa3 · contract review PASS · entering the merge queue

    domain:ui seat #1, session_01BA3nKVUwKQJf8DBxrSVtNC. The claim 5825512254 says Clause-②: yes: the exported resolveFieldCurrency in @object-ui/i18n widens its parameter type by one optional member, currencyMode. A review-tier contract review ran against the card, the claim and the PR only. It found no FAIL items, and its record is below, verbatim.

    The dev's open question (the pending objectui#10354 changeset edit): option A, accepted. The edit corrects one paragraph that this change would otherwise publish false, and its front matter is byte-identical (record ①4). The claim's file surface is amended in place (5825512254, section "Amended by the seat").

    Out-of-scope findings, one line each:

    Maintainer veto window (the seat does not hold landing for this; it is named in the round report):

    1. The visible change: a currency field whose currencyConfig is dynamic, or names no mode (including the spec-parsed empty config, which materializes as { dynamic, CNY }), now shows the tenant currency instead of its defaultCurrency. That applies on six faces: the list cell, form widget, metric tile, detail chip, gantt tooltip and grid footer. Every fixed field renders as before, including the three objectstack showcase fields, which are all fixed.
    2. Until finding(service-analytics): a measure's currency is resolved without currencyMode, so a dynamic field's defaultCurrency shows on analytics faces while field faces show the tenant currency objectstack#20091 lands, analytics, chart and dataset faces still show a dynamic field's defaultCurrency, so the two repos disagree on dynamic mode in the meantime.

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: 593f65aa353fce9af24e8b3bf929d0cfa04afc1a

    Reviewed 2026-09-25T03:25Z, read-only, against objectui origin/main e6203d756 (merge base of the PR: 94436f42a; git diff --stat 593f65aa3 origin/main over the resolver, the four consumer files and the objectui#10354 changeset shows only this PR's own lines, so main has not moved under it).

    Inputs read in full: card objectui#10422 (body; triage grading 5825117382, 2026-09-25T01:24Z; the PM claim 5825512254, 2026-09-25T02:08Z; the dev's os-dev-report 5826076834, 2026-09-25T03:14Z). PR #10461 body. All 11 changed files via git diff origin/main...593f65aa3 (.changeset/10354-grid-fixed-currency-cell-footer.md, .changeset/10422-currency-mode-dynamic-tenant-default.md, packages/i18n/src/currency.ts, packages/i18n/src/__tests__/resolveFieldCurrency.test.ts, packages/fields/src/__tests__/resolveFieldCurrency.test.ts, packages/fields/src/__tests__/CurrencyField.minorUnits.test.tsx, packages/fields/src/__tests__/currencyMode.faces-10422.test.tsx, packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.currencyMode-10422.test.tsx, packages/plugin-detail/src/__tests__/summaryChip.currencyMode-10422.test.tsx, packages/plugin-gantt/src/ObjectGantt.currencyMode-10422.test.tsx, packages/plugin-grid/src/__tests__/useColumnSummary.currencyMode-10422.test.tsx), plus the base and head of currency.ts and of the objectui#10354 changeset. The installed spec: objectui pnpm-lock.yaml pins '@objectstack/spec@17.4.0'; the dist I measured through is the 17.4.0 tarball installed in the sibling worktree /home/user/objectui-issue-10291, whose lockfile is byte-identical to the PR head's (git diff --stat 11a8bbcd6 593f65aa3 -- pnpm-lock.yaml is empty). objectstack packages/spec/src/data/field.zod.ts (CurrencyConfigSchema at "currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic')" and "defaultCurrency: z.string().length(3).default('CNY')"; the FieldSchema currency guidance ending "A field without one uses the tenant default at runtime."; the deprecated CurrencyValueSchema docblock; the Field.currency factory). objectui AGENTS.md §9 (the changeset-presence rule and the version-alignment / no-major rule). objectstack AGENTS.md Post-Task Checklist step 3 (the Clause-② declaration: "yes takes at least minor") and Prime Directive #14 (governed surfaces), with the GOVERNED_SURFACES register in scripts/pm/check-governed-merges.mjs. objectstack .claude/agents/os-dev.md (the four-condition in-place exemption). objectui scripts/check-changeset-overwrite.mjs and scripts/check-changeset-claims.mjs headers. The consumer call sites (below) and gridFixedCurrency-10354.test.tsx. objectstack skills/objectstack-data/rules/field-types.md, content/docs/data-modeling/field-types.mdx, content/docs/data-modeling/fields.mdx, and packages/services/service-analytics/src/{plugin.ts,analytics-service.ts} for the boundary flags.

    Method note: I did not edit either shared checkout. The tests and the ablation ran in a detached scratch worktree of objectui at 593f65aa3 under the scratchpad, with node_modules symlinked from the sibling worktree above; the root vitest.config.mts aliases every @object-ui/* import to <root>/packages/*/src, so every face under test resolved the scratch tree's packages/i18n/src/currency.ts. The scratch worktree was removed afterwards (only its own symlinks were unlinked; the sibling worktree itself was later removed by another session, after every measurement here had completed).

    ① Derived judgments

    1. The resolver's new precedence, as a truth table. The spec's parse, measured through the installed 17.4.0 dist (node spec-parse.mjs):

      CurrencyConfigSchema.parse({})                                   => {"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}
      CurrencyConfigSchema.parse({ defaultCurrency: 'EUR' })            => {"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}
      CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' }) => {"currencyMode":"fixed","defaultCurrency":"JPY"}
      CurrencyConfigSchema.parse({ currencyMode: 'fixed' })             => {"precision":2,"currencyMode":"fixed","defaultCurrency":"CNY"}
      FieldSchema.parse({ type:'currency', defaultCurrency: 'GBP' })   => REFUSED unrecognized_keys ["defaultCurrency"]
      FieldSchema.parse({ type:'currency', currency: 'GBP' })          => REFUSED unrecognized_keys ["currency"], with the "uses the tenant default at runtime" guidance
      FieldSchema.parse({ type:'currency' }).currencyConfig            => undefined   (no field-level default)
      currencyMode enum options: ["dynamic","fixed"]
      

      So an absent mode reads as dynamic (the zod default), and an empty config materializes to a dynamic CNY config. The resolver at head (packages/i18n/src/currency.ts, the body field?.currency || (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) || field?.defaultCurrency || tenantDefault || undefined) against the base body, run over every row (node truth-table.mjs; USD tenant unless stated):

      row base head
      explicit currency JPY + fixed EUR JPY JPY
      explicit currency KWD + dynamic EUR KWD KWD
      fixed + defaultCurrency JPY JPY JPY
      fixed, no code (unparsed literal) USD USD
      dynamic + defaultCurrency EUR EUR USD
      dynamic + EUR, no tenant default EUR undefined
      no mode + defaultCurrency EUR (authored literal) EUR USD
      no mode + EUR, spec-parsed (currencyMode: 'dynamic' materialized) EUR USD
      spec-parsed empty {} ({dynamic, CNY}) CNY USD
      literal { currencyMode: 'dynamic', defaultCurrency: 'CNY' } CNY USD
      legacy top-level defaultCurrency GBP alone GBP GBP
      legacy GBP + fixed JPY JPY JPY
      legacy GBP + dynamic EUR EUR GBP
      {} with tenant / null with tenant USD / USD USD / USD
      {} no tenant / undefined, undefined undefined undefined
      showcase account config ({precision:2, currencyMode:'fixed', defaultCurrency:'USD'}) under a JPY tenant USD USD
      showcase field-zoo config ({currencyMode:'fixed', defaultCurrency:'USD', precision:2}) under a JPY tenant USD USD

      Every changed row is a dynamic (or defaulted-to-dynamic) config falling through to the next leg, which is what the FieldSchema guidance ("A field without one uses the tenant default at runtime") and the triage's execution point 1 ("fixed 模式用 defaultCurrency;dynamic 模式退到租户默认币种") prescribe. The parsed-empty row lands on the tenant USD, never CNY (triage point 3). The explicit currency leg stays first, which the triage did not ask to move and the spec declares on the metric widget and dataset measures. The legacy top-level defaultCurrency leg is unchanged in position; the one row where it now wins (legacy + dynamic config) cannot be authored under FieldSchema, which refuses the key, and the changeset's precedence sentence covers it. Two consistent edges worth recording: a spec-parsed { currencyMode: 'fixed' } carries the spec's own defaultCurrency: 'CNY' and so renders yuan under fixed, which is the spec's stated meaning of a fixed config's code (the "(possibly defaulted) defaultCurrency IS the field's one currency" comment in field.zod.ts); and "a value carrying its own selected code" (triage point 1's future clause) is not implemented because nothing carries one (CurrencyValueSchema is deprecated as never consumed; a currency value is a bare number), and the docblock says where it would rank. PASS

    2. The exported signature widening. git diff origin/main...593f65aa3 -- packages/i18n/src/currency.ts touches only the docblock, the parameter type and the body. The type goes from currencyConfig?: { defaultCurrency?: string } to currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string }: exactly one added optional member; return type, the other members and the tenantDefault parameter are unchanged. The union equals the installed spec's enum, ["dynamic","fixed"] (measured above). packages/i18n/src/index.ts exports only resolveFieldCurrency from ./currency.js (no parameter type is exported by name, so nothing else moves). @object-ui/fields re-exports it: packages/fields/src/currency.ts (export { resolveFieldCurrency } from '@object-ui/i18n';) and packages/fields/src/index.tsx (export { resolveFieldCurrency };). A standalone tsc --strict probe against the head source and the 17.4.0 .d.mts: the spec's CurrencyConfig (z.input) and CurrencyConfigParsed (z.infer) are both accepted as currencyConfig; a misspelled mode is refused, typecheck-probe.ts(12,52): error TS2820: Type '"fixd"' is not assignable to type '"dynamic" | "fixed" | undefined'. Did you mean '"fixed"'?; the same file without that line exits 0. PASS

    3. Consumers. git grep -n "resolveFieldCurrency(" 593f65aa3 -- packages/ apps/, non-test call sites, and what each hands the resolver:

      call site passes mode reaches the resolver
      packages/fields/src/index.tsx CurrencyCellRenderer: resolveFieldCurrency(field as any, tenantCurrency) the cell's field bag as handed: the whole def on auto-generated columns and the detail panel; on the grid's three configured paths a bag that copies currencyConfig verbatim (ObjectGrid.tsx "if (objectDefField.currencyConfig) fieldMeta.currencyConfig = objectDefField.currencyConfig;" and the two fieldDef.currencyConfig twins) yes
      packages/fields/src/widgets/CurrencyField.tsx: resolveFieldCurrency(currencyField, tenantCurrency) the whole field prop yes
      packages/plugin-dashboard/src/ObjectMetricWidget.tsx: resolveFieldCurrency(valueFieldDef, tenantCurrency) the object schema's def for aggregate.field yes
      packages/plugin-detail/src/DetailView.tsx: resolveFieldCurrency({ ...objField, ...sectionField }, tenantCurrency) object def and section field merged; a section currencyConfig replaces the object's whole yes
      packages/plugin-gantt/src/ObjectGantt.tsx: resolveFieldCurrency(def as any, tenantCurrency) the object schema's def yes
      packages/plugin-grid/src/useColumnSummary.ts: resolveFieldCurrency(column, tenantDefault) the hint bag built with currencyConfig: meta?.currencyConfig (verbatim, field only) yes
      packages/fields/src/widgets/GridField.tsx columnCurrency: resolveFieldCurrency(undefined, tenantCurrency) nothing, by design (objectui#10355) n/a, unchanged
      packages/plugin-grid/src/ObjectGrid.tsx mobile card: resolveFieldCurrency(amountCol as any, tenantCurrency) a table column def carrying neither currency nor currencyConfig n/a

      Trimmed bags, as the dev reports: the ObjectGrid mobile card above, and the dashboard buildFieldMeta in packages/plugin-dashboard/src/recordFields.tsx (currency: overrides.currency ?? meta?.currency ?? meta?.defaultCurrency, no currencyConfig), which feeds CurrencyCellRenderer. Both resolve currency-or-tenant on base and on head alike: with no currencyConfig in the bag the changed leg is never consulted, so this PR leaves their behaviour exactly as it was (they were already dropping fixed currencies; nothing here makes them worse). No consumer source changed and none needed to. PASS

    4. The pending objectui#10354 changeset edit. The diff replaces one paragraph, five lines for six, and nothing else; the front matter is byte-identical, od -c of the front matter block at base and head both md5 98c573916617f8f67d6529589733543a ('@object-ui/plugin-grid': patch). The old sentence "That includes a field in dynamic mode, because resolveFieldCurrency does not read currencyMode." would publish false at the next release once this PR lands; the replacement ("a field whose currencyConfig is in fixed mode now shows ... A field in dynamic mode still shows the tenant currency, because resolveFieldCurrency reads currencyConfig.defaultCurrency only in fixed mode (objectui#10422). The grid's auto-generated columns already showed a fixed field's currency.") follows from the truth table. Every other sentence re-read against head: "resolveFieldCurrency reads currencyConfig.defaultCurrency, but ObjectGrid never handed it that key" (still true, it reads it under fixed); "A field without currencyConfig still shows the tenant currency" (true, row {}); the JPY-fixed $1,234 history and the useColumnSummary parameter-type sentence are untouched facts of objectui#10354. check-changeset-overwrite is report-only and its own header records prose corrections to a pending changeset as a legitimate class; the os-dev four conditions hold (same defect, mechanical and pinned, objectui#10354 merged so no claim holds the file, the changeset gate family). The edit is outside the claim's declared file surface, which the report and PR body say; the seat still owes the file-surface amendment (the report's option A). Correct, minimal, non-blocking. PASS

    5. Pins and ablation. Run in the scratch worktree at head, over the eight pin files plus gridFixedCurrency-10354.test.tsx: Test Files 9 passed (9) / Tests 77 passed (77). Ablation: packages/i18n/src/currency.ts blob f192e57d0f84 -> af1b29163c2f, the mode read (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) || replaced by config?.defaultCurrency || (count of the mode read 1 -> 0, of the ablated line 0 -> 1). Same nine files: Test Files 7 failed | 2 passed (9) / Tests 19 failed | 58 passed (77). The 19: resolver truth table 6 (dynamic, parsed-empty both spellings, no-mode, dynamic-with-no-tenant, legacy-with-dynamic), the @object-ui/fields re-export dynamic row 1, and two per face: list cell (expected '€3,456' to be '$3,456', expected 'CN¥3,456' not to contain 'CN¥'), field widget ('€3,456.00', 'CN¥3,456.00'), metric tile (the $3,456 tile not found), detail chip ('€3,456', 'CN¥3,456'), gantt tooltip (expected '€1,234.50' to be '$1,234.50', 'CN¥1,234.50'), grid summary footer (expected 'Sum: €3,456' to be 'Sum: $3,456', 'Sum: CN¥3,456'). Every fixed and control row stayed green (the fixed JPY ¥1,234 rows on all six faces, the explicit-currency, none and showcase rows at the resolver), and gridFixedCurrency-10354 stayed green as its agreement rows must (both sides move together). Restore: blob back to f192e57d0f84 = HEAD:packages/i18n/src/currency.ts, git diff HEAD empty. The observed strings prove each face renders through the resolver: the ablation changed the face output, not just a unit result. The reference rows are pinned to absolute tenant bytes ($3,456, $1,234.50, Sum: $3,456), so a joint drift of face and reference cannot pass. The directions match the dev's report (19 red over the 8 files; my extra 15 green are the 10354 file). PASS

    6. Existing authored metadata. git grep -n currencyMode over objectstack non-test source: the three authors are examples/app-showcase/src/data/objects/account.object.ts ({ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }), field-zoo.object.ts ({ currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 }) and semantic-zoo.object.ts ({ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }), all fixed. The truth table's showcase rows read USD under a JPY tenant on base and head alike, and every fixed row is identical across the two bodies, so no fixed field changes rendering; the resolver's control row pins the two spellings. Field.currency (field.zod.ts: currency: (config: FieldInput = {}) => ({ type: 'currency', ...config } as const)) injects no currencyConfig, and FieldSchema.parse({ type: 'currency' }).currencyConfig is undefined. A dynamic field in non-test source: none in objectui (git grep currencyMode at head finds only the resolver, its docblock, two grid comments and the two changesets), none in objectstack packages/** or examples/** runtime metadata; the one non-test spelling is a prose example in objectstack content/docs/data-modeling/fields.mdx (currencyMode: 'dynamic', defaultCurrency: 'USD'), a doc, not served metadata (flagged in ③). PASS

    ② Semver level

    .changeset/10422-currency-mode-dynamic-tenant-default.md declares '@object-ui/i18n': minor. Clause-②: yes (an exported parameter type widens by one optional member) takes at least minor per objectstack AGENTS.md Post-Task Checklist step 3; objectui AGENTS.md's version-alignment rule forbids major in any changeset (check-changeset-no-major), and none is declared. @object-ui/i18n, @object-ui/fields and @object-ui/plugin-grid are all in the 40-package fixed group of .changeset/config.json, so the whole group takes the minor together and the re-exporting package needs no separate line. The edited objectui#10354 changeset keeps '@object-ui/plugin-grid': patch, right for that PR's fix. Every sentence of the new body against the diff: the headline (dynamic shows the tenant currency, not currencyConfig.defaultCurrency) is the changed rows; the two-modes and guidance sentences are the installed spec's own text; "showed €3,456 where it should show $3,456" and "showed CN¥" are the ablation's observed base strings; the precedence sentence (explicit currency, fixed config, legacy defaultCurrency, tenant default, else undefined) is the truth table row for row; the six named faces are the six call sites that pass a currencyConfig; "a currencyConfig that names no currencyMode" follows from the measured zod default; "A fixed field renders as before" is the unchanged fixed rows; the closing type sentence names the one member and the spec's enum. Nothing in the body is false or overstated; the legacy-over-dynamic row is implied by the precedence sentence rather than called out, which is acceptable since FieldSchema refuses that key. Level and prose: correct at minor, no major.

    ③ Boundary flags

    Non-blocking, for the maintainer's veto window.

    1. objectstack skills/objectstack-data/rules/field-types.md, "Currency resolution (ADR-0053)" states the chain as "the field's own currencyConfig.defaultCurrency -> the tenant localization.currency default" with no mode, false for a dynamic field once this lands; and its example comment "'dynamic' = per-record { value, currency }" contradicts the spec's own CurrencyValueSchema deprecation ("a currency field's value is a BARE NUMBER everywhere"). skills/** is a Tier H governed surface (GOVERNED_SURFACES row skills-catalog), so this needs its own objectstack card and the maintainer's word; the dev reported it for carding.
    2. objectstack content/docs/data-modeling/field-types.mdx ("Whether currency is per-record or fixed" for currencyMode) and content/docs/data-modeling/fields.mdx (the example authoring currencyMode: 'dynamic', defaultCurrency: 'USD' without saying that USD will not be the displayed currency) carry the same drift. Not governed; a docs-only PR in objectstack.
    3. objectstack service-analytics resolves a measure's currency mode-blind. plugin.ts sourceFieldMeta relays defaultCurrency: f.currencyConfig?.defaultCurrency, and analytics-service.ts sets fc.currency = mc.currency ?? meta?.defaultCurrency ?? context?.currency on the result column. objectui then reads that as an explicit currency (the resolver's first leg), so on analytics, chart and dataset faces a dynamic field's defaultCurrency still shows while every field face now shows the tenant's. Same seam, producer side, other repo; not this PR's surface, but the two repos now disagree on dynamic mode until objectstack reads the mode too.
    4. objectui comments that restate the old precedence and are now incomplete (none wrong about a key): packages/fields/src/index.tsx above the CurrencyCellRenderer call ("currencyConfig.defaultCurrency → the tenant default"), packages/fields/src/widgets/CurrencyField.tsx ("field currency → currencyConfig → tenant default"), the columnCurrency docblock in packages/fields/src/widgets/GridField.tsx, packages/plugin-detail/src/synth/buildDefaultPageSchema.ts ("currency → currencyConfig.defaultCurrency → tenant default"), and the comment in packages/fields/src/__tests__/formatCurrency.minorUnits.test.tsx ("field -> currencyConfig -> tenant"). Also type-only: the internal (unexported) formatSummaryLabel in packages/plugin-grid/src/useColumnSummary.ts types its column as currencyConfig?: { defaultCurrency?: string }; the runtime object still carries the mode, so behaviour is right, but the type no longer names what the resolver reads. A follow-up sweep, outside this claim's surface as the PR says.
    5. Two consumers that never hand the resolver a currencyConfig: the ObjectGrid mobile card amount and the dashboard buildFieldMeta cell bag. Pre-existing (the objectui#10354 class), unchanged by this PR, reported by the dev with probe measurements for carding.
    6. Process: the objectui#10354 changeset edit is outside the claim's declared file surface ("stop on breach"); the dev applied the os-dev four-condition exemption, all four of which hold, and left the file-surface amendment to the seat as an open question. Accepting it (option A) is consistent with the exemption's two debts.

    Verdict

    PASS. No FAIL items. The resolver honours the spec's two modes exactly as the triage ruled, the parsed-empty config lands on the tenant default and never CN¥, the one-member widening is the spec's enum and is re-exported unchanged by @object-ui/fields, all six faces render through it (ablation: 19 red across the resolver and every face, fixed and control rows green), no fixed field changes rendering, the changeset is minor with true prose, and the objectui#10354 changeset correction is minimal with byte-identical front matter. The boundary flags above are for follow-up cards, not for this PR.

    Seat's note

    The record passes every derived judgment (①1–①6) and the semver level (②), with no FAIL items. Its boundary flags are carried as follows:

    ⇒ PASS, and the PR enters the merge queue.

    domain:ui seat #1 · review · 2026-09-25T03:33Z

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions