Repository navigation
feat(service-settings): the default currency locks once an exchange rate or a home-currency amount uses it, and Setup names what uses it (multi-currency M1-3) - #28297
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
Claude-Session: https://claude.ai/code/session_01Utp6CbTZrFqRYNgjSaSbN5 Co-authored-by: Claude <noreply@anthropic.com>
… lock Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude <noreply@anthropic.com>
…ettings context, not in it Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 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 02c3546b6b9c224e9d1907c4cec80178c95a051d && git checkout 02c3546b6b9c224e9d1907c4cec80178c95a051d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53749673ba7a0005d09e8a79544da10d5faa10bc e4dc4e97fed4d6f1f988961a789e4275168e5fea && git checkout -B drift-repro 53749673ba7a0005d09e8a79544da10d5faa10bc && git merge --no-ff e4dc4e97fed4d6f1f988961a789e4275168e5fea
node scripts/docs-audit/affected-docs.mjs --json 53749673ba7a0005d09e8a79544da10d5faa10bc
|
…e file is main's again Claude-Session: https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te Co-authored-by: Claude <noreply@anthropic.com>
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 Contract reviewServed-tier: Inputs read: card #27198 (body, claim 6538090164, os-dev-report 6538581927), the body and file list of PR #28297, and the net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 席位记录 · epic PM
|
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5
Requested by Bill · project thread
Fixes #27198
Ruling-ref: 6522614678
#28096 merged (
73b72709);mainmerged into this branch at317b646d(no rebase); heade4dc4e97.⛔ not released alone — R2: one Version Packages release with #27185, #28096 (#27191), #27192, #27190, #27193, #27196 (ADR-0144 D9).
Clause-②: yes (widening) — new exports on
@objectstack/service-settings(SettingsService.registerWritePredicate, the typesSettingsWritePredicate,SettingsWritePredicateInput,SettingsWriteRefusal,SettingsDisplayOptions, and an optional display argument onsetMany/liveStatusOf). The new refusal narrows no released accept set: it fires only while asys_exchange_raterow or ahomeAmountdeclaration exists, and neither is in any published version (npm@objectstack/platform-objectsis 18.2.0, without the rate table;homeAmount, #28096, is merged but unreleased). See Acceptance notes for the one case that flips the arm to(narrowing).Metadata-protocol: none
维护者速读
localization.currency)一旦被使用就锁定:本组织有任何一条汇率记录,或任何对象在字段上声明了currencyConfig.homeAmount,再改成别的币种或清空都会被 400SETTINGS_VALIDATION拒绝,拒绝落在 currency 字段上并点名使用者(N 个对象、M 条汇率)。Setup → Localization 页顶部用横幅说明锁定及使用者,英、简中、日、西四语随请求语言。homeAmount声明移除后自动解除。回滚即撤 PR。packages/services/service-settings/src/localization-currency-lock.ts(谓词、横幅、四语文案),再看settings-service.ts的refuseByWritePredicates与setMany里调用它的一行。pnpm --filter @objectstack/service-settings exec vitest run src/localization-currency-lock.test.ts src/settings-write-predicate.test.ts,预期 31 条全绿:首条汇率前、首个homeAmount前可改;之后各自被拒并点名;四语文案;横幅;PUT 路由 400。sys_exchange_rate{ currency, rate, effective_date, source } — tenant-scoped, admin-only, audited; Setup list view (multi-currency M1-1, epic #8345; released with M2) #27185 的 changeset;若它先于 R2 发布,本 PR 的拒绝就会收窄一个已发布状态,Clause-②须改为(narrowing)并补 ADR-0087 处置。What changes
New mechanism: the settings write predicate (
SettingsService.registerWritePredicate(namespace, key, predicate), ADR-0144 D4). The card names the gap:service-settingshad no write-side rule that can refuse a value change on engine state. The declared constraints (options,pattern,valueDomain, the value window) judge a value by itself; a predicate judges whether the value may change at all, given what the rest of the system holds.setManyasks a key's predicate only when the write CHANGES the key: the submitted value (null/undefinedread as a clear) against the value the caller's cascade resolves today. Re-saving the value in force is never refused, so a form that posts back what it read, or the rest of the page, still saves.validatePatch(a malformed value hears about its own fault first) and before the crypto pass and every write, so a refused batch writes nothing. All refusals of a batch come back at once asSettingsValidationError, oneFieldErrorper key:invalid_value(ADR-0114's slot for a reason no other member names), the predicate's sentence asmessage, its facts asconstraint.{ namespace, key, next, current, organizationId, ctx, locale }.organizationIdis the organization the row lands in, the same owner the row write computes. A predicate that throws fails the write: the state it guards could not be read, so the change is not made (500 through the route's existing fault arm).organizationId()(resolved only when called) andlocale.SettingsDisplayOptions { locale }travels BESIDE the caller's context, as a new optional last argument ofsetManyandliveStatusOf, never insideSettingsContext: the door pins hold that the service sees the very context the resolver returned, and a display language is not identity.GETandPUT /api/settings/:namespacepass the request's language (Accept-LanguageviapreferredLocaleFromHeader, else?locale), the same reading REST's metadata door takes.The home-currency lock (
localization-currency-lock.ts), registered bySettingsServicePluginonkernel:readyright after the engine binds:sys_exchange_raterows (a systemcountwith the organization filter explicit; a composition that does not register the table holds none), or any registered object whose metadata declarescurrencyConfig.homeAmounton a field (engine.registry.getAllObjects()). A record-mode amount withouthomeAmountdoes not count.localization.currency: a change to a SET currency (another code, or a clear) is refused while used. The message names what uses it: "The default currency is locked at USD because it is in use by 2 objects (Invoice, Order) and 14 exchange rates. Changing it would relabel every amount already converted into it. It can change again only once no exchange rate is recorded and no object declares a home-currency amount."constraint: { lockedBy: 'in_use', objects: [names], exchangeRates: n }. Up to five object labels are listed, then an ellipsis.info). Unset but needed: "No default currency set — …Choose it below; once saved, it cannot be changed." (warning). Nothing while nothing uses it.CURRENCY_LOCK_COPYcarries en, zh-CN, ja-JP and es-ES, the localessettingsBuiltinTranslationsships; a test holds the two key sets equal. The copy lives beside the lock and not in the settings bundle because it is computed per request with counts and names, and the console's banner lookup would replace a computed live-status text with a fixed one (SETTINGS_LIVE_STATUS_BANNER_ID's note). Locale pick: exact, then by language (ja→ ja-JP,es-MX→ es-ES), else English.Changeset
.changeset/27198-home-currency-lock.md:@objectstack/service-settingspatch (a non-protocol change;Metadata-protocol: none), with both declaration lines and the R2 pairing sentence.Why
ADR-0144 D4 (ruled letter A): the home currency is
localization.currency, "locked once a rate row or a home-amount declaration exists". Ruling 6522614678 on #8345 (letter A): build R2 now, release R2 after the customer confirms the consolidation terms.Verification
settings-write-predicate.test.ts(7): asked with current / next / organization; not asked on a re-save; refuses the whole batch before any write with the refusal at the key; a clear is a change; declared constraints first (ZZZ→value_domain, predicate not asked); a throwing predicate fails the write; registration refused for an unknown namespace or key.localization-currency-lock.test.ts(24): saves and changes before the first rate row and before anyhomeAmount; a record-mode amount withouthomeAmountand another organization's rows do not lock; refused after the first rate row and after ahomeAmountdeclaration, each naming its user (code, path, label,constraint, the message's first sentence); both named together; a clear and the namespace reset refused; a re-save and its batch pass; the first value passes while unset; the lock lifts once both are gone; no rate table ⇒ no count; a failing count fails the write;readCurrencyUsagescopes by organization under a system read; copy covers exactly the bundle's locales; the refusal in zh-CN,ja,es-MXandfr-FR(English); the page states nothing while unused, the locked banner in en and zh-CN, the unset warning in ja-JP;PUTanswers 400SETTINGS_VALIDATIONin es-ES; nolocalizationnamespace ⇒ nothing registered.setManycall removed → 16 of 31 red;isUsed→false→ 13 red; thehomeAmountdetector blinded → 8 red; the organization filter dropped → 2 red; the change gate dropped → 2 red.@objectstack/service-settings67 files / 1107 tests passed;tsc --noEmitexit 0; eslint on the touched files exit 0. Re-run after the main merge (install, closure rebuilt) one4dc4e97: 67 files / 1107 passed,tscexit 0.dispatch-gates --commandson5e49fa73(9 paths against feat(spec):currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096's head; the main merge leaves the delta the same 9 files, byte for byte): 63 commands, each exit code captured before any pipe; 62 exit 0, 1 exit 3 (check:dual-build-cjs-loads, PREREQUISITE NOT MET: it reads every package'sdist/, and only this package's closure is built here; CI's Build Core measures it).dispatch-gates --ran: 63 accounted, 62 run, 1 NOT-MEASURED, exit 0.Acceptance notes
.changeset/27185-exchange-rate-table.md(read onchangeset-release/mainat1f5d5a8e, 2026-10-31 00:18Z). If 18.3.0 is released before R2, organizations can hold rate rows and change their currency freely in 18.3.x, and this PR then refuses that change: a narrowing of a released accept set. The declaration would have to becomeClause-②: yes (narrowing)with an ADR-0087 disposition before R2 ships. Today it narrows nothing released.homeAmountis deployment metadata, so it locks every organization. The card's predicate is kept as written: "any object whose metadata declareshomeAmount". Objects are shared by every organization of a deployment, so once one is declared, every organization whose currency is set is locked, including an organization that holds no converted rows yet. A new organization can still pick its first currency. Loosening a lock later is easy and adding one after the fact is not, so this can be relaxed per organization later (an organization with no converted rows) if needed.currency, and a clear is a change. The other Localization keys can still be cleared one by one.setwithout a display language, so a refusal they ever met would read in English.registerLiveStatuskeeps the last registration per namespace; nothing else registers one forlocalizationtoday.packages/objectqlwrite paths, the spec,skills/**,docs/adr/**,.claude/**, and the console underui/(the banner and the field error render through the existing paths:info_bannerat the page head,details.fieldsunder the input).Human Minimum
packages/specholds no runtime logic.pnpm lintis left to CI).currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096's head;mainmerged in after feat(spec):currencyConfig.homeAmount— a record-mode amount declares its stored home-currency projection by pointer (multi-currency M2-1) #28096 landed, no rebase).packages/specnot touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te