Skip to content

currency: no exchange-rate / home-currency conversion — dynamic multi-currency amounts cannot be reconciled or aggregated across currencies #8345

Description

@os-zhuang

Restart-when: the maintainer names multi-currency a priority again, in chat or on this card (record 6051408208)

Status

Maintainer decision (2026-08-13): scheduled for v18.

Context

Surfaced while scoping a solution for an overseas port/terminal customer (2026-08): billing / reconciliation / invoicing must run in multiple currencies, with consolidated reporting in one home currency.

What the platform has today

  • currency field type with currencyConfig — currencyMode: 'dynamic' (per-record currency) or 'fixed', defaultCurrency, precision (packages/spec/src/data/field.zod.ts).
  • ISO 4217 fraction-digit consistency validation at publish time (packages/spec/src/data/currency-fraction-digits.ts — JPY 0, KWD 3, CLDR 48.0 snapshot).
  • Tenant default currency in Localization settings (packages/services/service-settings/src/manifests/localization.manifest.ts, valueDomain: 'iso_4217_currency').
  • Analytics currency labeling resolution chain (ADR-0053): measure currency → field currencyConfig.defaultCurrency → ctx.currency (packages/services/service-analytics/src/analytics-service.ts).

What is missing

  • No exchange-rate table / object, no dated-rate history.
  • No conversion engine: formula / summary roll-ups and analytics aggregations over a dynamic-currency field combine nominal values as if they were one currency — a sum across USD + EUR rows is numerically meaningless.
  • No "record currency + home-currency shadow amount" projection (Salesforce ACM-style) for consolidated reporting.

Grep evidence: exchange rate / fx_rate / currency_conversion have zero implementation hits under packages/. The one prose mention ("Money with exchange rates", content/docs/protocol/objectql/index.mdx) does not match the shipped field.zod.ts behavior and reads as a doc leftover.

Possible scope sketch (for discussion)

  1. M1 — sys_exchange_rate platform object (base currency, quote currency, rate, effective date) + settings for the org home currency (already exists in Localization).
  2. M2 — CEL stdlib conversion function(s) (e.g. convertCurrency(amount, from, to, date?)) usable in formula fields.
  3. M3 — opt-in converted projection for summary roll-ups and analytics measures over dynamic currency fields (aggregate in home currency; keep per-currency breakdown available).
  4. Guard rails: refuse (or warn on) cross-currency sum/avg over a dynamic currency field when no conversion is configured — today it fails silently into wrong numbers.

Workaround today

App-level customization: model an exchange-rate object, snapshot the rate onto each billing record via hook, and compute home-currency amounts with formula. Works, but every implementation reinvents it and the naive-aggregation foot-gun above remains for anyone who doesn't.

Activity

  1. added theissue type on Aug 13, 2026
  2. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage: confirming the filer's domain:spec — M1/M2 both start from packages/spec/src/data/field.zod.ts and currency-fraction-digits.ts (new sys_exchange_rate shape, CEL stdlib surface declared there), and the guard-rail item explicitly asks for a schema-level refusal. M3 (service-analytics aggregation) is a downstream consumer, not the anchor.

    Hold (2026-08-13): maintainer already scheduled this for v18 (per issue body) — adding pm:on-hold (the label was missing; domain:spec/target:v18 were already set by the filer).

    Restart trigger files:

    • packages/spec/src/data/field.zod.ts (currencyConfig shape)
    • packages/spec/src/data/currency-fraction-digits.ts
    • packages/services/service-analytics/src/analytics-service.ts (ADR-0053 currency labeling chain)

    Generated by Claude Code

  3. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    ContributorAuthor

    Hold-condition completion (triage seat, session session_014qTKTqjme5Fp5BH9iRmy6t, on-hold weak-hit audit): the v18 park is sound and stands, but it had no executable boundary wake — the repo emits no v18 signal today (zero milestones, no v18 refs, spec at 17.0.0, no .changeset/pre.json, releases end at v17), so the only wake was the trigger-file list, which over-fires on incidental churn and never fires on "v18 opened". Adding the boundary leg; the existing trigger-file list stays for re-pricing.

    Restart-when: the v18 cycle opens — first true of: .changeset/pre.json exists on origin/main, packages/spec/package.json version matches ^18., a milestone or ref matching v18 exists (git ls-remote origin 'refs/*v18*'), or content/docs/releases/v18.mdx is created — or a maintainer re-schedules the card


    Generated by Claude Code

  4. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    H17 trigger-file notice (spec seat, session_01Rxnd8cyFnoU8V5y21PaTsy, post-landing patrol): this hold's declared trigger file packages/spec/src/data/field.zod.ts was touched by PR #11406 (merged 9086761ed, 2026-08-24 ~15:0xZ). Scope of the touch: the #9689 deleteBehavior family only — the authored-set_null-on-master_detail parse rejection plus the ruled idempotent-materialization conditional in the .overwrite(). Zero currency surface moved: currencyConfig shape, currency-fraction-digits.ts and the analytics chain are untouched, and no v18 boundary signal exists (this hold's Restart-when: legs all still read false). Posting per the opportunistic-restart mechanism so the hold owner can judge; from this seat's reading the restart does not fire — no action taken on the hold's state. One forward pointer worth having on this card: the merged PR's ADR draft records the principle "never materialize a default the schema itself would refuse as authored", which will constrain how this card's future currency precision/fractionDigits shapes declare their defaults (#11423 is the near-term instance, now queued).


    Generated by Claude Code

  5. os-warren commented on Aug 24, 2026

    @os-warren
    Collaborator

    H17 trigger-file notice (spec seat, session_01Rxnd8cyFnoU8V5y21PaTsy, merge-time per the #11423 claim's declaration): this hold's declared trigger file packages/spec/src/data/field.zod.ts was touched by PR #11766 (Fixes #11423, merged 21:55Z) — and unlike the three prior comment-only touches on record here, this one moves currency surface: CurrencyConfigSchema's .overwrite() gains a one-conditional guard so the precision default 2 is no longer materialized onto a bare fixed config whose currency's ISO 4217/CLDR fraction digits contradict it (JPY/KRW/KWD class) — parse output omits the key and parse(parse(x)) holds. The superRefine rejection of an AUTHORED contradictory precision is unchanged; currency-fraction-digits.ts and the analytics chain are untouched.

    Restart determination: this hold's Restart-when: legs (v18 boundary signals / maintainer re-schedule) all still read false — the hold stays parked, no state change made. Forward note for whoever prices this card at v18: the merged guard is the second instance of the recorded principle "never materialize a default the schema itself would refuse as authored" (with #11406's master_detail conditional), and any future precision/fractionDigits default this card's M1–M3 shapes declare must be designed under it from the start.


    Generated by Claude Code

  6. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    H17 trigger-file notification (spec seat R5, session_01K93P8PbH7kVjsAXkqzH1zY, 2026-08-25): this wave's dispatch for #11566 (maxLength shape + applicability tightening, ruled 2026-08-24) touches packages/spec/src/data/field.zod.ts, a declared Restart-touch trigger file of this hold. Notification only, per the on-hold trigger-file protocol — no rider work from this hold is taken into that dispatch, and the hold stands unchanged (v18 Restart-when unaffected).


    Generated by Claude Code

  7. os-litant commented on Aug 25, 2026

    @os-litant
    Collaborator

    H17 trigger-file notification (spec seat R6, session_01NDGG54XF5gbTLdQzCtnaVV, 2026-08-25): this wave's dispatch for #11949 (+#11950 family-folded — minLength shape/applicability tightening per the maintainer's ruling B of 2026-08-25, plus the deferred maxLength ledger registration) touches packages/spec/src/data/field.zod.ts, a declared Restart-touch trigger file of this hold. Notification only, per the on-hold trigger-file protocol and this card's own precedent (the R5 #11566 dispatch notice above): no rider work from this hold is taken into the dispatch — the touch is the bounded-string minLength constraint only, zero currency surface — and the hold stands unchanged (all v18 Restart-when: legs still read false).


    Generated by Claude Code

  8. os-warren commented on Aug 31, 2026

    @os-warren
    Collaborator

    H17 trigger-file notification (domain:spec seat, session_01PBjwYLS6BciTQW3c9xQiD2, 2026-08-31): this wave's dispatch for the objectui#6140 + objectui#6153 spec half (declare rows?: number on markdown/html field metadata and description?: string on select options — maintainer ruling A of 2026-08-25) touches packages/spec/src/data/field.zod.ts, a declared Restart-touch trigger file of this hold. Notification only, per this card's own R5/R6 precedent: no rider work from this hold is taken into the dispatch — the touch is field-metadata key declaration, zero currency surface — and the hold stands unchanged (the v18 Restart-when: legs still read false).


    Generated by Claude Code

  9. zhuangjianguo commented on Sep 3, 2026

    @zhuangjianguo
    Collaborator

    H17 trigger-file notification (domain:spec seat, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T18:15Z): this round's dispatch for #13916 (rewrite the false sentence "the canonical spelling depends_on already exists at the field level" in the option-description docblock and its two echoes in the sibling test file — fact layer only, per triage 5486900363) touches packages/spec/src/data/field.zod.ts, a declared Restart-touch trigger file of this hold. Notification only, per this card's R5/R6 precedent: no rider work from this hold is taken into the dispatch — the touch is comment text, zero currency surface — and the hold stands unchanged. The Restart-when: legs re-read on origin/main at 18:14Z all still read false: no refs/*v18* on origin, packages/spec at 17.2.0, no .changeset/pre.json, no content/docs/releases/v18.mdx.


    Generated by Claude Code

  10. claude commented on Sep 4, 2026

    @claude
    Contributor

    H17 trigger-file notification (domain:spec seat, session session_0174WZTU6XcFcS7g2kykC53i, 2026-09-04T00:43Z): this round's dispatch for #14168 (maintainer ruling A of 2026-09-02 — FieldSchema gains a valueDomain slot whose vocabulary is exactly SpecifierValueDomainSchema's three members, shared by reference) touches packages/spec/src/data/field.zod.ts, a declared Restart-touch trigger file of this hold. Notification only, per this card's R5/R6 precedent: no rider work from this hold is taken into the dispatch. Honest characterisation of the touch: a constraint slot on string-storing fields whose vocabulary includes iso_4217_currency as a value domain (membership of a currency CODE) — currencyConfig, the currency field type, currency-fraction-digits.ts and the analytics chain are untouched, and no exchange-rate or home-currency surface moves. The Restart-when: legs re-read on origin/main 97a22639 at 2026-09-04T00:43Z all still read false: no refs/*v18* on origin, packages/spec at 17.2.0, no .changeset/pre.json, no content/docs/releases/v18.mdx. The hold stands unchanged.


    Generated by Claude Code

  11. claude commented on Sep 4, 2026

    @claude
    Contributor

    H17 landing notification (domain:spec seat, session session_0174WZTU6XcFcS7g2kykC53i, 2026-09-04T04:09Z): the dispatch notified at 00:43Z (comment 5534029200) has landed — PR #15133 merged at 04:05:20Z, merge commit 1d7e76a6, touching packages/spec/src/data/field.zod.ts, a declared Restart-touch trigger file of this hold. Landed shape, characterised honestly: FieldSchema.valueDomain, a write-time membership constraint on text fields whose vocabulary includes iso_4217_currency (membership of a currency CODE); currencyConfig, the currency field type, currency-fraction-digits.ts and the analytics chain are untouched, and no exchange-rate or home-currency surface moves. Notification only: no rider work from this hold was taken. The Restart-when: legs re-read on origin/main 5c584231a at 04:06Z all still read false: no refs/*v18* on origin, packages/spec at 17.2.0, no .changeset/pre.json, no content/docs/releases/v18.mdx. The hold stands unchanged.


    Generated by Claude Code

  12. 3 remaining items

  13. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1 · 2026-10-07T15:21Z
    Session: session_01RPo7FUd6bSnAfkWMAKi848
    Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee from this act)
    Branch: claude/issue-8345-fx-conversion-design-round
    Worktree: objectstack-issue-8345
    Domain: domain:spec
    Seat: domain:spec#3 (seat post #18883)
    File surface: ⛔ none written. Design round only, per triage's release 6039034449: the dev measures and reports; the branch stays empty at its base and no PR is opened (the shape of #22046's measurement round). Read and measured on origin/main 3d9188502e: packages/spec/src/data/field.zod.ts (currencyConfig), packages/spec/src/data/currency-fraction-digits.ts, the Localization settings manifest (service-settings, iso_4217_currency), packages/services/service-analytics/src/analytics-service.ts (the ADR-0053 currency-labelling chain), the formula / summary roll-up readers of a currency field, and the stored corpus (examples/**, packages/platform-objects/**). Any probe is a local scratch file, deleted after the run. Stop on breach and explain in the report.
    Container & model: M, mode:subagent, model: opus (--tier: no path-derived mandate; the tier is the PM's per-card call, default tier for a design round with judgment)
    Clause-②: no
    Responsibility: n/a — not a defect card
    Thread-read: 6039034449
    Serial constraints cleared: none. The round writes no file. No open PR touches field.zod.ts or currency-fraction-digits.ts (13 open PRs' file lists read at this stamp); analytics-service.ts last moved in c565813 (#21995, today), which the dev reads as the base.

    What the round delivers (triage 6039034449): a measured design note in the os-dev-report, then this seat writes the needs-user-decision analysis on this card (维护者速读, the four-facet block, Prior rulings read:) for the director to present. ⛔ No production change.

  14. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
    "issue": 8345,
    "status": "needs_decision",
    "branch": "claude/issue-8345-fx-conversion-design-round",
    "pr": null,
    "session": "session_01RPo7FUd6bSnAfkWMAKi848 — the dispatching PM's id (mode:subagent); this run's identity is the branch",
    "premise_still_valid": true,
    "summary": "DESIGN ROUND, measured note. Base: objectstack origin/main 3d91885, the claim's base. Main moved during the round to b04a529 (#22086), but that commit touches only .github/workflows/ci.yml and lint.yml, so none of the measured files changed. objectui read at its main 9990f9e; the four currency reader files compared with the .objectui-sha pin a58626c8 (only SummaryField.tsx differs, with 0 currency lines at both). Cloud is not in this container: not measured. Branch claude/issue-8345-fx-conversion-design-round pushed EMPTY at 3d91885. No PR, no changeset, no production edit. Two scratch probe files were run and deleted, and the tree is clean.\n\n0. PREMISE CORRECTIONS. The need stands, and it is LARGER than the body says. Four of the body's "what the platform has today" facts are stale.\n- "currencyMode: 'dynamic' (per-record currency)" is false. The currencyMode describe in field.zod.ts (since 9e7824a, #20179 for #20126) says dynamic means "the field has no currency of its own — amounts display in the tenant default currency (the localization.currency setting ...) ... Neither mode is a per-record choice: the value is a bare number either way." ADR-0104 D1 ("Reality wins") makes currency a scalar number. CurrencyValueSchema is deprecated, and "Removal rides the next spec major", which is v18. No key, column or value anywhere carries a per-record currency. So the platform has no record currency at all, and the gap is the record currency plus rates plus home conversion.\n- currencyConfig.precision was removed in spec 17.5.0 (#19992, ADR-0049), with a tombstone and the ADR-0087 conversion currency-config-precision-removed. Parse output is exactly { currencyMode, defaultCurrency }.\n- The "ISO 4217 fraction-digit consistency validation at publish time" left with precision. CURRENCY_FRACTION_DIGITS survives only as the KEY SET of the iso_4217_currency value domain. Its digit VALUES have zero readers today. A conversion would be their first reader, rounding the home amount to the home currency's minor unit.\n- The "analytics currency labeling chain (ADR-0053)" exists: analytics-service.ts queryDataset goes measure currency, then the source field's FIXED currency, then ctx.currency. But the middle step is relayed only under currencyMode 'fixed' (#20091, plugin.ts sourceFieldMeta). ADR-0053 is the date/datetime record and never names currency (#20179 rewrote those citations).\n- H5: c565813 (#22021, today) adds fields[].aggregate. Its six currency lines are context only, and the chain is byte-unchanged.\n\n1. H1, zero implementation: HOLDS. git grep -i -E "exchange.?rate|fx_rate|currency_conversion|convertCurrency" over packages/ (tests excluded) prints 0 lines (exit 1). The control, currencyConfig in field.zod.ts, gives 10. I widened the radius to the whole repo and to objectui packages/apps, and added fx_rate/fxRate, convert_currency, home/base/corporate/functional/reporting currency, conversion.?rate and forex. Implementation hits: 0 in objectstack and 0 in objectui. The non-implementation hits are only these: index.mdx:121 (H3); the seed-data.mdx ExchangeRateCache example (a user-object illustration); docs/NEXT_STEP.md task 12.5 (planning); the view.zod.ts ColumnSummaryConfig docblock example amount_in_base_currency (plus views.mdx:191 and spec tests); and aggregation-policy.ts "conversion rate" (a percent field, unrelated). The grep CANNOT see a rate concept stored as tenant data (sys_metadata or app objects in deployments), the cloud repo, or app-authored hooks. None of those is measurable here.\n\n2. TODAY'S SURFACE AND ITS READERS\n- Spec: CurrencyConfigSchema (strict) is { currencyMode: dynamic|fixed, default dynamic; defaultCurrency: string(3), default 'CNY' }. currencyConfig is optional on every field type (FieldSchema is flat). The field value is a bare number. The SQL column is NUMERIC(65,30)/DECIMAL(65,30) (float on SQLite), with no companion column.\n- Tenant currency: localization.currency (service-settings localization.manifest.ts) has tenant scope, NO platform default, and valueDomain iso_4217_currency. core resolve-authz-context.ts resolves it onto ExecutionContext.currency. It is mutable and keeps no history.\n- Code-valued text fields: valueDomain 'iso_4217_currency' (#14168) enforces write-time membership of a currency CODE. It is the natural carrier for a per-record code.\n- Label readers: the analytics relay and chain (fixed only); objectui resolveFieldCurrency (field.currency, then the fixed defaultCurrency, then field.defaultCurrency, then the tenant); and the currency template formatter in packages/formula template-engine.ts. That formatter defaults to USD when no code is given, falls back to USD when Intl refuses the code, and reads neither the field nor the tenant.\n- driver-sql is not a reader. builtin-column-collision.ts:89 classifies currencyConfig as 'storage' ("currency mode can change what is physically stored"), but no driver line reads currencyConfig (0 hits in packages/drivers outside that table).\n\n3. H2, PER-READER CROSS-CURRENCY VERDICT. Measured by probe, not by reading: two scratch vitest files over a real ObjectQL engine (SqliteWasmDriver for analytics, the summary-rollup.test.ts in-memory driver for roll-ups), run under os-verify-lock and then deleted. Fixture: object fx_invoice { txn_currency text, amount currency (bare, so dynamic), amt_usd fixed USD, amt_eur fixed EUR }. Rows: i1 {USD, amount 100, amt_usd 100, amt_eur 10} and i2 {EUR, amount 100, amt_usd 0, amt_eur 90}.\n- (1) analytics queryDataset, engine path, tenant USD (PROBE-A):\n - m_amount_sum 200 "USD" and m_amount_avg 100 "USD". This is the body's workaround model: a USD row and an EUR row summed silently and labelled with the tenant currency.\n - m_usd 100 "USD" and m_eur 100 "EUR". The fixed relay is correct.\n - derived sum(m_usd, m_eur) = 200 with NO currency, and derived difference = 0 with no currency. That is silent cross-currency arithmetic, and the label is dropped.\n - measure currency 'USD' over the fixed-EUR field = 100 "USD". That is a relabel, and it is the pinned contract: currency-mode-relay.test.ts says "an explicit measure currency wins ... over a fixed one".\n- (2) The same rows with tenant EUR (PROBE-B): every dynamic column flips to "EUR" with identical numbers (m_amount_sum 200 "EUR"). Changing localization.currency relabels all stored history.\n- (3) Grouped by txn_currency (PROBE-C): the per-group values are correct (EUR 100, USD 100), but the single column currency labels both groups "USD". The wire carries one currency per column, so even a correct per-currency breakdown is mislabelled.\n- (4) ObjectQL summary roll-up (summary-aggregate.ts; stored, recomputed at child write) (PROBE-R): children USD 100 and JPY 15000 give billed_total 15100 and billed_avg 7550, silently. The parent summary field carries no currency at all: objectui SummaryField renders a plain number.\n- (5) CEL formula (PROBE-F): record.amt_usd + record.amt_eur evaluates to 150. validateExpression('value', ..., fieldTypes currency) returns ok with 0 errors and 0 warnings; currency is typed dyn by design (validate.ts SPEC_TYPE_TO_CEL). A formula is VIRTUAL: aggregate-field-type-compatibility.ts refuses aggregates over it, and summary roll-ups read stored columns (the app-crm opportunity-line-item.object.ts comment). So a formula result can never feed a total.\n- (6) objectui grid footer (useColumnSummary.ts, client-side over loaded rows): one target field, labelled from that field's own metadata (fieldMetadata[targetField]). Same verdict as (1). Read, not probed.\n- (7) objectui ObjectMetricWidget: one field, and an authored widget currency wins over the field. Same relabel shape as the measure override. Read, not probed.\n- (8) Inline grid amountField running total (component.zod.ts): one child column, so a single currency. Read, not probed.\n- (9) Cube face AnalyticsService.query: no column currency at all (pinned in currency-mode-relay.test.ts). Read, not probed.\n\nVERDICT: the body's mechanism is FALSIFIED. No reader ever sees two currencies inside one field, because no field can hold two. Every reader assumes one field = one currency, and gets it. Cross-currency numbers ARE produced silently today in three ways:\n- (a) derived measures sum/difference across differently-fixed fields;\n- (b) CEL arithmetic across such fields;\n- (c) EVERY aggregate reader, once an author models a per-record code in a sibling text field. That is the body's own workaround, and it is invisible to the platform, so nothing can refuse it.\nThere are also two relabel hazards:\n- (d) the tenant setting is read at read time and keeps no history;\n- (e) an explicit measure or widget currency overrides the field's own.\nCorpus instance of (e): in app-crm opportunity.dataset.ts, total_amount and avg_amount declare currency 'USD' over opportunity.amount, which is a dynamic field. On a CNY tenant the record faces show yuan while the dashboard shows dollars for the same sum.\n\n4. H3: HOLDS, with a second site. content/docs/protocol/objectql/index.mdx:121 says "Money with exchange rates | {amount: 1000, currency: 'USD'}", which over-claims twice: there are no rates, and the value is a bare number, not an object. content/docs/protocol/objectql/types.mdx:30 says "Store amount + currency code": the same false claim, at the top of the page whose own currency section (lines 288-312) says the opposite. The field.zod.ts:471 docblock "supporting multi-currency" is true only per field.\n\n5. H4: corpus counts at 3d91885.\n- examples/: 17 currency fields (app-crm 4, app-multi-package 1, app-showcase 12).\n - 3 use currencyMode 'fixed', all USD (showcase account.annual_revenue, field-zoo.f_currency, semantic-zoo.budget).\n - 14 have no currencyConfig, so they are dynamic by default. 0 declare 'dynamic' explicitly.\n - Per-record currency-code fields: 0. Rate objects: 0.\n - Explicit measure currency: 2 (app-crm, USD, over a dynamic field).\n- packages/platform-objects/: 0 currency fields. packages/apps/**: 0.\n- In-tree author pull for conversion is zero. The pull is the named overseas port/terminal customer only.\n- Showcase invoices span the amer/emea/apac regions (including 华宁科技) in one currency. The corpus models one currency per tenant throughout.\n\n6. H6: the principle is recorded as a code docblock, not an ADR. It sits in the FieldSchema .overwrite() block of field.zod.ts (#9689, maintainer ruling 2026-08-24 「四维分析一致的,接手你的建议。」): "NEVER materialize a default the schema itself would refuse as authored". git grep over docs/adr finds 0 hits; the ADR draft named in comment 5397208210 never landed as an ADR. Its currency instance (the #11423 precision default) left with the key in #19992. The one surviving materialized currency default is defaultCurrency 'CNY'. It is baked into every parsed currencyConfig. It is harmless under dynamic, where it is not read, but it IS read under fixed: currencyConfig { currencyMode: 'fixed' } with no code parses to a yuan field on the declared-stack path. That does not breach the principle as worded (an authored 'CNY' is accepted), but it belongs to the same family. Design rule for any v18 key: no hardcoded rate, home currency or currency-code default; a missing rate is loud (never Odoo's 1:1 fallback); and fixed should require its code.\n\n7. COMPARABLE MODELS (web search, 2026-10-07; learn.microsoft.com is egress-blocked here, so the Dataverse facts come from search excerpts)\n- Salesforce: every record carries a CurrencyIsoCode, and the org has one corporate currency. ACM dated rates apply to opportunities and their related objects only. A roll-up between an ACM object and a non-ACM object is refused ("Roll-up summary fields can only summarize values of currency fields if both objects convert values or both objects do not"). Formula fields cannot reference dated rates. Reports convert at read time.\n- Dynamics 365 / Dataverse: every record has a transaction currency and an exchangerate snapshot. Every money field X gets a system-calculated X_base = X / exchangerate, computed at WRITE. It is recalculated when the money field or the record state is updated, not when a rate changes. The base currency is fixed when the environment is created.\n- Odoo: res.currency.rate holds dated rates per company. Monetary fields name a currency_field on the same record. Journal lines store amount_currency plus a balance in company currency, converted at the posting date. A missing rate silently converts 1:1.\n- Common shape: the record currency lives on the record; rates are dated against one home currency; the home amount is STORED at write. Read-time conversion is used only for presentation.\n\n8. SHAPES. Each shape answers the triage's four questions. A fifth question comes first: Q0, where does a record's currency live? Today it lives nowhere.\n\nA — guard rail only. Spec: no new capability.\n- As written ("refuse a cross-currency aggregate over a dynamic field"), A has nothing to grip: a dynamic field is one currency by contract.\n- What A can guard today: (a) derived sum/difference/ratio across measures whose resolved currencies differ; (e) a measure or widget currency that contradicts the field's resolved currency; (d) changes to localization.currency once amounts exist; plus the H3 docs.\n- It cannot guard (c): the workaround's text code is invisible.\n- A' = A plus a declared record currency. currencyMode 'record' plus a currencyField key naming a text field with valueDomain iso_4217_currency; the value stays a bare number per ADR-0104 D1. A raw sum/avg/min/max over a record-mode field is then refused at build unless it is grouped by the code, and the analytics wire gains a per-row currency for that grouping.\n- Q0 record currency: none (A) or a sibling code field (A'). Q1 rates: none. Q2 home: n/a. Q3 time: n/a. Q4: refuse, or group per currency.\n- Migration: the derived guard reds 0 corpus items (the 2 derived measures are both count ratios). The measure-override guard reds app-crm's 2 measures (fix: delete the key).\n- AI errors: a small, loud surface. But the customer still cannot consolidate, and AI authors keep reinventing the conversion by hand.\n\nB — dated rate object plus a CEL convertCurrency (the body's M1+M2). Spec: sys_exchange_rate plus a stdlib function.\n- Runtime: the stdlib's own header forbids it as worded: "Nothing registered here may be handed a resolver, a lazy getter or any other callback into the host". Purity is what keeps objectstack build byte-stable; os.lookup was retired for the same reason (eval-context-no-query-api.test.ts).\n- So rates would have to be PINNED before evaluation, per (from, to, date) tuple, by static analysis at every call site (the relationship-traversal.ts pattern). They would also have to be refused in build-time scopes, because seeds evaluate CEL at build.\n- Worse for the pull: a formula is virtual, aggregates refuse it, and roll-ups read stored columns. B's converted amount can never be totalled, so B does not deliver consolidated reporting at all.\n- The stored-with-expression workaround is computed client-side ("The server stores the client-sent value as-is"). That is not acceptable for billing.\n- Salesforce made the same call: formulas cannot read dated rates.\n- Q0: needs A' anyway. Q1: dated rows. Q2: localization.currency. Q3: read time (every evaluation). Q4: unchanged (still silent).\n- AI errors: high. The function's result looks aggregatable and is refused downstream, and a date-less call drifts on every read.\n\nC — rates plus an opt-in STORED home-currency projection, computed at write time as Dataverse and Odoo do.\n- Spec:\n - the A' record currency;\n - a sys_exchange_rate platform object { currency (ISO 4217 domain), rate against the home currency, effective_date, source }, unique per org/currency/date, with cross rates derived through the home currency (no N×N matrix);\n - a projection declaration on the amount field that names its STORED home field and its rate-date field (for example invoice_date). The declaration is explicit; there is no injected shadow column (route rule 2).\n- Runtime:\n - the engine computes the home amount at the latest rate effective on the rate date and rounds it to the home currency's minor unit (the first reader of the CURRENCY_FRACTION_DIGITS values);\n - it stores the rate it used beside the result;\n - it REFUSES the write when no rate is effective;\n - rate corrections re-run through one explicit recompute operation that shares the engine function (the summary-backfill precedent, os migrate summary-nulls).\n- The home field is a currency field fixed to the home currency. So every existing aggregate reader — roll-up, queryDataset, the grid footer (ColumnSummaryConfig.field already documents the amount_in_base_currency pattern), the metric widget — consolidates with ZERO reader change.\n- Q0: the sibling code field. Q1: manual entry plus import in v18, with provider feeds left to app flows. Q2: localization.currency, locked once a rate or projection exists (this also closes hazard (d)). Q3: write time, with a rate snapshot. Q4: raw record-mode aggregates are refused (A'), per-currency grouping is allowed, and consolidation goes through the home field.\n- Migration: additive. The 17 corpus fields and the stored rows are untouched; v18 only adds keys.\n- AI errors: the lowest of the three. Every new key is checked at build: the code field must be text with iso_4217_currency, the rate date must be a date field, and the home target must be a stored currency field fixed to the home currency. A missing rate is a located write refusal. A refused raw sum names its fix ("aggregate amount_home, or group by currency_code").\n\n9. RECOMMENDATION: C without the CEL function, called C' here. A' is its foundation, plus rates, plus the stored write-time projection. Drop M2 (convertCurrency) from v18. Reasons by axis:\n- Real business need: the named customer needs billing in the customer's own currency (the record currency), reconciliation at dated rates, and consolidated reporting in one currency. C' delivers all three. A delivers none. B delivers per-record conversion for display, but not consolidation. In-tree pull is zero, so nothing beyond those three is earned: no provider feeds, no per-user display currency, no revaluation or unrealized gains, no N×N rate matrix.\n- Long-term soundness: a stored write-time projection matches the platform's stored-aggregate architecture (roll-ups materialized at write, analytics over stored columns, formulas virtual), and it keeps ADR-0104 D1. Read-time conversion would fork every aggregate strategy per driver, and historical reports would move whenever a rate is corrected. B fights the stdlib purity invariant.\n- AI safety: declaration is enforcement end to end. Record mode, rate date and home target are checked at build; a missing rate is refused at write; raw cross-currency aggregates are refused with the prescription. There is no tolerant fallback anywhere: no 1:1 rate, no nearest-future rate, no CNY default.\n- Startup focus: C' is the largest surface, so it is cut to one enum member, two keys, one platform object and one recompute operation. The refusals live in existing doors (the schema refinement, the analytics compile leg, existing lint rules), so no new check:* gate is proposed; the maintainer has named none. The guards on today's surface (derived across currencies, measure override, localization lock) and the H3 docs fix ride v18 whichever shape is chosen. The docs fix can land now as a docs-only change.\n- Where the axes conflict: startup focus alone favours A, the smallest shape. A is rejected because it leaves the ruled pull (conversion in v18) undelivered, and it cannot guard the workaround it would push authors into.\n\nPrior rulings read (as cited in code and docs at the base; the ruling comments themselves were not opened):\n- the #9689 ruling of 2026-08-24 (the materialize principle);\n- #19910 ruling 乙 (a currency's decimal places are the currency's);\n- #19992 (precision removed, ADR-0049);\n- #20091, #20126 and #20179 (dynamic = tenant currency, relay fixed-only, the ADR-0053 citation corrected);\n- #14168 (valueDomain iso_4217_currency);\n- ADR-0104 D1 (currency is a scalar number);\n- decision batch #127 (formula aggregates refused);\n- the card body's v18 schedule (2026-08-13) and triage release 6039034449.",
    "tests": "Design round: no test of record. Probes were scratch files, run and then deleted.\nH1: git grep -n -i -E "exchange.?rate|fx_rate|currency_conversion|convertCurrency" -- packages/ (tests excluded) gave EXIT=1 and 0 lines. Control: git grep -c currencyConfig field.zod.ts gave 10. Widened grep (repo plus objectui packages/apps) gave 0 implementation files in each.\nBuild: OS_VERIFY_LOCK_SLOT=dev-8345-build OS_SKIP_DTS=1 os-verify-lock.sh -c "pnpm --workspace-concurrency=2 --filter '@objectstack/service-analytics^...' build" printed "VERDICT command-exit 0 · held the lock 84s". 14 packages built, spec/objectql/formula/driver-sqlite-wasm among them.\nProbes: os-verify-lock.sh -c "pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 src/tests/zz-probe-8345-fx.test.ts; pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/zz-probe-8345-fx.test.ts". Each file printed "Test Files 1 passed" (3/3 and 2/2). The lock printed "batch-last-exit 0" (parts joined by a semicolon, so each part was read from its own summary line).\nPROBE-A rows {"m_amount_sum":200,"m_amount_avg":100,"m_usd":100,"m_eur":100,"m_eur_as_usd":100,"m_mixed_sum":200,"m_mixed_diff":0}, currency {"m_amount_sum":"USD","m_amount_avg":"USD","m_usd":"USD","m_eur":"EUR","m_eur_as_usd":"USD","m_mixed_sum":null,"m_mixed_diff":null}.\nPROBE-B: same rows under tenant EUR, currency {"m_amount_sum":"EUR","m_amount_avg":"EUR","m_usd":"USD","m_eur":"EUR","m_eur_as_usd":"USD","m_mixed_sum":null,"m_mixed_diff":null}.\nPROBE-C rows [{"txn_currency":"EUR","m_amount_sum":100},{"txn_currency":"USD","m_amount_sum":100}], currency {"m_amount_sum":"USD"}.\nPROBE-R parent {"billed_total":15100,"eur_total":100,"billed_avg":7550}.\nPROBE-F eval {"ok":true,"value":150}, validate {"ok":true,"errors":0,"warnings":0}.\nAfter deletion: git status --porcelain printed 0 lines.\nAblation and reverse verification: not applicable (no change).",
    "mcp_calls": "0 — no MCP GitHub calls (card and comments read via gh api REST GET, two reads)",
    "api_writes": "1 — POST /repos//issues/8345/comments (this os-dev-report, via scripts/pm/post-stamped.mjs, fleet-write relay)",
    "files_changed": "none",
    "deviations": "The analytics dependency closure was built with OS_SKIP_DTS=1. The probes were runtime-only and no .d.ts was read.\nobjectui was read at its main 9990f9e, not at the pin a58626c8. I diffed the four reader files: only SummaryField.tsx differs, and it has 0 currency lines at both.\nlearn.microsoft.com is egress-blocked, so the Dataverse facts come from web-search excerpts only.\nShape C's projection key names (currencyMode 'record', currencyField, the projection declaration) are illustrative spellings, not proposed final names.\nThe dispatch's H2 premise ("dynamic = per-record") was falsified, so Section 3's shape A ("refuse over a dynamic field") was re-cast as A/A' rather than analysed as written.",
    "open_questions": [
    {
    "question": "Q5 (headline): which contract shape ships in v18? The four axes, per shape.",
    "options": [
    "A, guard rail only. Need: serves none of the customer's three needs (record currency, dated reconciliation, consolidation). Soundness: no debt, but nothing is built. AI safety: loud and small, but it cannot see the workaround it pushes authors into. Startup: smallest; 0 corpus reds except app-crm's 2 measure overrides.",
    "A', A plus a declared record currency. Need: billing in the record currency, but no consolidation. Soundness: correct foundation (ADR-0104 D1 kept). AI safety: raw cross-currency sums refused at build. Startup: one enum member, one key.",
    "B, dated rates plus a CEL convertCurrency. Need: per-record display conversion only, because formulas are virtual and aggregates refuse them, so nothing totals. Soundness: breaks the stdlib purity invariant unless rates are pinned per call site. AI safety: high; the result looks aggregatable and a date-less call drifts. Startup: rate object plus pinning machinery.",
    "C', A' plus sys_exchange_rate plus a stored, write-time home projection, with no CEL function. Need: all three. Soundness: matches the stored-aggregate architecture; zero aggregate-reader change. AI safety: every key checked at build and a missing rate refused at write. Startup: largest, cut to one enum member, two keys, one platform object and one recompute operation; no new check:* gate.",
    "Full C, C' plus convertCurrency. Adds B's costs for no pull named in-tree."
    ],
    "recommendation": "C', because it is the only shape that delivers the ruled v18 pull (consolidated reporting needs a STORED home amount, and the probe shows every aggregate reader already sums one field in one currency) while every new key is enforced at build or write. Startup focus alone would pick A; A is rejected because it leaves the scheduled capability undelivered."
    },
    {
    "question": "Q0 (prerequisite, not in the triage's list): where does a record's currency live? Today it lives nowhere. A currency value is a bare number (ADR-0104 D1), and 'dynamic' means the tenant currency.",
    "options": [
    "A: a sibling code field. currencyMode 'record' plus currencyField naming a text field with valueDomain iso_4217_currency (the Odoo currency_field model). The value stays scalar, and several amount fields can share one code field.",
    "B: a platform-injected per-object currency column switched on by an object flag (Salesforce CurrencyIsoCode). Implicit composition.",
    "C: a value object {amount, currency}. This needs an ADR superseding ADR-0104 D1, plus a migration of every stored currency column, and it revives CurrencyValueSchema, which v18 is due to remove.",
    "D: none (status quo). One fixed field per currency, unusable for an open currency set."
    ],
    "recommendation": "A. It is explicit composition (route rule 2), it reuses the landed iso_4217_currency value domain and the scalar value contract, and it gives the guard rails something to grip."
    },
    {
    "question": "Q1: rate source and storage, and what a missing rate does.",
    "options": [
    "A: a sys_exchange_rate platform object { currency, rate against the home currency, effective_date, source }, unique per org/currency/date, with cross rates derived through the home currency. v18 sources are manual entry and import only. A missing rate REFUSES the converting write, with the located gap named.",
    "B: A plus a built-in provider feed (ECB or similar). This is a network default, and no pull names one.",
    "C: an N×N pair matrix. More rows, and an inconsistency risk between pairs.",
    "Missing-rate alternatives: a null home amount plus a flag (sum ignores null, so totals undercount silently), or a 1:1 fallback (Odoo's behaviour; silent wrong numbers)."
    ],
    "recommendation": "A with refusal. Provider feeds can be an app flow writing sys_exchange_rate rows, and a silent fallback is exactly what the four-axis AI-safety rule refuses."
    },
    {
    "question": "Q2: what is the home currency?",
    "options": [
    "A: localization.currency (exists; tenant scope; no default; ISO 4217 domain), made immutable once a rate row or a projection exists.",
    "B: a separate finance home-currency setting. This gives two currency settings with different meanings, a confusion surface for authors.",
    "C: localization.currency staying mutable, with an explicit re-conversion operation on change (Dataverse long refused this; base currency is fixed at environment creation)."
    ],
    "recommendation": "A. One setting, one meaning. The lock also closes today's relabel hazard (probe B: a setting change relabels all stored dynamic amounts), and the setting already carries no hardcoded default, as the materialize principle requires."
    },
    {
    "question": "Q3: when is the conversion done, at write or at read?",
    "options": [
    "A: at write. The engine stores the home amount and the rate used, rounds to the home currency's minor unit, and uses an explicit recompute operation after a rate correction (the summary-backfill precedent). This is the Dataverse/Odoo model.",
    "B: at read. Every aggregate strategy (native SQL, ObjectQL aggregate, in-memory, the client footer, the metric widget, roll-ups) joins rates at the row date. Historical reports move when a rate is corrected.",
    "C: both."
    ],
    "recommendation": "A. Roll-ups are already materialized at write, analytics aggregates stored columns, and formulas are virtual, so only a stored home column composes with the existing readers."
    },
    {
    "question": "Q4: what do aggregates do across currencies in reports?",
    "options": [
    "A: over a record-mode field, a raw sum/avg/min/max is refused at build unless grouped by its code field. Grouped results carry a per-row currency on the wire (today one currency per column mislabels groups, probe C). Consolidation goes through the home field.",
    "B: implicitly group or label per currency, without refusing.",
    "C: allow silently (today's behaviour for the workaround model, probes A and R)."
    ],
    "recommendation": "A. It is a loud refusal with the prescription in the message, rather than an implicit regrouping the author never asked for."
    },
    {
    "question": "Q6: should the guards on today's surface ride v18 whatever shape is chosen?",
    "options": [
    "A: yes. Refuse a derived sum/difference/ratio across measures whose resolved currencies differ (probe A: 200 with no label). Refuse a measure or widget currency that contradicts a fixed field's currency. Refuse, or ask for an acknowledgement on, a hard-coded measure currency over a dynamic field. The last one reds app-crm opportunity.dataset.ts total_amount/avg_amount ('USD' over the dynamic amount); the fix is to delete the key.",
    "B: leave as is. The explicit measure currency stays the pinned contract (currency-mode-relay.test.ts)."
    ],
    "recommendation": "A. Each guard turns a silent wrong number or label into a build refusal, inside existing doors (the analytics compile leg and existing dataset lint), with no new check:* gate."
    },
    {
    "question": "Q7: currencyConfig { currencyMode: 'fixed' } with no code parses to a yuan field (defaultCurrency default 'CNY'). Should v18 change that?",
    "options": [
    "A: in v18 (a major), require defaultCurrency under fixed and drop the 'CNY' default. An ADR-0087 conversion strips the baked 'CNY' from dynamic rows, where it is unread, and keeps it on fixed rows, where it was the effective value.",
    "B: keep the default."
    ],
    "recommendation": "A. It is the materialize-principle family (a placeholder read as authored), and the v18 major is the window for it. Startup-stage rule: retire immediately, with no alias window."
    },
    {
    "question": "Q8: how should the two docs over-claims be fixed? They are index.mdx:121 "Money with exchange rates | {amount, currency}" and types.mdx:30 "Store amount + currency code".",
    "options": [
    "A: a docs-only PR now, because both are false today independent of v18.",
    "B: ride the v18 implementation."
    ],
    "recommendation": "A. A false claim on a published docs page should not wait for a release-scoped design."
    }
    ],
    "out_of_scope_findings": [
    "class: a (same family as this card: currency relabel) · reach: no public door measured. The mechanism is probe A's m_eur_as_usd (an explicit measure currency wins over the field's) plus the plugin relay. · evidence: examples/app-crm/src/datasets/opportunity.dataset.ts:32-33 declares currency 'USD' on total_amount/avg_amount over opportunity.amount, a dynamic field (opportunity.object.ts:40, no currencyConfig). On a tenant with localization.currency CNY, the record faces show yuan and the dashboard shows dollars for the same sum. · Folded into #8345 as Q6, not filed separately. · dedupe words: measure currency override, dynamic currency field, app-crm opportunity dataset USD, currency relabel",
    "carrier: #8345 (Q8; the card's own H3 scope) · content/docs/protocol/objectql/index.mdx:121 and content/docs/protocol/objectql/types.mdx:30 both claim exchange rates or a stored currency code. The value is a bare number (ADR-0104 D1). · noted, not filed",
    "carrier: #8345 (label-reader inventory; any shape that defines a home currency re-plumbs it) · packages/formula/src/template-engine.ts:71-80: the currency formatter defaults to USD when no code is given and falls back to USD on an Intl-refused code. It reads neither the field's nor the tenant's currency. content/docs/data-modeling/formulas.mdx:79 teaches the code-less spelling. Candidate class a; reach not measured. · noted, not filed · dedupe words: template currency formatter USD default, tenant currency template",
    "carrier: #8345 (shape choice) · packages/drivers/driver-sql/src/builtin-column-collision.ts:89 classifies currencyConfig as 'storage' ("currency mode can change what is physically stored"). No driver line reads currencyConfig, so the comment is false today; it would become true only under A'/C' (a code column). · noted, not filed"
    ]
    }

  15. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    Contributor

    Seat verdict: ACCEPT the measurement round (6041378776); the contract shape goes to the maintainer · domain:spec seat 3 (#18883) · session_01RPo7FUd6bSnAfkWMAKi848 · 2026-10-07T15:48Z

    Checked on GitHub and origin/main, not from the report:

    • Round terms met: branch claude/issue-8345-fx-conversion-design-round sits at its base 3d9188502e, no commit and no PR; the report comment is intact (34,323 bytes).
    • The card body's premise "currencyMode: 'dynamic' (per-record currency)" is falsified: packages/spec/src/data/field.zod.ts:489 describes dynamic as the tenant default currency, and "Neither mode is a per-record choice". currencyConfig parses to exactly { currencyMode, defaultCurrency } (:495), with defaultCurrency defaulting to 'CNY' (:490). The need stands and is larger: the platform has no record currency at all.
    • The CEL stdlib header forbids a host callback (packages/formula/src/stdlib.ts:7, "Nothing registered here may be handed a res[olver]"), so a convertCurrency function cannot read rates without pinning machinery.
    • The two docs over-claims are live: content/docs/protocol/objectql/index.mdx:121 ("Money with exchange rates") and types.mdx:30 ("Store amount + currency code").
    • The probe readings (a dynamic sum of a USD row and an EUR row answers 200 "USD"; a summary roll-up of USD 100 + JPY 15000 answers 15100) come from the report's scratch probes, which were deleted; recorded here as the dev's measurement, not re-run by the seat.

    Out of scope, one line each:

    • examples/app-crm opportunity.dataset.ts declares currency: 'USD' over a tenant-currency field: carried by this card (sub-question 5 below), not filed.
    • The two docs over-claims: carried by this card (sub-question 6), not filed.
    • packages/formula/src/template-engine.ts defaults the currency formatter to USD: no reach measured, so not a card; carrier: whichever PR implements the ruled home currency.
    • driver-sql's builtin-column-collision.ts:89 comment ("currency mode can change what is physically stored") is false today: Acceptance-notes material for the implementing PR.

    Release: session_01RPo7FUd6bSnAfkWMAKi848 · why: the measurement round is delivered and the shape is the maintainer's to rule · to: needs-user-decision, unassigned. Whoever implements the ruled shape claims afresh.


    待裁(needs-user-decision):v18 的多币种,用哪种契约形状交付?

    维护者速读

    • 改了什么: 什么都没改。本轮只测量,请你定多币种在 v18 的形状。
    • 测出的关键事实: 平台今天根本没有"记录币种"。金额字段只存一个数字,"动态"模式的意思是"按租户默认币种显示",不是"每条记录自带币种"。所以客户要的"按客户币种开票、按日期汇率对账、统一折成本位币出报表",三件事都要从零建。实测还发现一件事:作者用"旁边放一个币种文本字段"的办法凑多币种时,所有汇总都会把美元和欧元直接相加,标签还写成租户币种。
    • 席位意见: 推荐 C':记录上声明币种字段;平台提供汇率表;在写入时按单据日期的汇率,把本位币金额算好并存下来。v18 不做公式里的换算函数。
    • 你要做的: 回一个字母 A / A' / B / C';下面的子问题不回即按推荐执行。

    一句话问题

    客户的发票用客户自己的币种开,月底要按当日汇率折成本位币汇总对账。平台应该怎么让应用作者声明"这笔钱是什么币种、按哪天的汇率、折成什么"?

    背景

    • 测量轮报告 6041378776(只读;两个临时探针跑在真实 ObjectQL 引擎上,跑完已删)。
    • 你 2026-08-13 定了 v18 要做,分诊 6039034449 释放本卡时写明"形状待裁":汇率来源与存储、本位币、何时换算、跨币种汇总语义。
    • 测量补出第五个问题,而且排在最前:记录的币种存在哪里? 今天哪里都没有。

    Governing text

    • ADR-0104 D1(Reality wins):币种值是一个标量数字。CurrencyValueSchema 已废弃,下一个 spec 大版本(v18)移除。
    • packages/spec/src/data/field.zod.ts:489:dynamic = 租户默认币种,"Neither mode is a per-record choice"。
    • packages/formula/src/stdlib.ts:7:标准库函数不得拿到回调宿主的解析器(为保证 objectstack build 逐字节稳定)。
    • field.zod.ts FieldSchema .overwrite() 文档块中的已裁原则(FieldSchema accepts deleteBehavior: 'set_null' on a master_detail, and the engine silently resolves it to cascade #9689,维护者 2026-08-24):"NEVER materialize a default the schema itself would refuse as authored"。
    • AGENTS.md Prime Directive 10 与 12:声明即强制;错的元数据在发布时响亮拒收,不在消费端宽容。

    前提(各带复核命令与阳性对照)

    1. dynamic 不是逐记录币种。 git grep -n "Neither mode is a per-record choice" origin/main -- packages/spec/src/data/field.zod.ts(对照:同文件 git grep -c "currencyConfig" 应 >0)。
    2. 仓内无任何换算实现。 git grep -n -i -E "exchange.?rate|fx_rate|currency_conversion|convertCurrency" origin/main -- packages/ ':!*.test.ts'(应为空;对照同上)。
    3. CEL 标准库不许回调宿主。 git grep -n "may be handed a res" origin/main -- packages/formula/src/stdlib.ts(对照:同文件 git grep -c "export" 应 >0)。
    4. fixed 不写币种会被当成人民币。 git grep -n "default('CNY')" origin/main -- packages/spec/src/data/field.zod.ts(对照同 1)。
    5. 文档在宣称不存在的能力。 git grep -n "Money with exchange rates" origin/main -- content/docs/protocol/objectql/index.mdx(对照:同文件 git grep -c "currency" 应 >0)。

    选项 × 真实代价

    选项 做什么 客户感受到的后果
    A 只加护栏:不做换算,把今天已能测到的"静默跨币种相加 / 错标币种"改成构建时拒收 客户三件事一件都做不成。作者继续手搓换算,而手搓的写法平台根本看不见,护栏也拦不住
    A' A 加上声明"记录币种":金额字段新增 record 模式,指向一个 ISO 4217 币种文本字段;未按币种分组的原始求和在构建时拒收 能按客户币种开票,汇总不会再被悄悄算错,但还是没法折成本位币统一出报表
    B 汇率表 + 公式里的 convertCurrency 函数(原卡 M1+M2) 记录页上能显示折算值,但汇总不了:公式字段是虚拟的,所有汇总都拒绝它。而且与标准库"不许回调宿主"的不变量冲突
    C' A' + sys_exchange_rate 汇率表 + 写入时折算并存下本位币金额(同时记下所用汇率),缺汇率就拒绝写入;v18 不做公式函数 三件事都做到。现有的汇总、报表、表格合计、指标卡不用改任何读取代码就能出本位币合计。改了汇率要显式跑一次重算

    业务含义直译

    • A:收银台只装报警器,不装换汇柜台。
    • A':每张单子写明币种,但年底对账还得手工换算。
    • B:单子上能看到折算价,但财务汇总表里加不起来。
    • C':像 Dynamics 365 与 Odoo:开单时按当天牌价把本位币金额记进账,报表直接汇总;Salesforce 也是"每条记录带币种 + 公司本位币"。

    os-decision-facets

    • ① 项目长远合理性:C' 与平台"写入时物化、汇总读存储列、公式是虚拟的"这一既有架构同向,且保留 ADR-0104 D1(金额仍是标量)。读时换算会让每种汇总策略按驱动分叉,历史报表随汇率更正而漂移。B 与标准库纯度不变量冲突。A 不欠债,但也什么都没建。
    • ② 实际业务拉动:仓内作者拉动为零(示例里 17 个金额字段全是单币种,无币种字段、无汇率对象);唯一拉动是海外港口客户,要的是记录币种、按日期汇率对账、本位币合并三件。只有 C' 三件都满足;拉动之外的能力一概不做(行情源、个人显示币种、重估、N×N 汇率矩阵)。
    • ③ 防 AI 犯错:C' 每个新键在构建时校验(币种字段必须是 ISO 4217 文本、汇率日期必须是日期字段、本位币目标必须是存储型固定币种字段),缺汇率在写入时响亮拒绝,未分组的跨币种原始求和被拒并给出处方。不设任何宽容兜底:不按 1:1、不取最近汇率、不默认 CNY。B 的结果看着能汇总、实际被下游拒绝,AI 最容易踩。
    • ④ 创业阶段不扩散:C' 面最大,所以压到一个枚举成员、两个键、一个平台对象、一个重算操作;拒收都放在既有的门里,不新增 check:* 门禁。单看④会选 A,但 A 交付不了 v18 已定的能力。

    Prior rulings read: currency,exchange rate,currencyMode,defaultCurrency → ADR-0104 D1;#9689 裁决(2026-08-24,物化原则);#19910 乙(小数位归币种);#19992(precision 按 ADR-0049 移除);#20091 / #20126 / #20179(dynamic = 租户币种,fixed 才中继);#14168(iso_4217_currency 值域);thread: none(本卡线程无裁决,只有 v18 排期与分诊释放)。

    推荐

    C'。 两年后的样子:每条业务记录声明自己的币种,平台持有按日期生效的汇率,写入时把本位币金额与所用汇率一起存档,所有报表在本位币上汇总;对照 Dynamics 365(交易币种 + 写入时算 _base)与 Odoo(res.currency.rate + 过账日折算)。

    • 自检: 只看①选 C';②③④ 是否翻转:否(②③同向;④只把范围压窄,不翻字母)。
    • 子问题,同笔请裁(不回即按推荐执行):
      1. 记录币种放哪: 推荐在同对象上声明一个 ISO 4217 币种文本字段,由金额字段指向它(Odoo 的 currency_field 模式;多个金额可共用一个)。不推荐平台自动注入隐藏列,也不推荐把金额改成 {amount, currency} 对象(那要推翻 ADR-0104 D1)。
      2. 缺汇率: 推荐拒绝该次写入,并点名缺的是哪个币种、哪一天;汇率在 v18 只支持手工录入与导入,行情源由应用自己用流程写入。
      3. 本位币: 推荐沿用现有的 localization.currency,一旦有汇率行或折算声明就锁定不可改(这同时堵住今天"改租户币种就把所有历史金额换了标签"的问题)。
      4. 跨币种汇总: 推荐对 record 模式字段的原始 sum/avg/min/max 在构建时拒收,除非按币种分组;合并汇总一律走本位币字段。
      5. 今天的表面要不要先加护栏(不论选哪个字母): 推荐要。派生度量跨不同币种相加或相减、度量或指标卡写死的币种与字段币种矛盾,都改成构建时拒收。这会让 examples/app-crm 的两个度量变红,修法是删掉它们的 currency: 'USD'。
      6. fixed 不写币种默认人民币: 推荐 v18 起 fixed 必须写币种,去掉 'CNY' 默认值,带 ADR-0087 转换(动态行剥掉、固定行保留)。两处文档虚报现在就作纯文档修正,不等 v18。
    • 回退: A'(先有记录币种与拒收护栏,合并报表留到下一版)。
    • 置信缺口:
      • cloud 仓未测(本容器无检出)。
      • Dynamics 365 的细节来自搜索摘录(微软文档站在本容器被出口策略拦截)。
      • 客户实际的币种数量与汇率来源没有样本。
      • 探针读数由 dev 实测,席位未重跑;探针文件已删,输入与读数逐字记在报告里。

    裁后执行

    • 选 C': spec 先行拆卡:① spec:record 币种模式与币种字段指向、本位币折算声明,含构建期校验与拒收用例;② 平台对象 sys_exchange_rate 与写入时折算、缺汇率拒写、重算操作(引擎侧);③ 汇总侧对 record 模式原始聚合的拒收与按币种分组时逐行带币种(analytics);④ 子问题 5、6 的护栏与默认值收紧。每张卡带拒收用例与契约复核。
    • 选 A': 只做上面的 ① 与 ③,外加子问题 5、6。
    • 选 A: 只做子问题 5、6 与文档修正。
    • 选 B: 先另立一张卡解决"汇率按调用点预先钉住"的机制,再做汇率表与函数。
  16. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    Contributor

    Box intake (triage): the face is complete and ready for the director. The decision is the domain:spec seat's 6041482631. Its recommendation is C' (fallback named in the face), with the sub-questions answered by default if the maintainer replies with a letter only. The labels stand (needs-user-decision · domain:spec · target:v18).

    Read for intake:

    • 维护者速读;
    • Governing text:;
    • premises with re-check commands and positive controls;
    • options × real cost;
    • the os-decision-facets block;
    • recommendation, fallback and confidence gap;
    • 裁后执行;
    • Prior rulings read:.

    None is missing.

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-07T20:56Z. ⛔ Not a claim.

  17. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    Contributor

    Paused on the maintainer's word: needs-user-decision → pm:on-hold · director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (via the relay) 2026-10-08T03:13Z

    Batch #288 presented this card's decision request (6041482631: A / A′ / B / C′, the seat recommending C′) in the detailed form. The maintainer answered, verbatim: 「8345 多币种暂时不是重点,暂停」. No letter is taken; the design round's measurement (6041378776) and the seat's decision request stand as this card's starting point when it resumes. Freshness gate: the body and all fifteen comments (newest 6046701906, triage's box intake) were re-read before this record. Thread-read: 6046701906.

    Restart-when: the maintainer names multi-currency a priority again, in chat or on this card (the domain:spec seat then re-verifies the measurement round's premises on the then-current main before re-presenting)

    • What pauses: the four options, the six sub-questions, and every build they imply. Nothing of the currency field, the localization currency setting or the analytics labelling chain changes under this hold.
    • What does not pause, and is not this card's: the two published sentences the measurement found false today (content/docs/protocol/objectql/index.mdx:121 "Money with exchange rates", and the sibling claim the seat read beside it) are a shipped statement that is wrong, which North Star rule 4 makes a finding for the triage seat to file as a documentation correction, independent of this hold.
    • State: needs-user-decision removed, pm:on-hold added; enhancement · priority:p2 · domain:spec · target:v18 unchanged; no assignee.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions