Skip to content

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

Merged
objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/project-thread-1bmr1f
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 14 commits into
mainfrom
claude/project-thread-1bmr1f

Conversation

@os-bill

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

Copy link
Copy Markdown
Collaborator

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

Requested by Bill · project thread

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(chore: version packages #26517)已收进汇率表 platform-objects: 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-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; objectql: write-time home projection — rate lookup by document date, decimal arithmetic, minor-unit rounding, missing rate refuses, computed fields read-only (multi-currency M2-2, epic #8345) #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 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'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 chore: 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

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te

claude added 12 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
…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>
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings, touching 46 documentable anchor(s).

22 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 53749673ba7a0005d09e8a79544da10d5faa10bc.

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

What this run could not see
  • 11 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 — 9 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 53749673ba7a0005d09e8a79544da10d5faa10bc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 02c3546b6b9c224e9d1907c4cec80178c95a051d — the merge of head e4dc4e97fed4d6f1f988961a789e4275168e5fea into base 53749673ba7a0005d09e8a79544da10d5faa10bc, 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 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

⚠️ 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 53749673ba7a0005d09e8a79544da10d5faa10bc → 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 e4dc4e9 · 2026-10-31T04:06Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

Contract review

Served-tier: TIER_DEFAULT
Head-sha: e4dc4e97fed4d6f1f988961a789e4275168e5fea
Local-runs: none

Inputs read: card #27198 (body, claim 6538090164, os-dev-report 6538581927), the body and file list of PR #28297, and the net diff against main (merge-base 53749673, 9 files). The check-runs on this head: 37 success, 15 skipped, 0 failed. Read-only reads beside those: the files the diff cites (organization-reach.ts, localization.manifest.ts, plugin-auth's organization-default-currency.ts, the engine's count, sys-exchange-rate.object.ts, ADR-0144 D4 and D5), the npm registry versions, and the changeset-release/main branch at 1f5d5a8e.

① Derived judgments

  • Accept set: PUT /api/settings/localization (and in-process set / setMany) refuses a currency change while the currency is used. Right. A change to a SET localization.currency (to another code, or a clear) gets 400 SETTINGS_VALIDATION, invalid_value at currency, with constraint: { lockedBy: 'in_use', objects, exchangeRates }. It is refused while the organization holds a sys_exchange_rate row, or while any registered object declares currencyConfig.homeAmount. This matches the card's task line and ADR-0144 D4. Placement is right. The check runs after validatePatch and the upper-rung lock check, and before the crypto pass and the persist loop, so a refused batch writes nothing. Re-saving the value in force is never asked (stableStringify(next) === stableStringify(current)). An unset currency takes its first value (ADR-0144 Open item 4, this card's half). The manifest declares no default for currency, so for an organization with no row, "unset" is a real state.
  • Organization scoping. Right. currency is a tenant key, so setMany has already refused an unowned reach (organizationlessTenantKeyError). The predicate therefore sees either own, where the count filters organization_id explicitly under a system read, or no-organization-object, where it counts every row and those rows carry no organization. Another organization's rows cannot lock this one (pinned). The live-status provider resolves the organization with the same organizationReachOf and states nothing for unowned.
  • Accept set: resetNamespace('localization') is refused while the currency is used. Right, and stated. A clear is a change. No HTTP route calls resetNamespace. The other keys can still be cleared one by one.
  • In-process writers are unaffected. Right. plugin-auth's seedOrganizationCurrency writes only when source === 'default', which means the currency is unset, so the lock lets it through.
  • Public surface: SettingsService.registerWritePredicate(namespace, key, predicate), additive. Right. It refuses an unknown namespace or key, and the last registration wins, like registerAction. A throwing predicate fails the write (500 through the existing fault arm). That is the right direction for a guard whose state could not be read.
  • Public surface: an optional trailing display: SettingsDisplayOptions on setMany and liveStatusOf, additive. Right. It travels beside SettingsContext and never inside it, so the context-identity door pins hold. The GET and PUT routes fill it from Accept-Language, then ?locale (preferredLocaleFromHeader, the metadata door's reading). It selects prose only and authorizes nothing.
  • Public surface: new exported types SettingsWritePredicate, SettingsWritePredicateInput, SettingsWriteRefusal and SettingsDisplayOptions. Right. The lock module's own exports (CURRENCY_LOCK_COPY, readCurrencyUsage, registerLocalizationCurrencyLock) are not re-exported from index.ts, so they add no surface.
  • Public surface: the input of SettingsLiveStatusProvider gains organizationId() and locale. Right in kind, unnamed in the changeset (S1). The change is additive for implementers: the only caller is liveStatusOf, and the one other provider (plugin-email's mail) ignores its input. The PR body's What-changes section names it, but the changeset, which becomes the CHANGELOG, does not.
  • The Localization page's live-status slot. Right. registerLiveStatus keeps one provider per namespace, and nothing else registers one for localization. The banner reads info when locked, warning when unset but needed, and states nothing while the currency is unused. Its copy covers exactly the four locales of the settings bundle (pinned).
  • Face and tier. Right. The net diff is 9 files under packages/services/service-settings/src/** and .changeset/: no published schema and no governed path. The head commit e4dc4e97 edits skills/objectstack-data/rules/field-types.md, but only to undo the paragraph the main merge 317b646d doubled. That file is byte-equal to merge-base 53749673. So this is the declaration face, and TIER_DEFAULT is exact.
  • Not a finding: a check-then-write race. The count and the row write are not one transaction, so a rate row inserted at the same instant as a currency change can slip between them. This is inherent to a predicate over another object's state, and is acceptable for M1-3. The converse door (a converting write while the currency is unset) belongs to objectql: write-time home projection — rate lookup by document date, decimal arithmetic, minor-unit rounding, missing rate refuses, computed fields read-only (multi-currency M2-2, epic #8345) #27192.

② Semver level

  • Changeset .changeset/27198-home-currency-lock.md: '@objectstack/service-settings': patch. Right. AGENTS.md's changeset rule says a non-protocol change, widening or breaking, ships patch.
  • Metadata-protocol: none. Right. No key, accepted value or metadata type that an author writes changes. The refusal falls on a settings value, not on metadata.
  • Clause-②: yes (widening). Right today; see ③ flag 1 for the condition that flips it. The PR body and the changeset carry the same two lines. Widening needs no BREAKING banner and no ADR-0087 disposition, and none is carried.

③ Boundary flags

  • Flag 1 / open_questions[0], the arm: (widening) is right today, on one condition (C1).
    • Why it is right today: the released accept set is 18.2.0 (npm @objectstack/platform-objects and @objectstack/service-settings are both 18.2.0). The rate table's commit 194e66c0 is not in 18.2.0, and .changeset/27185-exchange-rate-table.md is still pending on main. homeAmount cannot exist in a released tree, because 18.2.0's CurrencyConfigSchema is a strictObject and refuses the key. So no released state can hold a "used" currency, and the new refusal narrows nothing released.
    • The flip turns on one ordering: the release of the rate table against the release of THIS changeset. R2 as a whole does not decide it. If this PR merges while Version Packages chore: version packages #26517 is unmerged, the regenerated release carries the lock in the same version as the rate table, and the arm stays (widening).
    • C1: chore: version packages #26517 is open at 1f5d5a8e, and its 18.3.0 platform-objects CHANGELOG already lists 194e66c0. If it merges first, the declarations must become Clause-②: yes (narrowing), with the BREAKING banner and the ADR-0087 marker in the changeset. That also moves the review to the narrowing face at CONTRACT_REVIEW_TIER, and this record would no longer govern. Before arming, re-read chore: version packages #26517's state.
    • Escalated: the rate table rides 18.3.0 ahead of its R2 pairing. That is the dev's out-of-scope release-pairing finding, and it belongs to the epic PM and the maintainer (Version Packages is the maintainer's hand). It is outside this PR's reach.
  • Flag 2 / open_questions[1], the deployment-wide homeAmount predicate: accepted as the stated trade-off.
    • It is the card's letter ("any object whose metadata declares homeAmount") and ADR-0144 D4's ("locked once a rate row or a home-amount declaration exists").
    • Objects are deployment metadata (registry.getAllObjects()), so any declaration locks every organization whose currency is set. That errs toward locking, which is the safe side of D4's harm (relabelling converted amounts).
    • Every organization can still choose its first currency, and the lock lifts by itself once the declaration is removed. The refusal and the banner name the declaring object, so the cause is visible. Loosening the lock later is a widening.
    • A per-organization predicate would need a cross-object read of converted rows. That is a design change beyond this card. If a customer needs it, it becomes a follow-up card under [Epic] multi-currency (shape C′): record currency, dated exchange rates, write-time home-currency projection — 17 children, R1/R2/R3 release pairing (ruling 6465431966) #8345. No escalation is needed.
  • Deviations, read and accepted. The PR body opens with the seat signature line (precedent feat(objectql,cli,metadata-protocol): backfill the currency code of rows stored before an amount switched to record mode, and acknowledge the switch with a 409 at the save #28128). WIP commit 1fb66e7c carries the epic PM's session in its Claude-Session trailer. The branch cannot be force-pushed, and the trailer misattributes nothing in the net diff. No action.
  • Should-fix S1 (non-blocking): add one sentence to the changeset naming the SettingsLiveStatusProvider input's new organizationId() and locale. The CHANGELOG then covers the whole public delta of @objectstack/service-settings.
  • Must-fix: none.

Implemented-by: claude/project-thread-1bmr1f
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-31T04:15Z

Head: e4dc4e97fed4d6f1f988961a789e4275168e5fea · Session session_01Utp6CbTZrFqRYNgjSaSbN5

维护者速读

  • 改了什么: 组织一旦有 sys_exchange_rate 汇率行,或任何对象声明了 homeAmount,localization.currency(本位币)就不能再改;拒绝时点名「被 N 个对象 / M 条汇率使用」。新增机制:settings 写侧谓词(registerWritePredicate)。
  • 为什么改: 改本位币会让所有已存的本位币金额失效(ADR-0144 D4)。
  • 风险与回滚: 只在已有汇率行或 homeAmount 声明时才拒绝;首次设置币种不受影响;去掉声明、删光汇率行后锁自动解除。已知取舍:部署级 homeAmount 会锁住所有已设币种的组织,含尚无换算行的,以后可按组织放宽。回滚即撤 PR。
  • 先看: packages/services/service-settings/src/localization-currency-lock.ts,再看 settings-write-predicate.test.ts。
  • 两分钟验证: 包内 1107/1107;31 条新测试;5 路消融全红。
  • 需要你做的: 无。非 Tier H,席位翻 ready、入队。注意:18.3.0(chore: version packages #26517)是否带出 R2 半成品另有决策线程。

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

席位核对

ACCEPT · epic PM epic:#8345 · head e4dc4e97

ACCEPT → this comment.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 04:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 31, 2026 04:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants