Skip to content

feat(objectql): store a record-mode amount converted into the home currency on every write (multi-currency M2-2) - #28302

Merged
objectstack-fleet[bot] merged 21 commits into
mainfrom
claude/project-thread-4g7rek
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 21 commits into
mainfrom
claude/project-thread-4g7rek

Conversation

@os-bill

@os-bill os-bill commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

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 (the localization.currency lock) (ruling 6539345871, letter A′; ADR-0144 D9: "the homeAmount key 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 的按记录币种金额,在新建、以及更新金额、币种或单据日期时,由引擎自动把金额换算成本位币存进本位币金额字段,并把当时用的汇率原样存进汇率字段:本位币金额 = 金额 ÷ 汇率,汇率取单据日期当天或之前最近一条,按本位币的小数位四舍五入(远离零)。
  • 为什么改: 合并报表要在本位币下汇总;R2 的声明(feat(spec): 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。
  • 需要你做的: 无,席位落地;datetime 取日已裁 B(6539345871,按组织 localization.timezone,本 PR 实现);另两个开放问题(未设本位币时拒绝、汇率字段 scale 小于 12)在卡片报告里。

What changes

  • packages/objectql/src/home-amount-projection.ts (new). One projection per homeAmount declaration, pure apart from two reads it is handed (the home currency and the rate row):
    • Rate row (Open item 2): the organization's sys_exchange_rate row for the record's currency with the latest effective_date on or before the document date (homeAmount.rateDate). A datetime document date is read as its calendar day in the organization's localization.timezone (UTC when unset; ruling 6539345871, letter B). One read per (currency, date) per write, memoized across a batch.
    • Arithmetic (parameter 3, Open item 3): home = amount ÷ rate, in exact scaled-decimal (BigInt) arithmetic, rounded once, half away from zero, to CURRENCY_FRACTION_DIGITS[home] (JPY 0, USD/CNY 2, KWD 3). No float step: 1.005 stores 1.01, not 1.00.
    • Same currency (Open item 6): rate 1, no rate read. Blank amount: blank home amount and rate.
    • The rate field stores the rate row's own value, unrounded, so the stored home amount reproduces from the stored amount and rate.
    • Refusals (Open items 4 and 9), ADR-0112 envelopes, both 400 like 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 (names localization.currency; never an assumed home currency). A foreign amount with a blank document date is the existing VALIDATION_FAILED with required on the date field.
  • packages/objectql/src/engine.ts. The projection runs at three seams, each after the readonly strip and the stored-expression pass and before validation and the driver:
    • insert / insertMany: per row, with the row's organization; under insertMany partial mode a refusal is that row's error and the other rows land.
    • update by id: when the patch carries the amount, its code, the document date, or the home or rate field, the projection is computed over the prior row merged with the patch.
    • update by filter: each matched row is projected; when they all convert to the same values the patch carries them, otherwise the write is refused (VALIDATION_FAILED, "update those rows by id"), the same settle rule as the stored-expression bulk path.
    • New setHomeLocalizationResolver(resolver) (returns { currency, timezone }); rate rows are read with isSystem in 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. ObjectQLPlugin wires the resolver to the settings service's localization.currency, asked for the row's organization. No settings service, 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 regenerated api-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.
  • Changesets: @objectstack/objectql patch and @objectstack/spec patch, 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:

  1. packages/spec/src/api/error-code-ledger.zod.ts: ERR_EXCHANGE_RATE_MISSING and ERR_HOME_CURRENCY_UNSET registered under @objectstack/objectql (ADR-0112: a refusal code the platform serves is a ledger member), plus the generated reference pages. This widens ApiErrorSchema.code and FlowInnerRefusal.code by two members.
  2. 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_DIGITS is 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 to Clause-②: yes (widening), Metadata-protocol: none:

ADR-0144 open items settled here

For the epic PM to carry into the ADR in its own PR:

  • Open item 2 (which rate row): latest effective_date on or before the document date; a later row never applies; none ⇒ ERR_EXCHANGE_RATE_MISSING. A datetime document date is its calendar day in the organization's localization.timezone, UTC when unset (ruling 6539345871, letter B).
  • Open item 3 (arithmetic and rounding): exact decimal, divide (parameter 3), round once, half away from zero, to the home currency's CLDR fraction digits (CURRENCY_FRACTION_DIGITS); the rate is stored unrounded.
  • Open item 4 (unset home currency): refused, ERR_HOME_CURRENCY_UNSET; no default. Raised as an open question on the card.
  • Open item 6 (same currency): rate 1, no lookup, still rounded to the home minor units.
  • Open item 9 (refusal vocabulary): ERR_EXCHANGE_RATE_MISSING / 400 and ERR_HOME_CURRENCY_UNSET / 400, plus VALIDATION_FAILED required for a blank document date and VALIDATION_FAILED for 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, the 1.005 float pin, memoized reads, the filter-update settle.
  • packages/objectql/src/engine-home-amount-projection.test.ts (in-memory driver double over applyDriverQueryInMemory): 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, insertMany partial 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; the validate() preview.
  • packages/objectql/src/home-amount-write-paths.test.ts: a booted kernel (ObjectQLPlugin, a fake settings service, 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 no settings service.
  • packages/qa/dogfood/test/home-amount-projection.test.ts: the rounding pins on better-sqlite3, sqlite-wasm and PostgreSQL with the real sys_exchange_rate object: the card's check; JPY / USD / KWD; a twelve-decimal rate through the column; same currency with the 1.005 pin; the rate-row choice and the refusal; amount, code and date updates.

Hand-off points, each pinned

  • 6526609699 — system write or after the strip: after the strip, in every context. A client value is dropped (or refused under strictReadonlyWrites), then the engine writes both fields.
  • 6526609699 — rateField scale below 12: the engine never rounds the rate; the full row value is stored. A readonly value is validated for shape only and every numeric column is the same exact decimal, so a declared scale: 4 reaches display only (pinned: 0.138912345678 stored 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.
  • 6526609699 — the currency.mdx passage: written.
  • 6528146352 — defaultValue on the home or rate field: overwritten by the projection, pinned.
  • 6528146352 — preserveAudit import write to the home field: recomputed over, not kept, pinned on the engine and through runImport with treatAsHistorical.

Import and REST share one engine path

REST: packages/rest/src/crud-routes.ts create and update call the protocol's createData / updateData, which call engine.insert / engine.update. Import: runImport (the sync route and the job worker) writes through insertManyData (→ engine.insertMany → engine.insert) or createData / updateData. Every door lands in the same insert or update seam; home-amount-write-paths.test.ts pins 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):

Ablation objectql dogfood (3 drivers)
A0 baseline 48/48 pass 18/18 pass
A1 rate predicate $lte → $lt 1 fails 3 fail
A2 latest → earliest row on or before 2 fail not run
A3 rate row ignores the date 9 fail 9 fail
A4 half-away → truncate 7 fail 9 fail
A5 minor units → fixed 2 1 fails not run
A6 exact decimal → float division 2 fail 3 fail
A7 divide → multiply (the × reading) 20 fail not run

Acceptance notes

Verification

Package suites at the final head, dispatch-gates.mjs --commands with each exit code captured before any pipe and the --ran reconciliation are reported with the head sha in the os-dev-report comment on #27192.


Generated by Claude Code

claude added 19 commits October 30, 2026 12:34
…}` 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
…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
…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>
…-wasm and Postgres (#27192)

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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 31, 2026
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 76 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

71 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 501c864dd5918a7bab1f3a59e1246b8a0ddff48c.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: recordId (5 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • 16 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 59 of 211 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 170 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 501c864dd5918a7bab1f3a59e1246b8a0ddff48c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9a46b9761954883e4f281872f0106c313285f365 — the merge of head 321991c7d13e8cdf74efef63c1053c1be1e0c71b into base 501c864dd5918a7bab1f3a59e1246b8a0ddff48c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 501c864dd5918a7bab1f3a59e1246b8a0ddff48c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract-review-claim: session_01Utp6CbTZrFqRYNgjSaSbN5 · head c37e52a · 2026-10-31T04:18Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c37e52a6687cec1d61aa9227abfd8d7a21b4ca9f
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/data/index.ts + packages/objectql/src/home-amount-projection.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-validate.ts, packages/objectql/src/plugin.ts, packages/objectql/src/validation/record-validator.ts, packages/objectql/src/validation/record-currency-code.ts, packages/objectql/src/engine-driver-options.ts, packages/objectql/src/record-currency-backfill.ts, packages/spec/src/data/currency-fraction-digits.ts, packages/spec/src/data/numeric-column-representation.ts, packages/spec/src/shared/value-domain.zod.ts, packages/spec/src/data/object.zod.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/platform-objects/src/system/sys-exchange-rate.object.ts, packages/types/src/thrown-http-error.ts, packages/core/src/utils/import-runner.ts, packages/rest/src/crud-routes.ts, packages/services/service-settings/src/manifests/localization.manifest.ts, docs/adr/0144-multi-currency-record-currency-dated-rates-write-time-home-projection.md, and the PR's four test files

Inputs: card #27192 (body and its four comments: 6526609699, 6528146352, 6538085874, 6538855343), PR #28302 (body, 18-file list, net diff against main at the merge base 73b72709), and the 50 check-runs on this head: 41 success, 9 skipped (opt-in harnesses and UI shards), 0 failed. The ten required contexts are all success, Governed Surface Queue Guard and Check Changeset included. The dispatching seat's conclusions were read as inputs to judge, not adopted; where this record agrees with them it says why from the tree.

① Derived judgments

  1. Ledger: ERROR_CODE_LEDGER['@objectstack/objectql'] gains ERR_EXCHANGE_RATE_MISSING and ERR_HOME_CURRENCY_UNSET. So ErrorCode, ApiErrorSchema.code and FlowInnerRefusal.code widen by two members (the three generated reference pages move from "+353 more" to "+355 more" and list both). Right. The registration is load-bearing, not decorative: thrown-http-error.ts narrows a thrown value's .code to StandardErrorCode ∪ ERROR_CODE_LEDGER and reads its .status; both classes in home-amount-projection.ts carry code, status = 400 and httpStatus = 400, so REST answers 400 with error.code set to the registered member instead of demoting it to declaredCode. The admission gate's rules hold on both rows (SCREAMING_SNAKE, domain-prefixed, no standard-catalog synonym, framework owner), and Test Core is green on this head. Precedent: 57c51aab (feat(objectql): record-currency guards — blank code refuses the write; ungrouped sum/avg/min/max over a record-mode amount refused; summary roll-up over one refused at registration #27339) registered ERR_CURRENCY_CODE_REQUIRED from an objectql card the same way.
  2. @objectstack/spec/data gains one public export, CURRENCY_FRACTION_DIGITS (api-surface/data.json +1, export-origins/data.json +1, both regenerated; check:api-surface green). Right. The module exports exactly one const, so export * leaks nothing else; shared/value-domain.zod.ts imports it internally and does not re-export it, so no dual-source name appears. Its first consumer is projectHomeAmounts, which reads CURRENCY_FRACTION_DIGITS[home] only after isValueDomainMember('iso_4217_currency', home), whose membership is this very table's key set — the non-null assertion is sound.
  3. ObjectQL.setHomeCurrencyResolver(resolver) is new public engine surface, declared in the objectql changeset; ValidateHost.previewHomeAmounts is an internal host addition. Right.
  4. Write-path accept set. For an object declaring currencyConfig.homeAmount — a key that landed on main today (feat(spec): currencyConfig.homeAmount — a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096) and ships in the same R2 release — four refusals are new: ERR_EXCHANGE_RATE_MISSING/400, ERR_HOME_CURRENCY_UNSET/400, VALIDATION_FAILED required on the document-date field for a foreign amount with no date, and VALIDATION_FAILED invalid_value on a predicate update whose one patch would convert the matched rows differently. No released accept set narrows, so Clause-②: yes (widening) is right and the dispatch order's Clause-②: no was wrong — the ledger's own header says registering a code is a Clause-② widening. Right.
  5. Every door is one seam. REST create/update → protocol.createData/updateData (crud-routes.ts 298, 483) → engine.insert/engine.update; import (runImport, sync route and job worker) → insertManyData (engine.insertMany is this.insert(..., { __partialRowErrors: true })) or createData/updateData per row, with treatAsHistorical mapped to preserveAudit (import-runner.ts 1010); validate() previews the same projection over the same image (judgedViews[i] is the stored row with the patch over it, engine-validate.ts 433, 565-576). The engine has no other write entry point (insert, insertMany, update; the Repository wrappers delegate). Right.
  6. Seam ordering, read in the tree. Insert: the projection (engine.ts 15737) runs after beforeInsert hooks (15144), after staticReadonlyCreateStrip and its re-default (15416, 14447), after the organization assignment seam (15545-15581), after the stored-expression pass and the strictReadonlyWrites refusal, and before validateRecordInScope(..., 'include'), which judges a readonly value for shape only. By id: after both strips and assertNoStrictDrops() (17495-17531), over priorRecord — read unconditionally on the by-id branch (16867, the Action-body writes have no not-found gate: ctx.api.object().update() against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867 not-found gate) — with the final patch laid over it. Predicate: the same point (17842-17889), each matched row projected and settled by settleBulkHomeAmounts. Right; the hand-off's "after the strip, in every context" is met at all three seams.
  7. The rate predicate and its organization. find(sys_exchange_rate, { where: { currency, effective_date: { $lte: day } }, orderBy effective_date desc, limit 1 }) under { ...ctx, isSystem: true, tenantId: organization }. engine-driver-options.ts applies the tenant scope whenever tenantId is present (164-173) and isSystem only mutes the tenant audit (180); sys_exchange_rate takes no tenancy opt-out, so organization_id is injected and the (currency, effective_date) key is unique per organization (its own test, lines 58-70). Right, with one gap: no test in this PR runs two organizations against a real driver — the in-memory doubles ignore tenantId and the dogfood cells run one organization — so the per-organization read rests on the engine's general tenancy pins (should-fix S1).
  8. Arithmetic. toScaledDecimal reads a number by its shortest round-trip spelling and a string as written; divideToPlaces is a·10^(rs+places) / (r·10^as) over BigInt, rounded once by divideRoundHalfAwayFromZero ((n % d)·2 ≥ d); toStoredNumber only spells the rounded decimal back as the bare number ADR-0104 D1 requires. No float step; 1.005 → 1.01, 10000 ÷ 0.1389 → 71994.24, ±0.125 → ±0.13. Right. Parameter 3 is division (the card body's "×" is the inverse the ADR corrected). Same currency as home: rate 1, no read, still rounded (Open item 6). A rate that is not a positive decimal counts as missing. Right.
  9. Physical columns. The SQL driver types every number/currency/percent/summary column from the spec's one representation, numericColumnFor → decimal(65, 30) (sql-driver.ts 16997; builtin-column-collision.ts 78 records that the field's own scale is not read for DDL). So the twelve-decimal rate and a three-decimal KWD home amount survive PostgreSQL and MySQL, and a declared scale: 4 on the rate field never reaches the column. The PR's claim that a declared scale "reaches display only" holds on the real drivers, not only on the in-memory double; CI's dogfood gate provisions PostgreSQL 16 (ci.yml 2081, 2177), so the PG cell ran. Right.
  10. Metadata-protocol: none. No authorable key is added, removed or changed; homeAmount was declared added by feat(spec): currencyConfig.homeAmount — a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096 with this exact meaning in its .describe(). Right. No governed path in the file list (skills/** restored to main's text at this head). Right.

② Semver level

Two changesets, @objectstack/objectql patch and @objectstack/spec patch, each carrying Clause-②: yes (widening: …), Metadata-protocol: none and the R2 ⛔ not-released-alone line (ADR-0144 D9). The PR body carries the same Clause-②: yes (widening) and Metadata-protocol: none. Under the Post-Task Checklist rule the level follows the metadata-protocol kind alone — none takes patch, and "a non-protocol change, widening or breaking, ships patch" — so patch is the right level for both, and the widening list (two ledger members, one spec export, one engine method) matches what the diff publishes. Check Changeset and the PR-body guards are green. Holds.

③ Boundary flags

Hand-off 6526609699

  • System write or after the strip — answered: after the strip at all three seams and in every context; a client value is dropped (or refused under strictReadonlyWrites), a system writer's value is recomputed over; pinned in engine-home-amount-projection.test.ts ("only the engine writes…").
  • rateField scale below 12 — answered: the engine never rounds the rate; a readonly value is judged for shape only (record-validator.ts: if (shapeOnly) return null at 1243 precedes the max_scale arm at 1344), and the column is the per-type decimal(65, 30), so a declared scale reaches display only; pinned (0.138912345678 stored, home 71987.84). Whether the spec should refuse the declaration is open question 3 below.
  • currency.mdx — written (prose, not reviewed here; read for its claims, see F1).
  • Release — the ⛔ R2 line is on the body and both changesets.

Hand-off 6528146352

  • defaultValue on the home or rate field — answered: the projection runs after staticReadonlyCreateStrip's re-default, so the default is overwritten; pinned (both a converting row and a blank-amount row).
  • preserveAudit import write to the home field — answered: recomputed over, never kept; pinned on the engine and through runImport with treatAsHistorical.

Open questions (os-dev-report 6538855343)

  1. Unset localization.currency (Open item 4). Answered: A, refuse ERR_HOME_CURRENCY_UNSET/400 — as built. Judged against the ADR, not the seat's steer: D5 names the fallbacks the design rejected for a missing rate — a 1:1 rate, the latest row, a null home amount — and the same reasoning covers a missing home currency: option B stores the null home amount D5 rejects (the row silently leaves every consolidated total), and option C is the silent default the sources rule out, with D8 rule 5 leaving no place in metadata to declare one. A distinct code is right under ADR-0112: the condition and the remedy differ from a missing rate (set a setting vs add a rate row), the message names the setting and the fix, and standardSynonymOf finds no standard member. Escalated to the epic PM: ADR-0144's status block says the two codes drafted for Open item 9 "are the only ledger additions"; carrying Open item 4's answer into the ADR must amend that sentence, and item 4 also names service-settings: lock localization.currency once a sys_exchange_rate row or a homeAmount declaration exists; Setup shows what uses it (multi-currency M1-3, epic #8345) #27198 — the lock must still admit the first setting of localization.currency.
  2. A datetime document date read as its UTC day. Acceptable for R2 as built and documented, but the report's premise ("the engine has no organization timezone at the write seam") is incomplete: localization.timezone exists in the same manifest (localization.manifest.ts 48, default UTC, not required) and could ride the same resolver bridge. For every organization on the default timezone A and B coincide. Escalated to the maintainer: A now with B as a follow-up card is my recommendation; until then the skill and docs should steer rateDate to a date field.
  3. Should the spec refuse a rateField scale below 12? Answered: yes, as a spec-lane card, not here. The tree loses no digit (①.9), so the hazard is display-only — a four-place display no longer reproduces the stored home amount for a reader — and Prime Directive Add comprehensive test suite for Zod schema validation #12 puts the fix at the producer: refuse (or warn on) homeAmount.rateField declaring a scale below 12 at parse. Escalated as domain:spec work.

Flags from the tree

  • F1 — the table is CLDR's digit counts, not ISO 4217 minor units for about seventeen codes. CURRENCY_FRACTION_DIGITS reads IQD: 0 (ISO 4217: 3) and HUF, COP, IDR, PKR, IRR, LBP, MMK, SYP, YER, AFN, ALL, LAK, KPW, SLL, SOS, MGA as 0 (ISO 4217: 2); its own header calls it a CLDR snapshot, and a host ICU 77.1 answers 2 for HUF/COP/IDR/PKR where the snapshot says 0. The sources (Open item 3) named this table, so the built behaviour follows them, and reading a checked-in snapshot is right for determinism — but the index.ts export comment, the spec changeset and currency.mdx all say "ISO 4217 minor units". Escalated to the maintainer: confirm rounding to CLDR's practical digits (my recommendation: yes — one table, the value domain's own, and stored equals displayed where renderers read CLDR), and fix the wording (should-fix S2).
  • F2 — the record-currency backfill now converts. os migrate record-currency-codes writes the code through engine.update per row (record-currency-backfill.ts 256); on an object declaring homeAmount that write runs the projection, so a row whose document date has no rate in force — or an organization with no home currency — fails that row with the new codes, recorded in report.failures (258-266), not swallowed. Correct under D5; the operator docs for the backfill should say rates must be in force first. For the docs/skills lane, not this PR.
  • F3 — organization of a system insert. The rate read is scoped by row.organization_id, else context.tenantId, else the organization the assignment seam derived; with none of the three the read is unscoped, which is reachable only where ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED does not already refuse — a deployment without organizations. Acceptable.
  • F4 — rounding mode vocabulary. The card says "half-up"; the ADR's Open item 3 names no mode; the PR pins half away from zero (−0.125 → −0.13), which is accounting HALF_UP. For positive amounts they coincide. Record the mode in the ADR amendment (S4).

④ Design judgment

packages/spec/src/api/error-code-ledger.zod.ts — two rows under @objectstack/objectql.
Judged against ADR-0112 (the admission door), ADR-0144 D5 and Open items 2, 4 and 9, and the first consumer, home-amount-projection.ts.

  • Against ADR-0112. Both rows are SCREAMING_SNAKE, domain-prefixed, owned by a framework package, and no standardSynonymOf member (gate green). Each comment states the condition, the status, the emitter and that the driver is never asked to write the row; the second comment says why it is not a reuse of the first (a different remedy). Holds.
  • Against ADR-0144. The ERR_EXCHANGE_RATE_MISSING comment states Open item 2's predicate (latest effective_date on or before the document date — never a later row, never "the most recent") and D5's three rejected fallbacks; the class message names the currency, the date, the home currency and the remedy, and the test pins that it carries no tracker number. ERR_HOME_CURRENCY_UNSET settles Open item 4 by refusal (③.1). Holds.
  • Against the consumer and the wire. The codes travel on the thrown classes' .code with .status = 400; thrown-http-error.ts keeps a registered .code in error.code; validate() answers either as the row's finding with the same code (engine-validate.ts 606), so a dry run refuses what the write refuses. Holds.
  • Tried to break it. (a) If Open item 4 were later ruled B or C the second row would be a dead member — but both contradict D5's reasoning and the sources (③.1), so the row stands with the settlement judged right here. (b) The ADR's "only ledger additions" sentence — owed an amendment, on the epic PM's ADR PR, not this one (S4). (c) Placement: the objectql entry is not alphabetical (INVALID_METADATA sits before ERR_READONLY_FIELD_REJECTED), so ERR_HOME_CURRENCY_UNSET after ERR_HOOK_TARGET_REBIND breaks no rule; cosmetic. (d) The two VALIDATION_FAILED sub-codes (required, invalid_value) reuse the field-level vocabulary rather than minting top-level codes — right; they are shapes the validator already answers.
    No must-fix.

packages/spec/src/data/index.ts — export * from './currency-fraction-digits'.
Judged against ADR-0144 Open item 3 (and D7 parameter 4) and the first consumer, projectHomeAmounts.

  • Against the ADR and the consumer. The sources name this table as the minor-unit reader; the consumer reads it after an ISO-membership check that is the same key set (value-domain.zod.ts 88), rounds once in exact arithmetic to CURRENCY_FRACTION_DIGITS[home] places, and stores the rate itself unrounded so the home amount reproduces from the stored pair. JPY 0, USD/CNY 2, KWD 3 are pinned at the unit, engine and driver faces. Holds.
  • Against the spec's own rules. A constant table, no business logic (Prime Directive 2); one declaration, one export path (check:dual-source-exports green); api-surface and export-origins regenerated. Holds.
  • Tried to break it. (a) F1: the export's comment calls the table "The ISO 4217 minor units per currency"; the table is CLDR's digit counts and departs from ISO 4217 for IQD and about sixteen zero-digit codes — the right thing to read, described wrongly (should-fix S2, pending ③ F1). (b) A CLDR regeneration that moves a digit count changes the rounding of future writes only; stored rows rest (ADR-0113 D1). Acceptable. (c) The table's type, a read-only record from string to number, under noUncheckedIndexedAccess hands the consumer number | undefined; handled by the membership check above.
    No must-fix.

Should-fix (none blocks):

  • S1 (lane, tests): pin the per-organization rate read with two organizations on a real driver — a rate row in organization B must neither convert organization A's amount nor satisfy A's missing-rate refusal; today only the engine's general tenancy pins carry it.
  • S2 (hits file + prose): the index.ts export comment, the spec changeset and currency.mdx should say the table is CLDR's digit counts — ISO 4217 minor units for most codes, 0 where CLDR records the minor unit unused (IQD, HUF, COP, IDR, PKR, …) — once ③ F1 is confirmed.
  • S3 (lane, test prose): the header of engine-home-amount-projection.test.ts says a rate-field scale below the rate's decimals "refuses max_scale" while its own body and the tree pin the opposite (stored unrounded); correct the header.
  • S4 (epic PM, ADR amendment): amend "They are the only ledger additions" when carrying Open item 4; record the rounding mode as half away from zero (accounting HALF_UP), the UTC-day reading of a datetime document date, and Open item 6's rate 1.
  • S5 (lane, optional): no scripts/adr-anchors/ entry anchors home-amount-projection.ts's D5/D7 spots; precedent 57c51aab added none either, so a convention gap rather than a gate.

The remaining 16 files of the diff (engine, validate, plugin, the four test files, the two changesets, the regenerated reference pages and surfaces, currency.mdx) are the lane seat's ACCEPT at TIER_DEFAULT; they were read here to judge the two hits files, not reviewed.

Implemented-by: claude/project-thread-4g7rek
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 321991c7d13e8cdf74efef63c1053c1be1e0c71b
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/data/index.ts + packages/objectql/src/home-amount-projection.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-validate.ts, packages/objectql/src/plugin.ts, packages/objectql/src/validation/record-validator.ts, packages/objectql/src/validation/record-currency-code.ts, packages/objectql/src/engine-driver-options.ts, packages/objectql/src/record-currency-backfill.ts, packages/spec/src/data/currency-fraction-digits.ts, packages/spec/src/data/numeric-column-representation.ts, packages/spec/src/data/autonumber-format.ts, packages/spec/src/shared/value-domain.zod.ts, packages/spec/src/data/object.zod.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/platform-objects/src/system/sys-exchange-rate.object.ts, packages/types/src/thrown-http-error.ts, packages/core/src/utils/import-runner.ts, packages/rest/src/crud-routes.ts, packages/services/service-settings/src/settings-service.ts, packages/services/service-settings/src/manifests/localization.manifest.ts, docs/adr/0144-multi-currency-record-currency-dated-rates-write-time-home-projection.md, and the PR's four test files

This record judges head 321991c7, whose parent chain is c37e52a6 (the PASS of record 6539117549) → 2fe4a2cd → 321991c7; the two commits were read as the delta c37e52a6..321991c7 (11 files, +362/−76; no file added or removed, so the file list and the hits are unchanged) and the whole PR was re-judged against main where the delta touches a judgment. Items carried forward unchanged from the PASS record are marked carried.

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 main), and the 54 check-runs on 321991c7, read once none was pending: 43 success, 11 skipped, 0 failed. Nine of the ten required contexts are success (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI); Lint UI is skipped on this head exactly as it was on c37e52a6 (its path filter sees no ui/** change), and the other skips are the opt-in harnesses, the UI shards and the PR-event jobs that do not run on a push. The dogfood gate ran with its PostgreSQL cell. The dispatching seat's description of the delta was an input to judge, not a conclusion; each watch item it named is answered from the tree below.

① Derived judgments

  1. Ledger (two rows under @objectstack/objectql; ErrorCode, ApiErrorSchema.code, FlowInnerRefusal.code +2) — carried, the delta does not touch error-code-ledger.zod.ts or the generated reference pages. Right.
  2. @objectstack/spec/data +1 export CURRENCY_FRACTION_DIGITS — the export line is unchanged; 2fe4a2cd rewrites only its comment, which now says the table is the CLDR fraction-digit snapshot and names where it departs from ISO 4217 (IQD 0 vs 3; HUF, COP, IDR, PKR 0 vs 2). Right; S2 closed on the hits file.
  3. New public engine method. setHomeCurrencyResolver existed only on this PR's branch — never on main, never released — so its rename to setHomeLocalizationResolver(resolver) in 321991c7 moves no published surface: the net diff against main publishes setHomeLocalizationResolver alone, and the objectql changeset's Clause-② line now names exactly that method. No reference to the old name remains anywhere in the tree at this head. The resolver answers { currency?, timezone? } (HomeLocalization); that type and HomeLocalizationResolver are exported from home-amount-projection.ts, which packages/objectql/src/index.ts does not re-export, so the declared surface is the method and its parameter type as reached through it. Right. ValidateHost.previewHomeAmounts — carried.
  4. Write-path accept set (four refusals reaching only an object declaring currencyConfig.homeAmount; Clause-②: yes (widening)) — carried, and the delta adds no refusal: an unknown or unset timezone never refuses (see ③). Right.
  5. Every door is one seam (REST → protocol → engine.insert/update; import → insertManyData/createData/updateData, treatAsHistorical → preserveAudit; validate() previews the same image) — carried; the delta changes none of the call sites, and the timezone reaches all of them through the one homeAmountServing(context) each seam already builds. Right.
  6. Seam ordering (after hooks, both strips, re-default and the strict-drop refusal; before shape-only validation; priorRecord read unconditionally on the by-id branch) — carried; the three seam blocks are untouched by the delta. Right.
  7. The rate predicate and its organization — carried in code (unchanged find under { ...ctx, isSystem: true, tenantId: organization }), and the gap closed: 2fe4a2cd adds a dogfood pin on every cell that switches the engine to the isolated posture (setTenancyPostureProvider answering 'isolated', restored in finally), creates two organizations, writes SEK in organization A only and NOK in both at different rates, and shows A's SEK write converts at 1.5, each organization's NOK write at its own rate, and B's SEK write refuses ERR_EXCHANGE_RATE_MISSING — A's row is never read for B. Right; S1 closed.
  8. Arithmetic (exact scaled-decimal division, one half-away-from-zero rounding, bare-number store) — carried. Right.
  9. Physical columns (decimal(65, 30) per type; a field's scale never reaches DDL) — carried. Right.
  10. The document day of a datetime rateDate (ruling 6539345871, Q2 = B). planHomeAmounts reads the rateDate field's declared type into rateDateIsInstant ('datetime'); the spec admits only date and datetime there (object.zod.ts 1941). For an instant, projectHomeAmounts asks serving.timezone(write.organizationId) and documentDateOf(value, timezone) dates it with Intl.DateTimeFormat('en-CA', { timeZone, year, month, day }).formatToParts — the reading autonumber-format.ts's calendarParts already uses; UTC, an empty or unset zone short-circuits to the UTC day; a bare YYYY-MM-DD spelling is its own day whatever the zone. For a date rateDate the timezone is never asked (pinned: zero timezone reads), and the SQL driver presents a date column as a YYYY-MM-DD string on every dialect (sql-driver.ts 18021-18031), so that branch is host-timezone independent. The engine reads both settings in one resolver call per organization per write (localizations memo in homeAmountServing) and memoizeHomeAmountServing memoizes timezone beside the home currency. Pinned at four faces: unit (Asia/Shanghai, America/New_York, UTC, unset, unknown zone), engine (Shanghai 01:00 local takes the local day's rate; UTC and unset keep the UTC day; an update re-dates in the same zone; a date is never moved; one localization read per organization), the REST door on a booted kernel, and dogfood through the real datetime column on all three cells (insert, and an amount edit that re-reads the stored instant). Right.
  11. The timezone is the organization's setting, never the request's. The only reader is serving.timezone(organizationId); the engine answers it from homeLocalizationResolver(organizationId); ObjectQLPlugin answers that from settings.getMany('localization', ['currency', 'timezone'], { tenantId: organizationId }). Nothing in the delta reads context.timezone or any request-carried zone (grep of the delta: none); the organization is the row's own, as before (row.organization_id, else the writer's active organization, else the system insert's derived one). An offset inside a datetime string defines only the instant, which the organization's zone then dates — pinned (2026-03-15T17:00:00Z under Asia/Shanghai is the 16th). Right.
  12. The getMany fallback is safe. The real getMany (settings-service.ts 1775-1790) is all-or-nothing by contract: an unregistered namespace or any unregistered key throws UnknownNamespaceError / UnknownKeyError (code SETTINGS_UNKNOWN_NAMESPACE / SETTINGS_UNKNOWN_KEY) before anything is read, and its docblock names "read per key" as the degradation. The plugin catches exactly those two codes and falls to per-key get, each again mapping the two codes to undefined; every other error propagates, so a failing settings read is the write's failure, never a silently unset setting. The return shape is a plain record keyed by setting key, each a ResolvedSettingValue with .value, which is what settingText(many.currency) reads; an unset timezone resolves its manifest default UTC (source: 'default'), an unset currency has no default and stays undefined → ERR_HOME_CURRENCY_UNSET. A settings service without getMany is asked key by key (pinned on a get-only fake); the grouped path is pinned on a getMany fake asked once per organization. Right. (The fakes do not exercise the real service's all-or-nothing throw; that contract was read in source.)
  13. An invalid timezone never throws on the write path. calendarDayIn wraps the Intl construction and formatting in try/catch and falls to the UTC day; settingText turns a non-string setting into undefined. Pinned (Mars/Olympus dates as UTC). Reachability is narrow: localization.timezone declares valueDomain: 'iana_time_zone', whose membership is the same Intl probe (value-domain.zod.ts 40-44), so the settings door refuses a zone the host does not know; the fallback covers a value stored under another ICU build or before that door, and mirrors autonumber-format.ts (UTC on failure). Right, with one note in ③ (S6).
  14. Metadata-protocol: none; no governed path — carried. Right.

② Semver level

Two patch changesets, @objectstack/objectql and @objectstack/spec, each with Clause-②: yes (widening: …) and Metadata-protocol: none — carried and still right: the delta adds engine semantics (the document day of an unreleased key's datetime pointer) and renames a method that never shipped, no protocol kind changes, so none takes patch. The Clause-② line of the objectql changeset now names setHomeLocalizationResolver, matching the tree. The ⛔ pairing lines were reworded as ruling 6539345871 (Q1 = A′) ordered: both now say the change is the R2 core under ADR-0144 D9 and ships with R1 in 18.3.0 together with the currencyConfig.homeAmount key (#27191), sys_exchange_rate (#27185) and the localization.currency lock (#27198); the "waits for the customer's confirmation" sentence is gone, as the ruling's amendment of 6522614678 requires. The PR body's pairing line says the same and names R2.1 (#27193, #27190, #27196). Holds.

③ Boundary flags

Hand-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)

  1. Unset localization.currency (Open item 4) — carried: A, refuse ERR_HOME_CURRENCY_UNSET/400, judged right against D5's rejected fallbacks and D8 rule 5; the ADR amendment still owes the "only ledger additions" correction (S4).
  2. A datetime document date — closed by ruling 6539345871, letter B, and implemented at this head: its calendar day in the organization's localization.timezone, UTC when unset; ①.10-13 judge the implementation. The skills/docs steer toward a date field is withdrawn, as the ruling says.
  3. Should the spec refuse a rateField scale below 12? — carried: yes, as a domain:spec card; not blocking.

Flags from the tree

  • F1 (CLDR digits vs ISO 4217 minor units) — wording closed (S2): index.ts, both changesets, currency.mdx, the module docblock and the PR body now say CLDR fraction digits and name the departing codes. The choice itself (round to CLDR's practical digits; stored equals displayed) remains the maintainer's to confirm; the ADR amendment should record it (S4).
  • F2 (the record-currency backfill now converts) — carried; unchanged by the delta.
  • F3 (organization of a system insert) — carried; the timezone read follows the same organization.
  • F4 (rounding mode vocabulary) — carried into S4.
  • F5 — ablations. The seat reports four timezone ablations and one for the isolation pin; no fix-round report carries them on the card (the card's comments end at 6538855343), so they were not an input here. The pins themselves are strong enough without them: each timezone pin distinguishes the local day from the UTC day by a rate that differs (0.15 vs 0.1389), and the isolation pin by a refusal.

④ Design judgment

packages/spec/src/api/error-code-ledger.zod.ts — untouched by the delta; the PASS record's judgment is carried in full: both rows hold against ADR-0112's admission door, ADR-0144 D5 and Open items 2, 4 and 9, and their first consumer; the codes travel as registered members on the wire and as validate() row findings. No must-fix.

packages/spec/src/data/index.ts — the export is unchanged; the comment now describes the table as what it is (the CLDR snapshot whose key set is the iso_4217_currency domain, with its ISO 4217 departures named), so the one should-fix on this file is closed. Judged again against Open item 3 and the first consumer at this head: projectHomeAmounts still reads CURRENCY_FRACTION_DIGITS[home] after the membership check that is this table's key set, rounds once in exact arithmetic, and stores the rate unrounded; the delta's timezone change touches the date the rate is looked up on, not the rounding. Holds; no must-fix.

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, currency.mdx) are the lane seat's ACCEPT at TIER_DEFAULT; they were read here to judge the two hits files and the ruling's execution, not reviewed.

Implemented-by: claude/project-thread-4g7rek
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

席位记录 · epic PM epic:#8345 · 2026-10-31T05:40Z

Head: 321991c7d13e8cdf74efef63c1053c1be1e0c71b · Session session_01Utp6CbTZrFqRYNgjSaSbN5

维护者速读

  • 改了什么: 声明了 homeAmount 的记录在写入时(金额、币种或单据日期变化)查该组织该币种「单据日当天或之前最新」的汇率,home = 金额 ÷ 汇率,用精确十进制(BigInt)运算,按本位币 CLDR 小数位一次舍入(远离零),写本位币列和汇率快照;同币种取 1 不查表;缺汇率拒写 ERR_EXCHANGE_RATE_MISSING,未设本位币拒写 ERR_HOME_CURRENCY_UNSET(均登记进 ledger);datetime 单据日按组织 localization.timezone 取日历日(裁决 6539345871 B)。
  • 为什么改: 这是合并报表的核心;R2 核心随 R1 进 18.3.0(裁决 6539345871 A′)。
  • 风险与回滚: 只作用于声明了 homeAmount 的对象;不走浮点;汇率按组织隔离(三驱动双组织 pin);REST、导入、批量写都走同一引擎路径。回滚即撤 PR(chore: version packages #26517 发版前)。
  • 先看: packages/objectql/src/home-amount-projection.ts,再看 dogfood 的双组织与时区 pin。
  • 两分钟验证: objectql 全量 9811/9811;dogfood 24/24(含 Postgres);时区消融 4 组全红。
  • 需要你做的: 无。非 Tier H;席位翻 ready、入队。合并后 chore: version packages #26517 可刷新并合并。

席位意见:接受。契约复审 PASS 先在 c37e52a6,增量复审 PASS 在本 head(上一条评论,CONTRACT_REVIEW_TIER),无必改。

席位核对

ACCEPT · epic PM epic:#8345 · head 321991c7

ACCEPT → this comment.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 05:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit 6762dd9 Oct 31, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-4g7rek branch October 31, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

2 participants