Repository navigation
Commit 291bf44
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)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgJwvCPPb41CT1Drvj7TwxCo)_
Fixes #27198
Ruling-ref: 6522614678
#28096 merged (`73b72709`); `main` merged into this branch at `317b646d`
(no rebase); head `e4dc4e97`.
⛔ 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 types
`SettingsWritePredicate`, `SettingsWritePredicateInput`,
`SettingsWriteRefusal`, `SettingsDisplayOptions`, and an optional
display argument on `setMany` / `liveStatusOf`). The new refusal narrows
no released accept set: it fires only while a `sys_exchange_rate` row or
a `homeAmount` declaration exists, and neither is in any published
version (npm `@objectstack/platform-objects` is 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`,再改成别的币种或清空都会被 400 `SETTINGS_VALIDATION`
拒绝,拒绝落在 currency 字段上并点名使用者(N 个对象、M 条汇率)。Setup → Localization
页顶部用横幅说明锁定及使用者,英、简中、日、西四语随请求语言。
- **为什么改:** 改本位币等于给所有已换算、已存储的本位币金额换标签,而没有任何东西会重算它们(ADR-0144 D4)。
- **风险与回滚:** 新机制「settings
写侧谓词」只在写入真正改变该键时才问;重存当前值永不被拒,同页其他设置照常保存。未设置时首次选择币种不拦(Open item 4
本卡这一半)。锁不在启动时记录,每次保存和读页都现查,最后一条汇率删掉、最后一个 `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。
- **需要你做的:** 无需批准(不碰受管路径)。请留意:18.3.0 的 Version Packages(#26517)已收进汇率表
#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-settings` had 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.
- `setMany` asks a key's predicate only when the write CHANGES the key:
the submitted value (`null` / `undefined` read 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.
- It is asked after `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
as `SettingsValidationError`, one `FieldError` per key: `invalid_value`
(ADR-0114's slot for a reason no other member names), the predicate's
sentence as `message`, its facts as `constraint`.
- The predicate gets `{ namespace, key, next, current, organizationId,
ctx, locale }`. `organizationId` is 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).
- The live-status provider input gains `organizationId()` (resolved only
when called) and `locale`.
- `SettingsDisplayOptions { locale }` travels BESIDE the caller's
context, as a new optional last argument of `setMany` and
`liveStatusOf`, never inside `SettingsContext`: the door pins hold that
the service sees the very context the resolver returned, and a display
language is not identity. `GET` and `PUT /api/settings/:namespace` pass
the request's language (`Accept-Language` via
`preferredLocaleFromHeader`, else `?locale`), the same reading REST's
metadata door takes.
**The home-currency lock** (`localization-currency-lock.ts`), registered
by `SettingsServicePlugin` on `kernel:ready` right after the engine
binds:
- **Used** = the organization's `sys_exchange_rate` rows (a system
`count` with the organization filter explicit; a composition that does
not register the table holds none), or any registered object whose
metadata declares `currencyConfig.homeAmount` on a field
(`engine.registry.getAllObjects()`). A record-mode amount without
`homeAmount` does not count.
- **Predicate** on `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.
- **Unset currency**: the first value passes. Nothing has been converted
into a currency that does not exist, and the lock never invents a
default or blocks choosing one (ADR-0144 Open item 4, this card's half;
#27192 owns the converting-write half).
- **Setup copy**: the Localization page's live-status banner. Locked:
"Default currency locked: USD — In use by 1 object (Invoice) and 1
exchange rate. Converted amounts are stored in this currency, so it can
no longer be changed." (`info`). Unset but needed: "No default currency
set — …Choose it below; once saved, it cannot be changed." (`warning`).
Nothing while nothing uses it.
- **Locales**: `CURRENCY_LOCK_COPY` carries en, zh-CN, ja-JP and es-ES,
the locales `settingsBuiltinTranslations` ships; 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.
- Nothing is recorded at boot: every save and every page read asks the
engine, so the lock lifts by itself.
**Changeset** `.changeset/27198-home-currency-lock.md`:
`@objectstack/service-settings` patch (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
- New `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.
- New `localization-currency-lock.test.ts` (24): saves and changes
before the first rate row and before any `homeAmount`; a record-mode
amount without `homeAmount` and another organization's rows do not lock;
refused after the first rate row and after a `homeAmount` declaration,
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; `readCurrencyUsage` scopes by
organization under a system read; copy covers exactly the bundle's
locales; the refusal in zh-CN, `ja`, `es-MX` and `fr-FR` (English); the
page states nothing while unused, the locked banner in en and zh-CN, the
unset warning in ja-JP; `PUT` answers 400 `SETTINGS_VALIDATION` in
es-ES; no `localization` namespace ⇒ nothing registered.
- Ablations (each restored to HEAD after): the `setMany` call removed →
16 of 31 red; `isUsed` → `false` → 13 red; the `homeAmount` detector
blinded → 8 red; the organization filter dropped → 2 red; the change
gate dropped → 2 red.
- Package: `@objectstack/service-settings` 67 files / 1107 tests passed;
`tsc --noEmit` exit 0; eslint on the touched files exit 0. Re-run after
the main merge (install, closure rebuilt) on `e4dc4e97`: 67 files / 1107
passed, `tsc` exit 0.
- Gates: `dispatch-gates --commands` on `5e49fa73` (9 paths against
#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's `dist/`, 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
- **The arm, and the one case that flips it.** Version Packages #26517
(18.3.0) already consumed `.changeset/27185-exchange-rate-table.md`
(read on `changeset-release/main` at `1f5d5a8e`, 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 become `Clause-②: yes (narrowing)` with an ADR-0087 disposition
before R2 ships. Today it narrows nothing released.
- **Known trade-off: `homeAmount` is deployment metadata, so it locks
every organization.** The card's predicate is kept as written: "any
object whose metadata declares `homeAmount`". 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.
- **The namespace reset is refused while the currency is used.** A reset
clears `currency`, and a clear is a change. The other Localization keys
can still be cleared one by one.
- **In-process writers are unaffected in practice.** plugin-auth's
organization-default write and the dev seed write a currency only where
none is set on any rung, which the lock always lets through. They call
`set` without a display language, so a refusal they ever met would read
in English.
- **The Localization page's live-status slot is taken.**
`registerLiveStatus` keeps the last registration per namespace; nothing
else registers one for `localization` today.
- **Not touched:** `packages/objectql` write paths, the spec,
`skills/**`, `docs/adr/**`, `.claude/**`, and the console under `ui/`
(the banner and the field error render through the existing paths:
`info_banner` at the page head, `details.fields` under the input).
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; eslint passes on every touched file (the
whole-repo `pnpm lint` is left to CI).
- [x] 4. One issue, one branch, one pull request (started on #28096's
head; `main` merged in after #28096 landed, no rebase).
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent de2a2e4 commit 291bf44
9 files changed
Lines changed: 943 additions & 3 deletions
File tree
- .changeset
- packages/services/service-settings/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
40 | 45 | | |
41 | 46 | | |
42 | 47 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| |||
200 | 201 | | |
201 | 202 | | |
202 | 203 | | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
203 | 219 | | |
204 | 220 | | |
205 | 221 | | |
| |||
280 | 296 | | |
281 | 297 | | |
282 | 298 | | |
283 | | - | |
| 299 | + | |
284 | 300 | | |
285 | 301 | | |
286 | 302 | | |
| |||
335 | 351 | | |
336 | 352 | | |
337 | 353 | | |
338 | | - | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
339 | 361 | | |
340 | 362 | | |
341 | 363 | | |
| |||
0 commit comments