You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 73b7270
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec): currencyConfig.homeAmount — a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) (#28096)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6VFixes#27191
⛔ not released alone — R2: pairs with #27192 (the write-time projection
that writes the home amount), #27185, #27190, #27198, #27193, #27196
(one Version Packages release; ADR-0144 D9: "the `homeAmount` key never
ships ahead of the engine")
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: updated here
Independent review: none run (quota); the PR waits for the seat's
contract-review hand-off.
## 维护者速读
- **改了什么:** 按客户币种记账(`currencyMode: 'record'`)的金额字段,现在可以声明
`homeAmount`:指明换算成本位币后的金额存在哪个字段、按哪个日期字段取汇率、实际使用的汇率存在哪个字段。三个字段都必须是同一对象上作者自己声明的字段,平台不注入列、不猜默认(不默认
`created_at`);本位币金额字段与汇率字段只由平台写入,作者须标 `readonly: true`。
- **为什么改:**
合并报表要在本位币下汇总;只有把换算结果连同当时的汇率一起存下来,报表、汇总与分析才能直接读一个普通的本位币金额字段(ADR-0144
D1/D5)。
- **风险与回滚:** 纯新增可选键(minor),不写 `homeAmount` 的应用零变化;本 PR
只有声明与拒收,真正写入本位币金额的引擎在 #27192。⛔ 不单独发版:必须与 #27192 同一次 Version Packages
发布,否则是「声明了却不兑现」。回滚即撤 PR。
- **先看:** `packages/spec/src/data/object.zod.ts` 的
`refuseDishonestHomeAmount`(指针所指字段的全部拒收);`packages/spec/src/data/field.zod.ts`
的 `HomeAmountSchema` 与 `refuseRecordModeKeyMismatch`
新增的一臂;`skills/objectstack-data/rules/field-types.md` 币种段。
- **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run
src/data/currency-config-home-amount.test.ts`,预期全绿:非 `record` 模式写
`homeAmount`、缺任一指针、本位币字段不是 `dynamic` 币种字段、日期字段不是日期、汇率字段不是数字、两字段未标
`readonly` 各自被拒并带处方。
- **需要你做的:** 本 PR 改了 `skills/**`(受管面 Tier H),请在 PR
上审批技能文本;之后由席位在契约复审通过后落地,并与 #27192 同批发版。
## What changes
- **`CurrencyConfigSchema.homeAmount`**
(`packages/spec/src/data/field.zod.ts`, ADR-0144 D5): a new optional
key, a closed shape `{ field, rateDate, rateField }`. All three pointers
are required whenever `homeAmount` is declared, and none may be empty.
ADR-0144 D5 corrects the card's draft `rateField?` to required. A
missing pointer and an empty one each get their own message with a
remedy. Near-miss spellings (`rate`, `date`, `homeField`, …) are steered
to the declared pointer by the shape's `aliases`.
`refuseRecordModeKeyMismatch` gains one arm: `homeAmount` under
`dynamic` or `fixed` (including the defaulted mode) is refused at
`homeAmount`, because only a `record` amount is converted.
- **`refuseDishonestHomeAmount`**
(`packages/spec/src/data/object.zod.ts`), in `ObjectSchema`'s
superRefine beside the `currencyField` arm. It judges what the pointers
name, against the authored field map. There is one located issue per
offending pointer, at `fields.AMOUNT.currencyConfig.homeAmount.POINTER`.
Each issue carries a remedy and the object's candidate fields.
- The amount must be a stored `currency` field. A `formula` amount is
computed on read and never written, so it is refused at `homeAmount`
itself.
- `field` must name another declared `currency` field in `dynamic` mode.
An absent `currencyConfig` counts as `dynamic`. This follows ADR-0144 D8
rule 5: "the home field is a `dynamic`-mode currency field", so metadata
never names the home currency. A second amount may not claim a home
field that another amount already writes.
- `rateDate` must name a declared `date` or `datetime` field. Nothing
defaults it to `created_at`.
- `rateField` must name a declared `number` field. Two amounts may share
one rate field only when they name the same `currencyField` and the same
`rateDate`, because otherwise they would store two different rates in
one column.
- **Platform-written mark.** The home field and the rate field are
written only by the engine. They say so with the mark the spec already
has for that, `readonly: true`, which drops every non-system write of
the field from the payload. Neither field may also declare an
`expression`, which would be a second writer. No new key is invented.
- **Skills** (`skills/objectstack-data/rules/field-types.md`, governed
`skills/**`): the currency block teaches `homeAmount` in one example
line and one paragraph; the example names are guidance, "never a
contract" (ADR-0144 D8 rule 2). Paid for by deleting restatement in the
same file: the intro line, the "Text with Max Length" example (a
duplicate of the file's first code block), the "Master-Detail with
Cascade" example (the Relational table's row says it) and a removed-key
history comment. 3207 → 3181 tokens, 323 → 304 lines; bundle 219711 →
219685 tokens.
- **Changeset** `.changeset/27191-home-amount-pointer.md`:
`@objectstack/spec` minor and `@objectstack/skills` minor. The body
carries `Clause-②: yes (widening)`, `Metadata-protocol: added` and the
R2 pairing line.
- **Generated, not hand-edited:**
`packages/spec/authorable-surface/data.json` (gen:schema) and
`content/docs/references/**` (gen:docs).
- **ADR anchors:** the ADR-0144 entries on `field.zod.ts` and
`object.zod.ts` gain the D5 invariant.
## Tests
- New `packages/spec/src/data/currency-config-home-amount.test.ts`:
- **The config door.** The accepted shape round-trips. A `record` config
without `homeAmount` parses byte-identical to before. `homeAmount` is
refused under `fixed`, `dynamic` and the defaulted mode. Each missing
pointer and each empty pointer is refused at its own path. An unknown
key is steered to `rateField`.
- **The object door.** The honest shape is accepted, including
`datetime` and an explicit `dynamic` home field, and `parse(parse(x))`
equals `parse(x)`. A consistently shared rate field is accepted. Each
refusal is pinned with `code`, `path` and the message's first clause:
- `field`: points into nothing, at the amount itself, at a
non-`currency` field (a `number`, a currency `formula`), at a `fixed` or
`record` home field, or at a home field another amount already writes;
- `rateDate`: points into nothing, or at a non-date field;
- `rateField`: points into nothing, at a non-`number` field, or at a
rate field shared inconsistently;
- a home or rate field without `readonly: true`, or one declaring an
`expression`;
- a `formula` amount.
- The R1 suite `currency-config-record-mode.test.ts` stays green,
unchanged.
- One ablation, a gate run, and the package test and typecheck readings
come with the os-dev-report comment on #27191, at the final head.
## Acceptance notes
- **What holds the key from a lone release, as measured: nothing
mechanical.** The R1 template (#27268) held its key with three things: a
`⛔ not released alone — pairs with …` line in its PR body, a `⛔ Not
released alone` sentence in its changeset, and the maintainer's hand on
Version Packages (Prime Directive #15; the epic #8345 body: "the
maintainer merges Version Packages only when a pair is complete"). This
PR does the same. No gate, ledger or `release-pending-publish.mjs` mode
reads a pairing marker, and `.changeset/config.json` has no pre-release
mode. Once this lands, `changeset-release/main` (#26517) folds its
changeset into the next Version Packages release with every other
pending one. The epic's R2 sibling
`.changeset/27185-exchange-rate-table.md` is already on `main` in the
same state.
- **`readonly: true` and the engine (#27192):** `readonly` strips every
non-system write of the field from the payload, on insert and on update.
So the write-time projection has to store the home amount and the rate
on a path that strip does not undo: a system-context write, or a write
after the strip. That is #27192's to build and pin.
- **Refusals beyond the card's list, declared for the contract review:**
a `formula` amount, a home field claimed twice, a rate field shared
inconsistently, and an `expression` on a home or rate field. Each
refuses a shape in which the engine would write a value that is wrong,
or that another writer can overwrite. Each refusal is pinned.
- **Not touched:** the Studio field form (`field.form.ts`) offers no
`homeAmount` row; the read-only display of the home field is #27196
(M3-2). The hand-written page `content/docs/data-modeling/currency.mdx`
does not mention `homeAmount`.
- No engine write path is in this diff.
## Verification
Gate readings (`dispatch-gates.mjs --commands` on the final diff, each
exit code captured before any pipe, `--ran` reconciliation), the package
test and typecheck runs, and the ablation are reported with the head sha
in the os-dev-report comment on #27191.
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec): `currencyConfig.homeAmount: { field, rateDate, rateField }` — a record-mode amount declares its stored home-currency amount by pointer (ADR-0144 D5)
7
+
8
+
Clause-②: yes (widening)
9
+
Metadata-protocol: added
10
+
11
+
⛔ Not released alone: this ships in the same release as the write-time home-currency projection that fills these fields (ADR-0144 D9, release R2: "the `homeAmount` key never ships ahead of the engine").
12
+
13
+
An amount in `currencyMode: 'record'` can now declare where its value is stored after conversion into the organization's home currency (the `localization.currency` setting), which date picks the exchange rate, and where the rate used is stored. Consolidated totals then aggregate the home field like any other stored currency field.
-**Three required pointers.** When `homeAmount` is declared, `field`, `rateDate` and `rateField` must all be given, and none may be empty. There is no default, no injected column and no fixed field name. The document date is never taken from `created_at`. `homeAmount` is refused under `dynamic` and `fixed`.
33
+
-**What the pointers name, refused at parse with a remedy and the object's candidates.** Each refusal is located at `fields.AMOUNT.currencyConfig.homeAmount.POINTER`.
34
+
-`field` must name another `currency` field of the same object in `dynamic` mode (it displays in the home currency, so metadata never names that currency). No other amount may already store into it.
35
+
-`rateDate` must name a `date` or `datetime` field.
36
+
-`rateField` must name a `number` field. Two amounts may share it only when they have the same `currencyField` and `rateDate`.
37
+
- The amount itself must be a stored `currency` field. A `formula` amount is refused.
38
+
-**The home and rate fields are written by the platform only.** They must declare `readonly: true`, the existing mark that drops a client's write of the field, and no `expression`.
39
+
-**Skills:**`objectstack-data/rules/field-types.md` teaches `homeAmount`. Its field names (`amount_home`, `amount_rate`) are examples, not a contract.
40
+
-**Nothing to migrate.**`homeAmount` is new, and no existing config changes meaning.
Copy file name to clipboardExpand all lines: content/docs/references/data/field.mdx
+11-1Lines changed: 11 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -31,6 +31,15 @@ const result = CurrencyConfigSchema.parse(data);
31
31
|**currencyMode**|`Enum<'dynamic' \| 'fixed' \| 'record'>`| optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). |
32
32
|**defaultCurrency**|`string`| optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. |
33
33
|**currencyField**|`string`| optional |`record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. |
34
+
|**homeAmount**|`{ field: string; rateDate: string; rateField: string }`| optional |`record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. |
35
+
36
+
### Nested Shape: `CurrencyConfig.homeAmount`
37
+
38
+
| Property | Type | Required | Description |
39
+
| :--- | :--- | :--- | :--- |
40
+
|**field**|`string`| ✅ | The field on the same object that stores the amount converted into the home currency: a stored `currency` field in `dynamic` mode (it displays in `localization.currency`, so metadata never names the home currency), declaring `readonly: true` — only the engine writes it. Conventionally `AMOUNT_home`. |
41
+
|**rateDate**|`string`| ✅ | The field on the same object holding the record's document date, whose value picks the exchange rate: a `date` or `datetime` field. Required, with no default — the engine never guesses the date (not `created_at`). |
42
+
|**rateField**|`string`| ✅ | The field on the same object that stores the exchange rate the conversion used: a `number` field declaring `readonly: true` — only the engine writes it. Required: the home amount is stored with the rate used. Conventionally `AMOUNT_rate`. |
34
43
35
44
36
45
---
@@ -92,7 +101,7 @@ const result = CurrencyConfigSchema.parse(data);
92
101
|**summaryOperations**|`{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }`| optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
93
102
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
94
103
|**step**|`number`| optional | Step increment for slider (default: 1) |
95
-
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
104
+
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
|**trackHistory**|`boolean`| optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
98
107
|**group**|`string`| optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
@@ -250,6 +259,7 @@ const result = CurrencyConfigSchema.parse(data);
250
259
|**currencyMode**|`Enum<'dynamic' \| 'fixed' \| 'record'>`| optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). |
251
260
|**defaultCurrency**|`string`| optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency, and refused under `record`, where each record names its own. |
252
261
|**currencyField**|`string`| optional |`record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. |
262
+
|**homeAmount**|`{ field: string; rateDate: string; rateField: string }`| optional |`record` mode only, optional: where the engine stores this amount converted into the organization's home currency (the `localization.currency` setting) at write, at the rate of the record's document date, and the rate it used. `field` names a `dynamic`-mode `currency` field, `rateDate` a `date` / `datetime` field and `rateField` a `number` field, all on the same object; all three are required, and the home and rate fields declare `readonly: true` because only the engine writes them. Consolidated totals aggregate the home field. Refused under `dynamic` and `fixed`. |
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -270,7 +270,7 @@ const result = ApiMethod.parse(data);
270
270
|**summaryOperations**|`{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }`| optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
271
271
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
272
272
|**step**|`number`| optional | Step increment for slider (default: 1) |
273
-
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
273
+
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
|**trackHistory**|`boolean`| optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
276
276
|**group**|`string`| optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
@@ -636,7 +636,7 @@ const result = ApiMethod.parse(data);
636
636
|**summaryOperations**|`{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }`| optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
637
637
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
638
638
|**step**|`number`| optional | Step increment for slider (default: 1) |
639
-
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
639
+
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
|**trackHistory**|`boolean`| optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
642
642
|**group**|`string`| optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
Copy file name to clipboardExpand all lines: content/docs/references/system/migration.mdx
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -104,7 +104,7 @@ Add a new field to an existing object
104
104
|**summaryOperations**|`{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }`| optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
105
105
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
106
106
|**step**|`number`| optional | Step increment for slider (default: 1) |
107
-
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
107
+
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
|**trackHistory**|`boolean`| optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
110
110
|**group**|`string`| optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
@@ -537,7 +537,7 @@ Add a new field to an existing object
537
537
|**summaryOperations**|`{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }`| optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
538
538
|**language**|`string`| optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
539
539
|**step**|`number`| optional | Step increment for slider (default: 1) |
540
-
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
540
+
|**currencyConfig**|`{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }`| optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
|**trackHistory**|`boolean`| optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
543
543
|**group**|`string`| optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
0 commit comments