Repository navigation
feat(objectql): store a record-mode amount converted into the home currency on every write (multi-currency M2-2) - #28302
Conversation
…}` on a record-mode amount A `record`-mode amount declares, by three required pointers into the same object, where the engine stores it converted into the home currency, which document date picks the rate, and where the rate used is stored (ADR-0144 D5). - `CurrencyConfigSchema` gains `homeAmount`, a closed shape whose three pointers are required and non-empty; it is refused outside `record`. - `ObjectSchema`'s superRefine gains `refuseDishonestHomeAmount`: the amount is a stored `currency` field; `field` names another `dynamic`-mode `currency` field no other amount stores into; `rateDate` a `date` / `datetime` field; `rateField` a `number` field, shared only by amounts with the same code field and document date. The home and rate fields declare `readonly: true` and no `expression`. Each refusal is located at its pointer and carries a remedy and the object's candidates. - ADR-0144 anchors on field.zod.ts and object.zod.ts gain the D5 invariant. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…me-amount pointer The currency block of `objectstack-data/rules/field-types.md` gains the `homeAmount` example and one paragraph, paid for by deleting restatement in the same file (3207 -> 3181 tokens). The changeset declares `@objectstack/spec` and `@objectstack/skills` minor and marks the key as released with the write-time projection (ADR-0144 D9, R2). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…me-amount-pointer
…and the strictness ledger for `homeAmount` Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…e engine writes it The form/Zod reconciliation gate refuses a key the Zod accepts and the form does not offer. `homeAmount` is offered in no form yet, deliberately: nothing writes the home amount until the write-time projection lands in the same release, so the gate's ledger carries a `planned` omit, the precedent of the other spec-first keys. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…me-amount-pointer # Conflicts: # packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
…erging main Main's side of the four regenerated files, re-derived from the merged tree so this branch's `homeAmount` rows sit beside main's new entries. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…main Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V
…ATE_MISSING and ERR_HOME_CURRENCY_UNSET; export CURRENCY_FRACTION_DIGITS (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…ate update, validate() preview; bridge localization.currency (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…ce (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…-wasm and Postgres (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…7192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…main Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…'s key column is ref (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 71 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 170 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9a46b9761954883e4f281872f0106c313285f365 && git checkout 9a46b9761954883e4f281872f0106c313285f365
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 501c864dd5918a7bab1f3a59e1246b8a0ddff48c 321991c7d13e8cdf74efef63c1053c1be1e0c71b && git checkout -B drift-repro 501c864dd5918a7bab1f3a59e1246b8a0ddff48c && git merge --no-ff 321991c7d13e8cdf74efef63c1053c1be1e0c71b
node scripts/docs-audit/affected-docs.mjs --json 501c864dd5918a7bab1f3a59e1246b8a0ddff48c
|
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 Contract reviewServed-tier: Inputs: card #27192 (body and its four comments: 6526609699, 6528146352, 6538085874, 6538855343), PR #28302 (body, 18-file list, net diff against ① Derived judgments
② Semver levelTwo changesets, ③ Boundary flagsHand-off 6526609699
Hand-off 6528146352
Open questions (os-dev-report 6538855343)
Flags from the tree
④ Design judgment
Should-fix (none blocks):
The remaining 16 files of the diff (engine, validate, plugin, the four test files, the two changesets, the regenerated reference pages and surfaces, Implemented-by: VERDICT: PASS |
…igits, not ISO 4217 minor units; the scale pin's header matches its body (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
…'s localization.timezone; the R2 core ships with R1 (#27192) Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 Contract reviewServed-tier: This record judges head Inputs: card #27192 (body and its four comments: 6526609699, 6528146352, 6538085874, 6538855343 — no fix-round report was posted on the card, so the fix round's own account is the PR body, and the tree was judged), ruling 6539345871 on #8345 (Q1 = A′, Q2 = B; read in full), PR #28302 (patched body, 18-file list, net diff against ① Derived judgments
② Semver levelTwo ③ Boundary flagsHand-offs 6526609699 and 6528146352 — all five points carried as answered (strip, scale, docs, defaultValue, preserveAudit); the delta leaves each pin in place. Open questions (os-dev-report 6538855343)
Flags from the tree
④ Design judgment
Should-fix status
The remaining files of the diff (engine, validate, plugin, the four test files, the two changesets, the regenerated reference pages and surfaces, Implemented-by: VERDICT: PASS |
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 席位记录 · epic PM
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5
Fixes #27192
⛔ not released alone — the R2 core: ships with R1 in 18.3.0 (Version Packages #26517), together with #28096 (
currencyConfig.homeAmount), #27185 (sys_exchange_rate) and #27198 (thelocalization.currencylock) (ruling 6539345871, letter A′; ADR-0144 D9: "thehomeAmountkey never ships ahead of the engine"). #27193, #27190 and #27196 are R2.1, in a later Version Packages.Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
homeAmount的按记录币种金额,在新建、以及更新金额、币种或单据日期时,由引擎自动把金额换算成本位币存进本位币金额字段,并把当时用的汇率原样存进汇率字段:本位币金额 = 金额 ÷ 汇率,汇率取单据日期当天或之前最近一条,按本位币的小数位四舍五入(远离零)。currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096)只说了存哪,本卡让它真正被写入,报表直接对本位币字段求和即可(ADR-0144 D5)。homeAmount的对象,其它对象零变化。缺汇率、未设本位币时写入被拒(不默认为 1、不猜本位币),可能拦住数据录入,这是有意的。⛔ 不单独发版,须与 R2 其它卡同批。回滚即撤 PR。packages/objectql/src/home-amount-projection.ts的projectHomeAmounts与divideToPlaces(取汇率、除法、舍入、两个拒绝);packages/objectql/src/engine.ts中 insert、按 id 更新、按条件更新三处接入;packages/objectql/src/plugin.ts的本位币读取桥。pnpm --filter @objectstack/objectql exec vitest run src/engine-home-amount-projection.test.ts,预期全绿,其中 USD 10,000、汇率 0.1389、本位币 CNY 存为 71994.24。localization.timezone,本 PR 实现);另两个开放问题(未设本位币时拒绝、汇率字段 scale 小于 12)在卡片报告里。What changes
packages/objectql/src/home-amount-projection.ts(new). One projection perhomeAmountdeclaration, pure apart from two reads it is handed (the home currency and the rate row):sys_exchange_raterow for the record's currency with the latesteffective_dateon or before the document date (homeAmount.rateDate). Adatetimedocument date is read as its calendar day in the organization'slocalization.timezone(UTC when unset; ruling 6539345871, letter B). One read per (currency, date) per write, memoized across a batch.BigInt) arithmetic, rounded once, half away from zero, toCURRENCY_FRACTION_DIGITS[home](JPY 0, USD/CNY 2, KWD 3). No float step:1.005stores1.01, not1.00.1, no rate read. Blank amount: blank home amount and rate.ERR_CURRENCY_CODE_REQUIRED:ExchangeRateMissingError→ERR_EXCHANGE_RATE_MISSING(names the currency, the date, the home currency and Setup → Exchange Rates; never a rate of 1, a later rate or an empty home amount);HomeCurrencyUnsetError→ERR_HOME_CURRENCY_UNSET(nameslocalization.currency; never an assumed home currency). A foreign amount with a blank document date is the existingVALIDATION_FAILEDwithrequiredon the date field.packages/objectql/src/engine.ts. The projection runs at three seams, each after thereadonlystrip and the stored-expression pass and before validation and the driver:insertManypartial mode a refusal is that row's error and the other rows land.VALIDATION_FAILED, "update those rows by id"), the same settle rule as the stored-expression bulk path.setHomeLocalizationResolver(resolver)(returns{ currency, timezone }); rate rows are read withisSystemin the row's organization, inside the ambient transaction.packages/objectql/src/engine-validate.ts.validate()previews the same projection, so the import dry run and the preview answer the same refusals as the write, as row findings.packages/objectql/src/plugin.ts.ObjectQLPluginwires the resolver to thesettingsservice'slocalization.currency, asked for the row's organization. Nosettingsservice, or an unknown namespace or key, reads as unset (and refuses); any other settings error propagates.packages/spec(see the deviation below): two ledger rows and one export line, plus the regeneratedapi-surface/,export-origins/and reference pages. No Zod shape changes.content/docs/data-modeling/currency.mdx: the "Converting into the home currency" section (declaration, rate direction, what a write stores, the engine-only fields, the refusals, totals) replaces the "comes in a later release" paragraph.@objectstack/objectqlpatch and@objectstack/specpatch, each with the two declaration lines and the R2 ⛔ line.Accepted deviation from the dispatch order
The order put
packages/spec/src/**out of scope. The epic PM accepted two narrow exceptions in this thread; no Zod schema shape and no table value changes:packages/spec/src/api/error-code-ledger.zod.ts:ERR_EXCHANGE_RATE_MISSINGandERR_HOME_CURRENCY_UNSETregistered under@objectstack/objectql(ADR-0112: a refusal code the platform serves is a ledger member), plus the generated reference pages. This widensApiErrorSchema.codeandFlowInnerRefusal.codeby two members.packages/spec/src/data/index.ts: one line,export * from './currency-fraction-digits', so the engine rounds with the spec's CLDR fraction-digit table instead of a copy (CLDR, not ISO 4217: about 17 codes differ, e.g. IQD, HUF, COP, IDR, PKR).CURRENCY_FRACTION_DIGITSis new public surface of@objectstack/spec/data(api-surface/data.json+1).Clause-② and Metadata-protocol, re-derived
The order proposed
Clause-②: no·Metadata-protocol: none. Corrected toClause-②: yes (widening),Metadata-protocol: none:ERROR_CODE_LEDGER(and so inApiErrorSchema.code); (b)CURRENCY_FRACTION_DIGITSexported from@objectstack/spec/data; (c)ObjectQL.setHomeLocalizationResolver, new public engine method.currencyConfig.homeAmount, which ships in this same release.none: no authorable key is added, removed or changed;homeAmountwas declaredaddedby feat(spec):currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096, and this PR gives it the meaning that PR already documented. So both changesets arepatch.ADR-0144 open items settled here
For the epic PM to carry into the ADR in its own PR:
effective_dateon or before the document date; a later row never applies; none ⇒ERR_EXCHANGE_RATE_MISSING. Adatetimedocument date is its calendar day in the organization'slocalization.timezone, UTC when unset (ruling 6539345871, letter B).CURRENCY_FRACTION_DIGITS); the rate is stored unrounded.ERR_HOME_CURRENCY_UNSET; no default. Raised as an open question on the card.ERR_EXCHANGE_RATE_MISSING/ 400 andERR_HOME_CURRENCY_UNSET/ 400, plusVALIDATION_FAILEDrequiredfor a blank document date andVALIDATION_FAILEDfor a divergent filter update.Tests
packages/objectql/src/home-amount-projection.test.ts: the arithmetic and each verdict, without an engine: division, half-away rounding of ±0.125, JPY / CNY / KWD, the1.005float pin, memoized reads, the filter-update settle.packages/objectql/src/engine-home-amount-projection.test.ts(in-memory driver double overapplyDriverQueryInMemory): the card's check (USD 10,000 at 0.1389 into CNY = 71994.24), a batch reading each (currency, date) once, same currency, blank amount, missing rate (nothing written), blank date, blank code,insertManypartial mode; unset home with no resolver, and the resolver asked with the row's organization; the hand-off points below; updates of the amount, the code and the date, a clear, an unrelated edit after a rate correction, a date with no rate; filter update uniform and divergent; thevalidate()preview.packages/objectql/src/home-amount-write-paths.test.ts: a booted kernel (ObjectQLPlugin, a fakesettingsservice, the real protocol,runImport). REST create/update, import batched (insertManyData) and per row (createData), a historical update-mode import of the home column, unset home via REST, and nosettingsservice.packages/qa/dogfood/test/home-amount-projection.test.ts: the rounding pins on better-sqlite3, sqlite-wasm and PostgreSQL with the realsys_exchange_rateobject: the card's check; JPY / USD / KWD; a twelve-decimal rate through the column; same currency with the1.005pin; the rate-row choice and the refusal; amount, code and date updates.Hand-off points, each pinned
strictReadonlyWrites), then the engine writes both fields.rateFieldscale below 12: the engine never rounds the rate; the full row value is stored. Areadonlyvalue is validated for shape only and every numeric column is the same exact decimal, so a declaredscale: 4reaches display only (pinned:0.138912345678stored in full, home 71987.84). Whether the spec should refuse a scale below 12 is spec-lane work and is an open question on the card.currency.mdxpassage: written.defaultValueon the home or rate field: overwritten by the projection, pinned.preserveAuditimport write to the home field: recomputed over, not kept, pinned on the engine and throughrunImportwithtreatAsHistorical.Import and REST share one engine path
REST:
packages/rest/src/crud-routes.tscreate and update call the protocol'screateData/updateData, which callengine.insert/engine.update. Import:runImport(the sync route and the job worker) writes throughinsertManyData(→engine.insertMany→engine.insert) orcreateData/updateData. Every door lands in the same insert or update seam;home-amount-write-paths.test.tspins each.Ablations
Each mutation applied on disk, the suites re-run, then restored. Engine face (objectql's three suites, 48 tests) and driver face (objectql rebuilt, dogfood's 18 tests across the three cells):
$lte→$ltAcceptance notes
currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096's head (cf13692214); feat(spec):currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096 landed 2026-10-31 andmainis merged in (no rebase).localization.currencyonce asys_exchange_raterow or ahomeAmountdeclaration exists; Setup shows what uses it (multi-currency M1-3, epic #8345) #27198's settings lock, cli:os migrate recompute-home-amounts --object X [--since DATE] [--apply]— report then apply; rows with a missing rate are listed and skipped (multi-currency M2-3, epic #8345) #27193's recompute (a rate row corrected later rewrites nothing; an unrelated edit leaves the stored conversion), the home field's display (ui: home field read-only with "converted at RATE on DATE"; live preview when a rate exists; Studio editor forhomeAmount(multi-currency M3-2, epic #8345; the read-only display is released with M2) #27196),skills/**,docs/adr/**,.claude/**.datetimedocument date — ruled B (6539345871), implemented here; whether the spec should refuse arateFieldscale below 12.Verification
Package suites at the final head,
dispatch-gates.mjs --commandswith each exit code captured before any pipe and the--ranreconciliation are reported with the head sha in the os-dev-report comment on #27192.Generated by Claude Code