Skip to content

fix(i18n): a dynamic-mode currency field shows the tenant currency, not its defaultCurrency (#10422) - #10461

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-10422-currency-mode-dynamic-tenant-default
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-10422-currency-mode-dynamic-tenant-default

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #10422
Clause-②: yes

A currency field in dynamic mode now shows the tenant's currency on every face, not its currencyConfig.defaultCurrency. resolveFieldCurrency in @object-ui/i18n reads currencyConfig.defaultCurrency only when currencyMode is 'fixed'. Its parameter type gains currencyConfig.currencyMode?: 'dynamic' | 'fixed', the spec's enum. No other export changes.

In a USD tenant, a field declaring currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } read €3,456; it now reads $3,456. An empty currencyConfig, which the spec parses to a dynamic CNY config, read CN¥; it now reads $. A fixed field renders as before.

The contract, quoted from the installed spec

@objectstack/spec 17.4.0, the version in objectui's lockfile, CurrencyConfigSchema:

  • currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)')
  • defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)')

FieldSchema guidance for the key currency, which it refuses: "currency is not a field key — a fixed currency is declared as currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'JPY' }. A field without one uses the tenant default at runtime."

Measured by parsing through that installed spec from this worktree:

input result
CurrencyConfigSchema.parse({}) {"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}
CurrencyConfigSchema.parse({ defaultCurrency: 'EUR' }) {"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}, so an absent mode is dynamic
CurrencyConfigSchema.parse({ currencyMode: 'fixed', defaultCurrency: 'JPY' }) {"currencyMode":"fixed","defaultCurrency":"JPY"}
FieldSchema with a top-level defaultCurrency: 'GBP' refused, unrecognized_keys
FieldSchema with a top-level currency: 'GBP' refused, unrecognized_keys, with the guidance above

The precedence (tenant USD)

step the field carries the resolver returns why
1 an explicit currency that code Not a FieldSchema key, but the spec declares currency on the metric widget and on dataset measures, and looser column configs carry it. Kept first: nothing in the spec says otherwise.
2 currencyConfig with currencyMode: 'fixed' its defaultCurrency The spec's one fixed-currency declaration.
skipped currencyConfig with currencyMode: 'dynamic' falls through The FieldSchema guidance: the tenant default. ⛔ Not defaultCurrency.
skipped currencyConfig with no currencyMode falls through The spec defaults the mode to dynamic (measured above).
skipped the spec-parsed empty config (dynamic, CNY) falls through Never CN¥.
3 a legacy top-level defaultCurrency that code A pre-spec spelling: 6c3f018a8 added it as the "canonical top-level" key, which FieldSchema now refuses as unrecognized. Kept in its place.
4 none of the above the tenant default localization.currency, ADR-0053
5 no tenant default either undefined The amount shows as a plain number.

Triage point 1 says a value carrying its own user-selected currency should win. Nothing carries one today: the spec deprecates CurrencyValueSchema, and a currency value is a bare number. So there is nothing to implement. The docblock says where such a code would rank: ahead of the tenant default.

Consumers: what each call site passes

face call what it passes does currencyMode reach the resolver?
list cell, CurrencyCellRenderer (@object-ui/fields) resolveFieldCurrency(field as any, tenantCurrency) the cell's field bag as handed yes. The whole def on auto-generated grid columns and the detail panel. The three configured grid paths copy currencyConfig verbatim since objectui#10354.
field widget, CurrencyField resolveFieldCurrency(currencyField, tenantCurrency) the whole field prop yes
metric tile, ObjectMetricWidget resolveFieldCurrency(valueFieldDef, tenantCurrency) the object schema's def for aggregate.field yes
detail summary chip, DetailView resolveFieldCurrency({ ...objField, ...sectionField }, tenantCurrency) the object def and the section field, merged yes. A section-level currencyConfig replaces the object's as a whole.
gantt tooltip, ObjectGantt resolveFieldCurrency(def as any, tenantCurrency) the object schema's def yes
grid summary footer, useColumnSummary resolveFieldCurrency(column, tenantDefault) hints carrying currencyConfig: meta?.currencyConfig verbatim yes
GridField computed currency column resolveFieldCurrency(undefined, tenantCurrency) nothing, by design (objectui#10355) not applicable, unchanged
ObjectGrid mobile card amount resolveFieldCurrency(amountCol as any, tenantCurrency) a table column that carries no currency key not applicable. See Out of scope.

No consumer needed a change. Every consumer that reads a currencyConfig passes it through whole.

Pins

  • At the resolver (packages/i18n/src/__tests__/resolveFieldCurrency.test.ts): the truth table under a USD tenant. It covers fixed, dynamic, parsed-empty (parsed through the installed spec, and as a literal), no mode (the spec's default is read from the spec itself), dynamic with no tenant default, fixed with no code, an explicit currency over both modes, the legacy defaultCurrency next to each mode, and none. A control row feeds the showcase configs in objectstack at 246314df, all fixed USD (account, field-zoo, semantic-zoo), under a JPY tenant: they read USD.
  • One agreement row per face: list cell and field widget (packages/fields/src/__tests__/currencyMode.faces-10422.test.tsx), metric tile (ObjectMetricWidget.currencyMode-10422.test.tsx), detail chip (summaryChip.currencyMode-10422.test.tsx), gantt tooltip (ObjectGantt.currencyMode-10422.test.tsx), and grid summary (useColumnSummary.currencyMode-10422.test.tsx). Each row asserts two things. A dynamic field, and a spec-parsed empty config, read exactly what a field with no currencyConfig reads on that face. That reference is itself pinned to the tenant $ as absolute bytes. A fixed JPY field keeps ¥. The grid's configured cells are held to the whole-def cell by gridFixedCurrency-10354.test.tsx, which stays green. Its dyn row now reads $3,456 on both sides.
  • Tests the fix made false: packages/fields/src/__tests__/resolveFieldCurrency.test.ts and CurrencyField.minorUnits.test.tsx used a mode-less currencyConfig to stand for a fixed one. They now say currencyMode: 'fixed'. The re-export test also gains a dynamic row.

Ablation (on committed code, 34795f4b4)

The mutation was the mode read, removed with ablation-replace.mjs. The replacement text: (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) became config?.defaultCurrency.

  • On disk: anchor x1 -> x0, replacement x0 -> x1, blob f192e57d0f84 -> af1b29163c2f. Inside the run, grep -c found the mode read 0 times and the ablated line once.
  • The resolver resolves through the vitest alias to packages/i18n/src, so no build or dist step applies.
  • 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, as did the resolver's no-mode, dynamic-with-no-tenant and legacy-with-dynamic rows. Every fixed and control row stayed green.
  • Predicted versus observed: I predicted the legacy row GREEN and observed it RED. Its second assertion pairs the legacy key with a dynamic config, whose EUR the base read first. The pin's header records this.
  • Restore: blob after restore f192e57d0f84 equals the blob at HEAD, and git diff HEAD is empty.

Reverse type check (at 8380484d6, after the closure build). I wrote currencyMode: 'fixd' into the dynamic row of packages/fields/src/__tests__/resolveFieldCurrency.test.ts. That file imports the resolver through @object-ui/fields from @object-ui/i18n's rebuilt dist (paths: {}). tsc -p tsconfig.test.json then fails with TS2820: Type '"fixd"' is not assignable to type '"fixed" | "dynamic" | undefined'. Did you mean '"fixed"'?, and --listFilesOnly lists both test files. Restored by blob equality and an empty git diff HEAD.

Local verification

Final head: 593f65aa3. It differs from 8380484d6 by one line: a regex in currencyMode.faces-10422.test.tsx now spells U+00A0 as an escape instead of a raw byte. The same regex matches the same text.

  • At 593f65aa3:
    • the 8 pin files, plus gridFixedCurrency-10354, plus the two tests that read currency.ts as text (machineLocaleCensus-9909, CurrencyField.symbol): all passed;
    • pnpm --filter @object-ui/fields type-check: exit 0;
    • pnpm check:control-bytes: OK;
    • pnpm check:new-line-citations: 0 new;
    • node scripts/check-changeset-presence.mjs: 9 source files of 6 released packages, 1 changeset declared;
    • pnpm check:changeset-claims: no pending changeset names a touched file, and the two bodies this PR adds or edits are self-consistent;
    • check-changeset-no-major: exit 0;
    • check-changeset-overwrite (report-only): it reports the intended objectui#10354 body edit;
    • check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:test-path-roots, check:pending-changeset-literals: all exit 0.
  • At 8380484d6, from the repo root: vitest run packages/i18n/ packages/fields/ gave 257 files passed and 1 skipped, 4280 tests passed and 7 skipped. packages/plugin-dashboard/ packages/plugin-detail/ packages/plugin-gantt/ gave 406 passed and 1 skipped, 3857 tests passed and 8 skipped. packages/plugin-grid/ ran in two shards: 77 + 77 files, 687 + 786 tests passed.
  • Type-check at 8380484d6, after turbo run build over the dependency closure of fields, plugin-dashboard, plugin-detail, plugin-gantt and plugin-grid (14 tasks, i18n included): type-check exited 0 for @object-ui/i18n, fields, plugin-dashboard, plugin-detail, plugin-gantt and plugin-grid.
  • ESLint over the 9 touched .ts/.tsx files: 0 errors. The 4 modified files keep their base counts exactly: no-explicit-any warnings 3, 1, 1 and 0. The 5 new test files add 10 no-explicit-any warnings, the idiom of the suites they copy (the GanttView stub, as any data sources).

The objectui#10354 changeset (bounded in-place fix)

Its "Behaviour change" paragraph said a dynamic field shows defaultCurrency on the grid, "because resolveFieldCurrency does not read currencyMode". That changeset is still pending, so this PR would publish it false at the next release. The paragraph now says a field whose currencyConfig is in fixed mode shows its code, and a dynamic field still shows the tenant currency. The front matter is byte-identical to the base (od -c).

The four conditions for an in-place fix:

  1. It is the same defect: the sentence describes the resolver this PR replaces.
  2. The replacement text follows mechanically from the pinned truth table.
  3. No open claim holds the file: objectui#10354 is merged.
  4. It falls in the same gate family: changesets.

This file is outside the claim's declared file surface. The report says so.

Acceptance notes

  • Several comments restate the precedence without the fixed condition: the CurrencyCellRenderer comment in packages/fields/src/index.tsx, CurrencyField.tsx, the columnCurrency docblock in GridField.tsx, and a buildDefaultPageSchema.ts comment. They are now incomplete, not wrong about any key. Left alone, because they are outside this claim's file surface. Carrier: none named.
  • No objectui doc describes currency resolution, so no guide or README page needed an update.

Out of scope (reported to the seat, not fixed here)

  • The ObjectGrid mobile card amount and the dashboard ObjectDataTable cell (buildFieldMeta in recordFields.tsx) never hand the resolver a currencyConfig. Throwaway probes, deleted afterwards, measured both under a USD tenant with a fixed-JPY 1234: the desktop grid read ¥1,234, the mobile card read $1.2K, and the dashboard table cell read $1,234.
  • objectstack's skills/objectstack-data/rules/field-types.md states the resolution chain without the mode, and describes dynamic as a per-record { value, currency }. The spec says a currency value is a bare number.

Generated by Claude Code

…hows the tenant currency

`currencyConfig.defaultCurrency` is the field's currency only under
`currencyMode: 'fixed'`. A dynamic config, and a config with no mode (the
spec's default is dynamic), falls through to the legacy top-level
`defaultCurrency`, then the tenant default. The parameter type gains
`currencyMode`. The truth table is pinned beside the resolver.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Each face that resolves a currency through `resolveFieldCurrency` (list
cell, field widget, metric tile, detail summary chip, gantt tooltip, grid
summary footer) gets a row: a dynamic field and a spec-parsed empty config
read what a field with no `currencyConfig` reads, and a fixed field keeps
its code. Two fields tests whose mode-less fixtures stood for "fixed" now
say `currencyMode: 'fixed'`.

The objectui#10354 changeset's dynamic-mode sentence described the
resolver this change replaces; its body now says a dynamic field keeps
the tenant currency. Its front matter is byte-identical.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…escape

The editor materialized the escape into a raw U+00A0 byte, which
`no-irregular-whitespace` refuses. Same regex, same match.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3044.7 KB 3104.5 KB
Main entry chunk (gzip) 148.0 KB 350 KB
Entry file index-BTP7nRxG.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.68KB 6.20KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 543.20KB 129.69KB
core (index.js) 9.22KB 3.71KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 223.86KB 62.25KB
fields (index.js) 256.84KB 65.07KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.22KB 14.56KB
plugin-charts (index.js) 74.76KB 20.82KB
plugin-chatbot (index.js) 198.27KB 47.18KB
plugin-dashboard (index.js) 133.59KB 35.39KB
plugin-designer (index.js) 216.12KB 44.37KB
plugin-detail (index.js) 231.66KB 61.23KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 146.34KB 37.47KB
plugin-gantt (index.js) 168.25KB 41.46KB
plugin-grid (index.js) 215.43KB 58.90KB
plugin-kanban (index.js) 49.30KB 15.39KB
plugin-list (index.js) 114.37KB 28.24KB
plugin-map (index.js) 21.83KB 7.07KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.64KB 8.94KB
plugin-tree (index.js) 10.52KB 3.69KB
plugin-view (index.js) 87.90KB 21.97KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 114.58KB 37.60KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 03:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit ea9d17f Sep 25, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10422-currency-mode-dynamic-tenant-default branch September 25, 2026 03:47
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rency only under currencyMode 'fixed' (objectstack-ai#20091) (objectstack-ai#20119)

Fixes objectstack-ai#20091
Clause-②: no

## What was wrong

`AnalyticsServicePlugin` passes each source field's metadata to the
service through `sourceFieldMeta`. That hook passed on
`currencyConfig.defaultCurrency` without reading
`currencyConfig.currencyMode`. `queryDataset` resolves a monetary
measure column's `currency` in this order: the measure's own `currency`,
then that relayed value, then `ExecutionContext.currency`. So a
`dynamic` field's `defaultCurrency` showed on the dataset result where
the tenant currency belonged. The spec says otherwise:

- `CurrencyConfigSchema` defaults `currencyMode` to `'dynamic'`. Its own
precision refinement treats `defaultCurrency` as the field's one
currency only under `'fixed'`.
- The `FieldSchema` guidance says a fixed currency is `currencyConfig: {
currencyMode: 'fixed', defaultCurrency: … }`, and "A field without one
uses the tenant default at runtime".

objectstack-ai/objectui#10461 changed the renderer's
`resolveFieldCurrency` to read `defaultCurrency` only under `'fixed'`.
The renderer reads an explicit column `currency` first, though, so the
analytics faces kept overriding that rule.

## Measured before the fix (commit `9a98c59b44`: base `fa00ebf447` plus
the new test file only), and after

`AnalyticsServicePlugin` booted over a real `ObjectQL` engine on
`SqliteWasmDriver`, with the object registered on the engine.
`ExecutionContext.currency = 'USD'`, and every authored
`defaultCurrency` is `EUR`. Each cell is the result column's `currency`
on the live path / the draft-preview path of `queryDataset`:

| case | raw registration, before | parsed registration
(`ObjectSchema.parse`), before | after the fix (both registrations) |
|:---|:---|:---|:---|
| `{ currencyMode: 'fixed', defaultCurrency: 'EUR' }` | EUR / EUR | EUR
/ EUR | EUR / EUR |
| `{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }` | **EUR / EUR**
| **EUR / EUR** | USD / USD |
| `{ defaultCurrency: 'EUR' }` (no mode) | **EUR / EUR** | **EUR / EUR**
| USD / USD |
| `{}` (parses to `dynamic` + the schema's `CNY`) | USD / USD | **CNY /
CNY** | USD / USD |
| currency field, no `currencyConfig` | USD / USD | USD / USD | USD /
USD |
| measure `currency: 'JPY'` over the dynamic field | JPY / JPY | JPY /
JPY | JPY / JPY |
| measure `currency: 'JPY'` over the fixed field | JPY / JPY | JPY / JPY
| JPY / JPY |
| non-monetary control (`number` field) | none / none | none / none |
none / none |

Bold cells break the spec rule. The "before" columns are also in the
message of commit `9a98c59b44`. The "after" column was measured on head
`131de3a37a` with the same probe.

**What "no mode" looks like at the relay.**
`SchemaRegistry.registerObject` stores what it is handed and does not
parse. A raw `registerObject` call therefore arrives with no
`currencyMode` key at all. The declared-stack path parses first, which
fills in `currencyMode: 'dynamic'`, and fills in `defaultCurrency:
'CNY'` when the author wrote none. Measured with `ObjectSchema.parse`:
`{ defaultCurrency: 'EUR' }` parses to
`{"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}`, and
`{}` parses to
`{"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}`. The
fix compares `currencyMode === 'fixed'`, so both shapes fall to the
tenant default. The relay supplies no default of its own.

**Every reader of `sourceFieldMeta().defaultCurrency` in the package:
one.** It is `enrichResultColumns` (`analytics-service.ts`, the currency
chain). The live and draft-preview paths of `queryDataset` both reach
it, and the same loop covers `__compare` columns. A grep over `src/` for
`defaultCurrency` and for `currenc` finds nothing else:
- `sourceFieldMeta`'s other readers take only `.type` (the compile gate
at `registerDataset`, the time-dimension type, `measureResultType`) or
`max` (`percentScaleOf`).
- Dimension columns get only a `label`.
- Drill metadata and dimension value labels carry no currency.
- The package has no export face.
- The cube face (`AnalyticsService.query` and its SQL echo) never writes
a column `currency`. The suite pins that as measured.

## The fix

- `plugin.ts`: the relay answers `defaultCurrency` only when
`currencyConfig.currencyMode === 'fixed'`, and `undefined` otherwise.
The rule is decided at the one producer, so the one reader inherits it.
An explicit measure `currency` still wins.
- No reader needs the raw `defaultCurrency`, so no second key was added.
The public `AnalyticsServiceConfig.sourceFieldMeta` return type is
unchanged. Its TSDoc now says what `defaultCurrency` means there (the
field's FIXED currency) and what a host writing its own
`sourceFieldMeta` must answer.
- No `packages/spec` change and no objectui change.

## Tests

New
`packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts`,
19 cases. Each case runs end to end through the plugin's relay, the real
engine and driver, and the `queryDataset` result, under both
registration shapes. Each asserts the live and the preview path. The
cases:
- fixed, dynamic, no mode, empty config, no config;
- an explicit measure currency over a dynamic field and over a fixed
one;
- a non-monetary control;
- two fixture controls: the shape really differs, and the live rows
really came from the driver;
- the cube-face pin.

Before the fix: 5 failed, 14 passed. After: 19 passed. The failures
before the fix were the 5 bold cells above:

```text
AssertionError: expected 'EUR' to be 'USD'   (dynamic, raw + parsed; no mode, raw + parsed)
AssertionError: expected 'CNY' to be 'USD'   (empty config, parsed)
Tests  5 failed | 14 passed (19)
```

**Ablation.** Every negative pin was ablated through `node
scripts/ablation-replace.mjs` in WRAP mode, after the fix was committed.
Each mutation was proven on disk: the anchor count went 1 to 0 and the
blob changed. Each restore was proven too: the blob equals `HEAD` and
`git diff HEAD` is empty.

| leg | mutation | red |
|:---|:---|:---|
| A | `plugin.ts`: the pre-fix relay, which passes `defaultCurrency`
whenever present | 5 failed / 14 passed: dynamic ×2, no mode ×2, empty
config (parsed) |
| B | `plugin.ts`: a relay that fills in the schema's `CNY` itself and
ignores mode | 8 failed / 11 passed: dynamic ×2, no mode ×2, empty
config ×2, no config ×2 |
| C | `analytics-service.ts`: the cube face writes a `currency` on its
columns | 13 failed / 6 passed, including the cube-face pin |
| D | `analytics-service.ts`: every measure treated as monetary | 2
failed / 17 passed: the non-monetary control ×2 |
| E | test fixture: the raw shape is parsed too | 1 failed / 18 passed:
the shape control |

Leg C's first attempt did not run. Its replacement text still contained
the anchor, so `ablation-replace` refused the leg before running
anything ("the anchor count moved 1 -> 1"), and the file was restored.
It was re-run with a replacement that consumes the anchor, and that run
is the one in the table.

Package suite on the merged head `131de3a37a`: `pnpm --filter
@objectstack/service-analytics test`, 125 files / 2905 tests passed.
`pnpm --filter @objectstack/service-analytics run typecheck` exits 0.
`tsc --listFiles` includes the new test file (1 hit; the control
`plugin.ts` also has 1).

## Changeset

`.changeset/20091-analytics-currency-mode.md`:
`@objectstack/service-analytics`, **patch**, with a `Clause-②: no` line.
It is a fix to a released package that adds or removes no authorable
key, export or type member. `check-changeset-no-major --base
origin/main` exits 0, and so does `check-adr-0087-registration --base
origin/main`: the changeset is not declared breaking, so no ADR-0087
marker is owed.

## Acceptance notes

- **The code labels this chain "ADR-0053", but ADR-0053 says nothing
about currency.** ADR-0053 is date/datetime semantics: `grep -ci
currenc` on it gives 0, and the control `datetime` gives 73. This PR
corrects the label on the lines it rewrites (`plugin.ts` relay comment,
`analytics-service.ts` TSDoc and chain comment). It does not touch the
same label in `preview-column-enrichment.test.ts` (the header table and
two test names). No carrier: noted, not filed.
- **`{ currencyMode: 'fixed' }` with no code differs by registration
door.** Not measured, a source read. A raw registration relays
`undefined`, so the column gets the tenant default. A parsed one relays
the spec's defaulted `CNY`, which the spec's own refinement treats as
the field's currency in fixed mode. The relay follows the registered
shape on purpose. Supplying the spec default here would be a second copy
of it.
- **A host that writes its own `sourceFieldMeta`** must apply the same
rule. A type cannot express it, so the TSDoc and the changeset state it.

## Local verification (head `131de3a37a`, after the `origin/main` merge)

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 61 commands from this diff, and all 61 exit 0.
`--ran` (each command recorded with its exit code) answers: "61 derived,
61 run, 0 NOT-MEASURED, 0 UNRUN".
- The derivation added six families to the dispatch-time list:
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure`, `check:type-check-coverage`,
`check:type-check-debt` and `check:where-matcher`.
- `check:dual-build-cjs-loads` and `check:type-check-debt` first
answered `PREREQUISITE NOT MET` (exit 3), which is not a measurement.
Both exit 0 after `turbo run build --filter='./packages/*'
--filter='./packages/*/*'` (72/72 tasks).
- `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` exits 0: 5
citations, 3 resolve, 2 are cross-repo.
- **Lint, narrowed to the touched files** (`pnpm lint` over the whole
repo is CI's job):
- `eslint --no-inline-config --format json` on the 3 touched TypeScript
files exits 0. The JSON reports 3 files, with 0 errors and 0 warnings.
- `eslint --print-config` puts all 3 files in the linted population: 6
rules each on the two sources, and 5 on the test file.
- Linting only these files misses nothing. `eslint.config.mjs` enables
no type-aware linting (no `parserOptions.project`), and its local rules
are scope-based and per-file. A diff to these files therefore cannot
change a verdict on any untouched file.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant