Repository navigation
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
Activity
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions分诊首次定级:
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(findingremoved — graded); rationale: the spec's FieldSchema guidance says a field without a fixed currency 「uses the tenant default at runtime」 andCurrencyConfigSchema.currencyModenames two modes, but the one resolver honours onlyfixed— adynamicconfig with adefaultCurrencyrenders that currency on every consumer face (measured in objectui#10418's agreement rows:€3,456in a USD tenant), and a spec-parsed emptycurrencyConfigmaterializes 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/main57a2bc28和 objectstackorigin/main上核对。本席核对
- objectui
currency.ts第 32 行读field?.currencyConfig?.defaultCurrency,文件头注释第 12 行把它写作「(fixed mode)」,但代码里没有任何地方判断currencyMode。 - objectstack
packages/spec/src/data/field.zod.ts:- 第 439 行
currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic'); - 第 440 行
defaultCurrency: z.string().length(3).default('CNY'),描述是「Default or fixed currency code」; - FieldSchema 的指引(约第 1059–1061 行)写着:固定币种用
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }声明,「A field without one uses the tenant default at runtime」。
- 第 439 行
- PR fix(plugin-grid): a fixed-currency field shows its own currency in the grid cell and the summary footer (#10354) #10418(finding(plugin-grid): a fixed-currency field (
currencyConfig.defaultCurrency) shows the tenant's currency on the grid cell and the summary footer, because the column cell bag andcolumnHintsdropcurrencyConfig#10354)已合并为c3025ead,表格单元格和汇总行现在也走这个解析函数,所以影响面随之扩大。 - 卡面的影响面测量本席没有重跑。
为什么不需要维护者裁决
defaultCurrency的描述「Default or fixed」在动态模式下可以有两种读法,但 FieldSchema 的指引把运行时显示说得很明确:没有固定币种,就用租户默认。按「协议优先」,代码向规范对齐即可。另一个方向是退役currencyMode,那是 spec 那边的问题,要另开卡,本卡不做。定级说明
p2:金额显示成错误的币种,属于严重的显示错误。但今天仓库里没有任何动态配置,只有作者主动写了才会触发,所以不给 p1。
执行要点
- 解析函数读
currencyMode:fixed模式用defaultCurrency;dynamic模式退到租户默认币种。将来如果值本身带了用户选择的币种,就优先用它。 - 参数类型补全:解析函数的参数类型加上
currencyMode。 - 注意解析后的配置:空的
currencyConfig经 spec 解析后会补成{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }。按第 1 点它会落到租户默认,不会显示成人民币;用钉子锁住这一点。 - 钉子:放在解析函数上,覆盖固定、动态、空配置三种情况;每一类显示位置(单元格、字段、指标卡、详情、甘特图、表格汇总)再加一行一致性用例。
Generated by Claude Code
- objectui
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 25, 2026 objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 —
domain:uiexecution 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 readscurrencyMode; 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.mdbody (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 touchescurrency.ts. Livepm:dispatchedclaims read at the same time ⇒ none names it.Scope
The triage grading (
5825117382), protocol-first, so no new ruling is owed:- The resolver reads
currencyMode:fixedusesdefaultCurrency;dynamicfalls back to the tenant default. A value that carries its own user-selected currency, if one exists later, wins. - 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. - The spec-parsed empty
currencyConfig({ currencyMode: 'dynamic', defaultCurrency: 'CNY' }) lands on the tenant default, neverCN¥. Pinned. - Pins on the resolver (fixed, dynamic, empty), plus one consistency row per consumer face.
⛔ Retiring
currencyModein 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 indynamicmode, becauseresolveFieldCurrencydoes not readcurrencyMode." 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:uiseat #1 ·session_01BA3nKVUwKQJf8DBxrSVtNC· claim · amended 2026-09-25T03:33Z- The resolver reads
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-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
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions✅ ACCEPT — PR objectui#10461 at
593f65aa3· contract review PASS · entering the merge queuedomain:uiseat #1,session_01BA3nKVUwKQJf8DBxrSVtNC. The claim5825512254saysClause-②: yes: the exportedresolveFieldCurrencyin@object-ui/i18nwidens 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:
- The ObjectGrid mobile card amount drops a fixed currency ⇒ duplicate of objectui#10423 (same site, same probe shape). Not re-filed.
- The dashboard
buildFieldMetabag never carriescurrencyConfig⇒ filed finding(plugin-dashboard):buildFieldMetanever readscurrencyConfig, so a fixed-currency field in a dashboardObjectDataTablerenders in the tenant currency #10463. - The objectstack skill
field-types.mdand the docs pagefield-types.mdxteachdynamicas a per-record{ value, currency }and a mode-less resolution order ⇒ filed finding(skills,docs): the currency teaching infield-types.mdandfield-types.mdxcontradicts the spec:dynamicis taught as a per-record{ value, currency }, and the resolution order has no mode objectstack#20090. Record ③2 addscontent/docs/data-modeling/fields.mdx(same drift); that addition is posted on finding(skills,docs): the currency teaching infield-types.mdandfield-types.mdxcontradicts the spec:dynamicis taught as a per-record{ value, currency }, and the resolution order has no mode objectstack#20090 for its carrier. - Record ③3: objectstack
service-analyticsrelays a measure'sdefaultCurrencywithout readingcurrencyMode⇒ filed finding(service-analytics): a measure's currency is resolved withoutcurrencyMode, so adynamicfield'sdefaultCurrencyshows on analytics faces while field faces show the tenant currency objectstack#20091. - Comments that restate the old precedence without the
fixedcondition, and the internalformatSummaryLabelparameter type (record ③4) ⇒ PR Acceptance notes. 承接者:无.
Maintainer veto window (the seat does not hold landing for this; it is named in the round report):
- The visible change: a currency field whose
currencyConfigisdynamic, or names no mode (including the spec-parsed empty config, which materializes as{ dynamic, CNY }), now shows the tenant currency instead of itsdefaultCurrency. That applies on six faces: the list cell, form widget, metric tile, detail chip, gantt tooltip and grid footer. Everyfixedfield renders as before, including the three objectstack showcase fields, which are allfixed. - Until finding(service-analytics): a measure's currency is resolved without
currencyMode, so adynamicfield'sdefaultCurrencyshows on analytics faces while field faces show the tenant currency objectstack#20091 lands, analytics, chart and dataset faces still show adynamicfield'sdefaultCurrency, so the two repos disagree ondynamicmode in the meantime.
Contract review
Served-tier:
CONTRACT_REVIEW_TIER
Head-sha:593f65aa353fce9af24e8b3bf929d0cfa04afc1aReviewed 2026-09-25T03:25Z, read-only, against objectui
origin/maine6203d756(merge base of the PR:94436f42a;git diff --stat 593f65aa3 origin/mainover 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 claim5825512254, 2026-09-25T02:08Z; the dev'sos-dev-report5826076834, 2026-09-25T03:14Z). PR #10461 body. All 11 changed files viagit 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 ofcurrency.tsand of the objectui#10354 changeset. The installed spec: objectuipnpm-lock.yamlpins'@objectstack/spec@17.4.0'; the dist I measured through is the17.4.0tarball 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.yamlis empty). objectstackpackages/spec/src/data/field.zod.ts(CurrencyConfigSchemaat "currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic')" and "defaultCurrency: z.string().length(3).default('CNY')"; theFieldSchemacurrencyguidance ending "A field without one uses the tenant default at runtime."; the deprecatedCurrencyValueSchemadocblock; theField.currencyfactory). objectuiAGENTS.md§9 (the changeset-presence rule and the version-alignment / no-majorrule). objectstackAGENTS.mdPost-Task Checklist step 3 (theClause-②declaration: "yestakes at leastminor") and Prime Directive #14 (governed surfaces), with theGOVERNED_SURFACESregister inscripts/pm/check-governed-merges.mjs. objectstack.claude/agents/os-dev.md(the four-condition in-place exemption). objectuiscripts/check-changeset-overwrite.mjsandscripts/check-changeset-claims.mjsheaders. The consumer call sites (below) andgridFixedCurrency-10354.test.tsx. objectstackskills/objectstack-data/rules/field-types.md,content/docs/data-modeling/field-types.mdx,content/docs/data-modeling/fields.mdx, andpackages/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
593f65aa3under the scratchpad, withnode_modulessymlinked from the sibling worktree above; the rootvitest.config.mtsaliases every@object-ui/*import to<root>/packages/*/src, so every face under test resolved the scratch tree'spackages/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
-
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 bodyfield?.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 currencyJPY + fixed EURJPY JPY explicit currencyKWD + dynamic EURKWD KWD fixed + defaultCurrencyJPYJPY JPY fixed, no code (unparsed literal) USD USD dynamic + defaultCurrencyEUREUR USD dynamic + EUR, no tenant default EUR undefined no mode + defaultCurrencyEUR (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 defaultCurrencyGBP aloneGBP GBP legacy GBP + fixed JPY JPY JPY legacy GBP + dynamic EUR EUR GBP {}with tenant /nullwith tenantUSD / USD USD / USD {}no tenant /undefined, undefinedundefined undefined showcase accountconfig ({precision:2, currencyMode:'fixed', defaultCurrency:'USD'}) under a JPY tenantUSD USD showcase field-zooconfig ({currencyMode:'fixed', defaultCurrency:'USD', precision:2}) under a JPY tenantUSD 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 tenantUSD, neverCNY(triage point 3). The explicitcurrencyleg stays first, which the triage did not ask to move and the spec declares on the metric widget and dataset measures. The legacy top-leveldefaultCurrencyleg is unchanged in position; the one row where it now wins (legacy + dynamic config) cannot be authored underFieldSchema, 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 owndefaultCurrency: 'CNY'and so renders yuan underfixed, which is the spec's stated meaning of a fixed config's code (the "(possibly defaulted)defaultCurrencyIS the field's one currency" comment infield.zod.ts); and "a value carrying its own selected code" (triage point 1's future clause) is not implemented because nothing carries one (CurrencyValueSchemais deprecated as never consumed; a currency value is a bare number), and the docblock says where it would rank. PASS -
The exported signature widening.
git diff origin/main...593f65aa3 -- packages/i18n/src/currency.tstouches only the docblock, the parameter type and the body. The type goes fromcurrencyConfig?: { defaultCurrency?: string }tocurrencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string }: exactly one added optional member; return type, the other members and thetenantDefaultparameter are unchanged. The union equals the installed spec's enum,["dynamic","fixed"](measured above).packages/i18n/src/index.tsexports onlyresolveFieldCurrencyfrom./currency.js(no parameter type is exported by name, so nothing else moves).@object-ui/fieldsre-exports it:packages/fields/src/currency.ts(export { resolveFieldCurrency } from '@object-ui/i18n';) andpackages/fields/src/index.tsx(export { resolveFieldCurrency };). A standalonetsc --strictprobe against the head source and the 17.4.0.d.mts: the spec'sCurrencyConfig(z.input) andCurrencyConfigParsed(z.infer) are both accepted ascurrencyConfig; 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 -
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.tsxCurrencyCellRenderer: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 currencyConfigverbatim (ObjectGrid.tsx"if (objectDefField.currencyConfig) fieldMeta.currencyConfig = objectDefField.currencyConfig;" and the twofieldDef.currencyConfigtwins)yes packages/fields/src/widgets/CurrencyField.tsx:resolveFieldCurrency(currencyField, tenantCurrency)the whole fieldpropyes packages/plugin-dashboard/src/ObjectMetricWidget.tsx:resolveFieldCurrency(valueFieldDef, tenantCurrency)the object schema's def for aggregate.fieldyes packages/plugin-detail/src/DetailView.tsx:resolveFieldCurrency({ ...objField, ...sectionField }, tenantCurrency)object def and section field merged; a section currencyConfigreplaces the object's wholeyes 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.tsxcolumnCurrency:resolveFieldCurrency(undefined, tenantCurrency)nothing, by design (objectui#10355) n/a, unchanged packages/plugin-grid/src/ObjectGrid.tsxmobile card:resolveFieldCurrency(amountCol as any, tenantCurrency)a table column def carrying neither currencynorcurrencyConfign/a Trimmed bags, as the dev reports: the ObjectGrid mobile card above, and the dashboard
buildFieldMetainpackages/plugin-dashboard/src/recordFields.tsx(currency: overrides.currency ?? meta?.currency ?? meta?.defaultCurrency, nocurrencyConfig), which feedsCurrencyCellRenderer. Both resolvecurrency-or-tenant on base and on head alike: with nocurrencyConfigin 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 -
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 -cof the front matter block at base and head both md598c573916617f8f67d6529589733543a('@object-ui/plugin-grid': patch). The old sentence "That includes a field indynamicmode, becauseresolveFieldCurrencydoes not readcurrencyMode." would publish false at the next release once this PR lands; the replacement ("a field whosecurrencyConfigis infixedmode now shows ... A field indynamicmode still shows the tenant currency, becauseresolveFieldCurrencyreadscurrencyConfig.defaultCurrencyonly infixedmode (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: "resolveFieldCurrencyreadscurrencyConfig.defaultCurrency, butObjectGridnever handed it that key" (still true, it reads it underfixed); "A field withoutcurrencyConfigstill shows the tenant currency" (true, row{}); the JPY-fixed$1,234history and theuseColumnSummaryparameter-type sentence are untouched facts of objectui#10354.check-changeset-overwriteis 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 -
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.tsblobf192e57d0f84 -> af1b29163c2f, the mode read(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||replaced byconfig?.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/fieldsre-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,456tile 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,234rows on all six faces, the explicit-currency, none and showcase rows at the resolver), andgridFixedCurrency-10354stayed green as its agreement rows must (both sides move together). Restore: blob back tof192e57d0f84=HEAD:packages/i18n/src/currency.ts,git diff HEADempty. 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 -
Existing authored metadata.
git grep -n currencyModeover objectstack non-test source: the three authors areexamples/app-showcase/src/data/objects/account.object.ts({ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }),field-zoo.object.ts({ currencyMode: 'fixed', defaultCurrency: 'USD', precision: 2 }) andsemantic-zoo.object.ts({ precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' }), allfixed. The truth table's showcase rows readUSDunder 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 nocurrencyConfig, andFieldSchema.parse({ type: 'currency' }).currencyConfigisundefined. Adynamicfield in non-test source: none in objectui (git grep currencyModeat head finds only the resolver, its docblock, two grid comments and the two changesets), none in objectstackpackages/**orexamples/**runtime metadata; the one non-test spelling is a prose example in objectstackcontent/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.mddeclares'@object-ui/i18n': minor.Clause-②: yes(an exported parameter type widens by one optional member) takes at leastminorper objectstack AGENTS.md Post-Task Checklist step 3; objectui AGENTS.md's version-alignment rule forbidsmajorin any changeset (check-changeset-no-major), and none is declared.@object-ui/i18n,@object-ui/fieldsand@object-ui/plugin-gridare all in the 40-packagefixedgroup 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, notcurrencyConfig.defaultCurrency) is the changed rows; the two-modes and guidance sentences are the installed spec's own text; "showed€3,456where it should show$3,456" and "showedCN¥" are the ablation's observed base strings; the precedence sentence (explicitcurrency, fixed config, legacydefaultCurrency, tenant default, elseundefined) is the truth table row for row; the six named faces are the six call sites that pass acurrencyConfig; "acurrencyConfigthat names nocurrencyMode" 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 sinceFieldSchemarefuses that key. Level and prose: correct atminor, nomajor.③ Boundary flags
Non-blocking, for the maintainer's veto window.
- objectstack
skills/objectstack-data/rules/field-types.md, "Currency resolution (ADR-0053)" states the chain as "the field's owncurrencyConfig.defaultCurrency-> the tenantlocalization.currencydefault" with no mode, false for adynamicfield once this lands; and its example comment "'dynamic' = per-record{ value, currency }" contradicts the spec's ownCurrencyValueSchemadeprecation ("acurrencyfield's value is a BARE NUMBER everywhere").skills/**is a Tier H governed surface (GOVERNED_SURFACESrowskills-catalog), so this needs its own objectstack card and the maintainer's word; the dev reported it for carding. - objectstack
content/docs/data-modeling/field-types.mdx("Whether currency is per-record or fixed" forcurrencyMode) andcontent/docs/data-modeling/fields.mdx(the example authoringcurrencyMode: '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. - objectstack
service-analyticsresolves a measure's currency mode-blind.plugin.tssourceFieldMetarelaysdefaultCurrency: f.currencyConfig?.defaultCurrency, andanalytics-service.tssetsfc.currency = mc.currency ?? meta?.defaultCurrency ?? context?.currencyon the result column. objectui then reads that as an explicitcurrency(the resolver's first leg), so on analytics, chart and dataset faces adynamicfield'sdefaultCurrencystill 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. - objectui comments that restate the old precedence and are now incomplete (none wrong about a key):
packages/fields/src/index.tsxabove theCurrencyCellRenderercall ("currencyConfig.defaultCurrency→ the tenant default"),packages/fields/src/widgets/CurrencyField.tsx("field currency → currencyConfig → tenant default"), thecolumnCurrencydocblock inpackages/fields/src/widgets/GridField.tsx,packages/plugin-detail/src/synth/buildDefaultPageSchema.ts("currency → currencyConfig.defaultCurrency → tenant default"), and the comment inpackages/fields/src/__tests__/formatCurrency.minorUnits.test.tsx("field -> currencyConfig -> tenant"). Also type-only: the internal (unexported)formatSummaryLabelinpackages/plugin-grid/src/useColumnSummary.tstypes itscolumnascurrencyConfig?: { 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. - Two consumers that never hand the resolver a
currencyConfig: the ObjectGrid mobile card amount and the dashboardbuildFieldMetacell bag. Pre-existing (the objectui#10354 class), unchanged by this PR, reported by the dev with probe measurements for carding. - 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 isminorwith 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:
- ③1 and ③2 → finding(skills,docs): the currency teaching in
field-types.mdandfield-types.mdxcontradicts the spec:dynamicis taught as a per-record{ value, currency }, and the resolution order has no mode objectstack#20090. - ③3 → finding(service-analytics): a measure's currency is resolved without
currencyMode, so adynamicfield'sdefaultCurrencyshows on analytics faces while field faces show the tenant currency objectstack#20091. - ③4 → PR Acceptance notes.
- ③5 → objectui#10423 and finding(plugin-dashboard):
buildFieldMetanever readscurrencyConfig, so a fixed-currency field in a dashboardObjectDataTablerenders in the tenant currency #10463. - ③6 → the claim amendment above.
⇒ PASS, and the PR enters the merge queue.
domain:uiseat #1 · review · 2026-09-25T03:33Z- added a commit that references this issue
on Sep 28, 2026
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#4execution seat (session_01BP8CMtACxTdLjqR6rhd33C) from theos-dev-reportof 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/mainthe resolver isfield?.currency || field?.currencyConfig?.defaultCurrency || field?.defaultCurrency || tenantDefault || undefined. It never readscurrencyMode, and its parameter type declares onlycurrencyConfig?: { defaultCurrency?: string }.CurrencyConfigSchemadescribescurrencyModeas "dynamic (user selectable) or fixed (single currency)".So a field authored
currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' }renders€in a USD tenant. There is one more trap: the spec'sCurrencyConfigSchemamaterializes a PARTIAL authored config such ascurrencyConfig: {}to{ precision: 2, currencyMode: 'dynamic', defaultCurrency: 'CNY' }when parsed. On a parsed path, then, an empty config rendersCN¥.Reach (measured by the objectui#10418 dev and its reviewer; ⛔ not re-run by the seat)
CurrencyCellRenderer,CurrencyField,ObjectMetricWidget,DetailView,ObjectGantt,useColumnSummary, and, since objectui#10418, the grid's configured-column cells and summary footer.{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }reads€3,456.currencyConfigin three places, allcurrencyMode: 'fixed', and there are zerodynamicor partial configs in non-test source.Field.currency()injects nocurrencyConfig, andFieldSchema.currencyConfighas 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.Grading notes (for triage, not a grade)
currencyModeand, indynamicmode, fall back to the tenant default (or to the user's selection, if one is ever carried). The alternative, retiringcurrencyMode, is a spec-side question and belongs to its own card.spec:CurrencyConfigSchema.currencyMode→i18n:resolveFieldCurrency.Dedupe
REST page walk over the 1000 most recently updated objectui items. The pattern
currencyMode,resolveFieldCurrencynear mode/dynamic, or dynamic near currency ⇒ 1 hit, objectui#10354 itself (the source report; ⛔ not this defect). The controlresolveFieldCurrency⇒ 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 resolverGenerated by Claude Code