Skip to content

Commit 1fb66e7

Browse files
committed
wip(service-settings): write predicate and the home-currency lock
Claude-Session: https://claude.ai/code/session_01Utp6CbTZrFqRYNgjSaSbN5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf13692 commit 1fb66e7

6 files changed

Lines changed: 471 additions & 4 deletions

File tree

‎packages/services/service-settings/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export {
3737
type SettingsActionHandler,
3838
type SettingsLiveStatus,
3939
type SettingsLiveStatusProvider,
40+
// ADR-0144 D4 — the write-side rule a package registers on one key.
41+
type SettingsWritePredicate,
42+
type SettingsWritePredicateInput,
43+
type SettingsWriteRefusal,
4044
SETTINGS_LIVE_STATUS_BANNER_ID,
4145
type SettingsAuditSink,
4246
type SettingsContext,
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The home-currency lock (ADR-0144 D4, multi-currency M1-3).
5+
*
6+
* The organization's home currency is `localization.currency`, and it is
7+
* locked "once a rate row or a home-amount declaration exists". Changing it
8+
* after that relabels every amount already converted into it: a stored home
9+
* amount and its rate were computed against the old code, and nothing would
10+
* recompute them. So once the currency is used:
11+
*
12+
* - **The write is refused.** {@link localizationCurrencyWritePredicate} is the
13+
* key's settings write predicate. A change to a set value — another code,
14+
* or a clear — is refused while any `sys_exchange_rate` row of the
15+
* organization exists, or any registered object declares
16+
* `currencyConfig.homeAmount` on a field. The refusal names both.
17+
* - **The page says so.** {@link localizationCurrencyLiveStatus} heads the
18+
* Localization page with what uses the currency, in the caller's language.
19+
*
20+
* Setting the currency while it is UNSET is not a change of home currency:
21+
* nothing has been converted into a currency that does not exist, so that
22+
* write passes, and the page asks for it (ADR-0144 Open item 4, this card's
23+
* half: the lock never invents a default, and never blocks choosing one).
24+
*
25+
* "Used" reads the engine at each ask, never a flag recorded at boot: the lock
26+
* lifts by itself once the last rate row is deleted and the last `homeAmount`
27+
* declaration is removed.
28+
*/
29+
30+
import { resolveLocale } from '@objectstack/core';
31+
import type {
32+
SettingsLiveStatus,
33+
SettingsLiveStatusProvider,
34+
SettingsWritePredicate,
35+
} from './settings-service.types.js';
36+
37+
/** The rate table of multi-currency M1-1, owned by `@objectstack/platform-objects`. */
38+
export const EXCHANGE_RATE_OBJECT = 'sys_exchange_rate';
39+
40+
/** The engine surface the lock reads: a count, and the object registry. */
41+
export interface CurrencyUsageEngine {
42+
count(
43+
objectName: string,
44+
query?: { where?: Record<string, unknown>; context?: Record<string, unknown> },
45+
): Promise<number>;
46+
registry?: {
47+
getAllObjects?(): ReadonlyArray<unknown>;
48+
getObject?(name: string): unknown;
49+
};
50+
}
51+
52+
/** What uses the home currency of one organization. */
53+
export interface CurrencyUsage {
54+
/** Objects declaring `currencyConfig.homeAmount` on a field, in registry order. */
55+
objects: Array<{ name: string; label: string }>;
56+
/** The organization's `sys_exchange_rate` rows. */
57+
exchangeRates: number;
58+
}
59+
60+
const SYSTEM_CONTEXT = Object.freeze({ isSystem: true as const });
61+
62+
type AnyRecord = Record<string, unknown>;
63+
const isRecord = (v: unknown): v is AnyRecord => typeof v === 'object' && v !== null && !Array.isArray(v);
64+
65+
/** Does any field of this object declare a home amount? */
66+
function declaresHomeAmount(schema: AnyRecord): boolean {
67+
const fields = isRecord(schema.fields) ? schema.fields : {};
68+
return Object.values(fields).some(
69+
(def) => isRecord(def) && isRecord(def.currencyConfig) && isRecord(def.currencyConfig.homeAmount),
70+
);
71+
}
72+
73+
/**
74+
* Read what uses the home currency. `organizationId` is the organization whose
75+
* rate rows count: an id, or `null` in a composition with no organization
76+
* object, whose rows carry none. Objects are deployment metadata, so every
77+
* organization sees the same ones.
78+
*
79+
* A composition that does not register `sys_exchange_rate` holds no rate rows.
80+
* Any other failure of the count propagates: the lock does not guess.
81+
*/
82+
export async function readCurrencyUsage(
83+
engine: CurrencyUsageEngine,
84+
organizationId: string | null,
85+
): Promise<CurrencyUsage> {
86+
const objects: CurrencyUsage['objects'] = [];
87+
for (const schema of engine.registry?.getAllObjects?.() ?? []) {
88+
if (!isRecord(schema) || typeof schema.name !== 'string' || !declaresHomeAmount(schema)) continue;
89+
objects.push({ name: schema.name, label: typeof schema.label === 'string' ? schema.label : schema.name });
90+
}
91+
const registered = typeof engine.registry?.getObject === 'function'
92+
? Boolean(engine.registry.getObject(EXCHANGE_RATE_OBJECT))
93+
: true;
94+
const exchangeRates = registered
95+
? await engine.count(EXCHANGE_RATE_OBJECT, {
96+
// The organization filter is explicit: a system read carries no
97+
// organization, so nothing else scopes it.
98+
...(organizationId !== null ? { where: { organization_id: organizationId } } : {}),
99+
context: SYSTEM_CONTEXT,
100+
})
101+
: 0;
102+
return { objects, exchangeRates };
103+
}
104+
105+
const isUsed = (usage: CurrencyUsage): boolean => usage.objects.length > 0 || usage.exchangeRates > 0;
106+
107+
const isUnset = (value: unknown): boolean => value === null || value === undefined || value === '';
108+
109+
// ── Copy ─────────────────────────────────────────────────────────────────
110+
111+
/** How many object labels a sentence lists before it elides the rest. */
112+
const LISTED_OBJECTS = 5;
113+
114+
interface CurrencyLockCopy {
115+
objects(count: number, labels: string): string;
116+
rates(count: number): string;
117+
and: string;
118+
listSeparator: string;
119+
refusal(current: string, usage: string): string;
120+
lockedLabel(current: string): string;
121+
lockedText(usage: string): string;
122+
unsetLabel: string;
123+
unsetText(usage: string): string;
124+
}
125+
126+
/**
127+
* The lock's copy, one entry per locale the settings bundle ships
128+
* (`settingsBuiltinTranslations`). Kept here and not in that bundle: the
129+
* sentences are computed per request with counts and names, and the console's
130+
* banner lookup would replace a computed live-status text with a fixed one
131+
* (`SETTINGS_LIVE_STATUS_BANNER_ID`).
132+
*/
133+
export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', CurrencyLockCopy> = {
134+
en: {
135+
objects: (n, labels) => `${n} ${n === 1 ? 'object' : 'objects'} (${labels})`,
136+
rates: (n) => `${n} ${n === 1 ? 'exchange rate' : 'exchange rates'}`,
137+
and: ' and ',
138+
listSeparator: ', ',
139+
refusal: (current, usage) =>
140+
`The default currency is locked at ${current} because it is in use by ${usage}. ` +
141+
'Changing it would relabel every amount already converted into it. It can change again only once ' +
142+
'no exchange rate is recorded and no object declares a home-currency amount.',
143+
lockedLabel: (current) => `Default currency locked: ${current}`,
144+
lockedText: (usage) =>
145+
`In use by ${usage}. Converted amounts are stored in this currency, so it can no longer be changed.`,
146+
unsetLabel: 'No default currency set',
147+
unsetText: (usage) =>
148+
'Exchange rates and home-currency amounts need a default currency, and none is set. ' +
149+
`In use: ${usage}. Choose it below; once saved, it cannot be changed.`,
150+
},
151+
'zh-CN': {
152+
objects: (n, labels) => `${n} 个对象(${labels})`,
153+
rates: (n) => `${n} 条汇率`,
154+
and: '和',
155+
listSeparator: '、',
156+
refusal: (current, usage) =>
157+
`默认币种已锁定为 ${current}:它已被${usage}使用。修改它会给所有已换算成该币种的金额换上错误的币种。` +
158+
'只有在不再有任何汇率记录、也没有任何对象声明本位币金额之后,才能修改。',
159+
lockedLabel: (current) => `默认币种已锁定:${current}`,
160+
lockedText: (usage) => `已被${usage}使用。换算后的金额都以该币种存储,因此不能再修改。`,
161+
unsetLabel: '尚未设置默认币种',
162+
unsetText: (usage) =>
163+
`汇率和本位币金额都需要默认币种,但目前尚未设置。使用情况:${usage}。请在下方选择;保存后将无法修改。`,
164+
},
165+
'ja-JP': {
166+
objects: (n, labels) => `${n} 件のオブジェクト(${labels})`,
167+
rates: (n) => `${n} 件の為替レート`,
168+
and: 'と',
169+
listSeparator: '、',
170+
refusal: (current, usage) =>
171+
`既定の通貨は ${current} でロックされています。${usage}で使用されているためです。` +
172+
'変更すると、この通貨に換算済みのすべての金額に誤った通貨が付きます。' +
173+
'為替レートが 1 件もなく、基準通貨の金額を宣言するオブジェクトもなくなった場合にのみ変更できます。',
174+
lockedLabel: (current) => `既定の通貨はロックされています: ${current}`,
175+
lockedText: (usage) => `${usage}で使用中です。換算後の金額はこの通貨で保存されるため、変更できません。`,
176+
unsetLabel: '既定の通貨が設定されていません',
177+
unsetText: (usage) =>
178+
'為替レートと基準通貨の金額には既定の通貨が必要ですが、まだ設定されていません。' +
179+
`使用状況: ${usage}。下で選択してください。保存後は変更できません。`,
180+
},
181+
'es-ES': {
182+
objects: (n, labels) => `${n} ${n === 1 ? 'objeto' : 'objetos'} (${labels})`,
183+
rates: (n) => `${n} ${n === 1 ? 'tipo de cambio' : 'tipos de cambio'}`,
184+
and: ' y ',
185+
listSeparator: ', ',
186+
refusal: (current, usage) =>
187+
`La moneda predeterminada está bloqueada en ${current} porque está en uso en ${usage}. ` +
188+
'Cambiarla volvería a etiquetar todos los importes ya convertidos a ella. Solo podrá cambiarse ' +
189+
'cuando no quede ningún tipo de cambio y ningún objeto declare un importe en la moneda principal.',
190+
lockedLabel: (current) => `Moneda predeterminada bloqueada: ${current}`,
191+
lockedText: (usage) =>
192+
`En uso en ${usage}. Los importes convertidos se guardan en esta moneda, por lo que ya no se puede cambiar.`,
193+
unsetLabel: 'No hay moneda predeterminada',
194+
unsetText: (usage) =>
195+
'Los tipos de cambio y los importes en la moneda principal necesitan una moneda predeterminada, ' +
196+
`y no hay ninguna. En uso: ${usage}. Elíjala abajo; una vez guardada, no se podrá cambiar.`,
197+
},
198+
};
199+
200+
const COPY_LOCALES = Object.keys(CURRENCY_LOCK_COPY);
201+
202+
/** The copy for a caller's locale: exact, then by language, else English. */
203+
export function currencyLockCopy(locale: string | undefined): CurrencyLockCopy {
204+
const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined;
205+
return CURRENCY_LOCK_COPY[(picked ?? 'en') as keyof typeof CURRENCY_LOCK_COPY];
206+
}
207+
208+
/** "2 objects (Invoice, Order) and 14 exchange rates", in the copy's language. */
209+
export function describeCurrencyUsage(copy: CurrencyLockCopy, usage: CurrencyUsage): string {
210+
const parts: string[] = [];
211+
if (usage.objects.length > 0) {
212+
const listed = usage.objects.slice(0, LISTED_OBJECTS).map((o) => o.label);
213+
const labels = listed.join(copy.listSeparator) + (usage.objects.length > LISTED_OBJECTS ? '…' : '');
214+
parts.push(copy.objects(usage.objects.length, labels));
215+
}
216+
if (usage.exchangeRates > 0) parts.push(copy.rates(usage.exchangeRates));
217+
return parts.join(copy.and);
218+
}
219+
220+
/** The discrete values a refusal carries (`FieldError.constraint`). */
221+
function usageConstraint(usage: CurrencyUsage): Record<string, unknown> {
222+
return {
223+
lockedBy: 'in_use',
224+
objects: usage.objects.map((o) => o.name),
225+
exchangeRates: usage.exchangeRates,
226+
};
227+
}
228+
229+
// ── The predicate and the statement ──────────────────────────────────────
230+
231+
/**
232+
* `localization.currency`'s write predicate: a change to a SET currency is
233+
* refused while the currency is used. Setting it while unset passes.
234+
*/
235+
export function localizationCurrencyWritePredicate(engine: CurrencyUsageEngine): SettingsWritePredicate {
236+
return async ({ current, organizationId, ctx }) => {
237+
if (isUnset(current)) return undefined;
238+
const usage = await readCurrencyUsage(engine, organizationId ?? null);
239+
if (!isUsed(usage)) return undefined;
240+
const copy = currencyLockCopy(ctx.locale);
241+
return {
242+
message: copy.refusal(String(current), describeCurrencyUsage(copy, usage)),
243+
constraint: usageConstraint(usage),
244+
};
245+
};
246+
}
247+
248+
/**
249+
* The Localization page's statement: what uses the home currency, and that it
250+
* is locked — or, while it is unset, that it is needed. Nothing to state while
251+
* nothing uses it.
252+
*
253+
* Reads the currency the caller's page resolves through `currentCurrency`, so
254+
* the statement and the form below it name the same value.
255+
*/
256+
export function localizationCurrencyLiveStatus(
257+
engine: CurrencyUsageEngine,
258+
currentCurrency: (ctx: Parameters<SettingsLiveStatusProvider>[0]['ctx']) => Promise<unknown>,
259+
): SettingsLiveStatusProvider {
260+
return async ({ ctx, organizationId }): Promise<SettingsLiveStatus | undefined> => {
261+
const organization = await organizationId();
262+
if (organization === undefined) return undefined;
263+
const usage = await readCurrencyUsage(engine, organization);
264+
if (!isUsed(usage)) return undefined;
265+
const copy = currencyLockCopy(ctx.locale);
266+
const described = describeCurrencyUsage(copy, usage);
267+
const current = await currentCurrency(ctx);
268+
if (isUnset(current)) {
269+
return { label: copy.unsetLabel, bannerText: copy.unsetText(described), bannerSeverity: 'warning' };
270+
}
271+
return { label: copy.lockedLabel(String(current)), bannerText: copy.lockedText(described), bannerSeverity: 'info' };
272+
};
273+
}
274+
275+
/** The service surface the lock registers on. */
276+
export interface CurrencyLockHost {
277+
getManifest(namespace: string): unknown;
278+
registerWritePredicate(namespace: string, key: string, predicate: SettingsWritePredicate): void;
279+
registerLiveStatus(namespace: string, provider: SettingsLiveStatusProvider): void;
280+
get(namespace: string, key: string, ctx?: Parameters<SettingsLiveStatusProvider>[0]['ctx']): Promise<{ value: unknown }>;
281+
}
282+
283+
/**
284+
* Register the lock on a settings service bound to `engine`. Returns `false`,
285+
* registering nothing, when the service carries no `localization` namespace
286+
* (a host that replaced the built-in manifests).
287+
*/
288+
export function registerLocalizationCurrencyLock(host: CurrencyLockHost, engine: CurrencyUsageEngine): boolean {
289+
try {
290+
host.getManifest('localization');
291+
} catch {
292+
return false;
293+
}
294+
host.registerWritePredicate('localization', 'currency', localizationCurrencyWritePredicate(engine));
295+
host.registerLiveStatus(
296+
'localization',
297+
localizationCurrencyLiveStatus(engine, async (ctx) => (await host.get('localization', 'currency', ctx)).value),
298+
);
299+
return true;
300+
}

‎packages/services/service-settings/src/settings-routes.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
shouldDenyAnonymous,
4545
} from '@objectstack/core';
4646
import type { ErrorCode } from '@objectstack/spec/api';
47+
import { preferredLocaleFromHeader } from '@objectstack/spec/system';
4748
// The declared envelope is written in ONE place for the whole platform (#3973).
4849
// Its `extra` is `ApiError`'s own optional fields, so the undeclared siblings
4950
// #4224 retired from this module cannot come back through it either.
@@ -200,6 +201,20 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined)
200201
return true;
201202
}
202203

204+
/**
205+
* The caller's display language, read the way REST's metadata door reads it:
206+
* the top `Accept-Language` tag, else `?locale`. It only picks the language of
207+
* prose a write predicate or a live-status provider composes
208+
* (`SettingsContext.locale`); it authorizes nothing.
209+
*/
210+
function requestLocaleOf(req: IHttpRequest): string | undefined {
211+
const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language'];
212+
const preferred = preferredLocaleFromHeader(typeof header === 'string' ? header : undefined);
213+
if (preferred) return preferred;
214+
const query = req.query?.locale;
215+
return typeof query === 'string' && query.length > 0 ? query : undefined;
216+
}
217+
203218
export function registerSettingsRoutes(
204219
http: IHttpServer,
205220
service: SettingsService,
@@ -280,7 +295,7 @@ export function registerSettingsRoutes(
280295
// `getNamespace`, which in-process consumers call to read values: the
281296
// mail plugin reads `getNamespace('mail')` while rebuilding the very
282297
// transport its statement waits on.
283-
const liveStatus = await service.liveStatusOf(ns, ctx);
298+
const liveStatus = await service.liveStatusOf(ns, { ...ctx, locale: requestLocaleOf(req) });
284299
const manifest = liveStatus
285300
? { ...payload.manifest, specifiers: [liveStatus, ...payload.manifest.specifiers] }
286301
: payload.manifest;
@@ -335,7 +350,12 @@ export function registerSettingsRoutes(
335350
// namespace — the same 404 `setMany` would have raised one line later, in
336351
// the same order relative to the 403 authz check.
337352
const secretKeys = service.secretKeysOf(ns);
338-
const result = await service.setMany(ns, dropEchoedSecretMasks(body, secretKeys), ctx);
353+
const result = await service.setMany(
354+
ns,
355+
dropEchoedSecretMasks(body, secretKeys),
356+
// The language a write predicate's refusal is written in.
357+
{ ...ctx, locale: requestLocaleOf(req) },
358+
);
339359
// The write response carries resolved values too — including cascade
340360
// entries the caller never submitted (an upper-scope secret it may not
341361
// have set). Same boundary, same redaction.

‎packages/services/service-settings/src/settings-service-plugin.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type { SettingsAuditWriter, SettingsEngine, SettingsSecretStore } from '.
3434
import type { CryptoAdapter } from './crypto-adapter.js';
3535
import { LocalCryptoProvider } from './local-crypto-provider.js';
3636
import { buildConfigChangeAuditSink } from './config-change-audit.js';
37+
import { registerLocalizationCurrencyLock, type CurrencyUsageEngine } from './localization-currency-lock.js';
3738
import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js';
3839
import { registerSettingsRoutes } from './settings-routes.js';
3940
import { registerSettingsReadDoor } from './settings-read-door.js';
@@ -345,6 +346,11 @@ export class SettingsServicePlugin implements Plugin {
345346
resolveGuestOrganization(engine, await this.resolveAdmissionTenancyPosture(ctx)),
346347
},
347348
);
349+
// [ADR-0144 D4] `localization.currency` is the home currency, locked
350+
// once an exchange rate or a `homeAmount` declaration uses it: the
351+
// key's write predicate and the page's statement both read this
352+
// engine (see `localization-currency-lock.ts`).
353+
registerLocalizationCurrencyLock(this.service!, engine as unknown as CurrencyUsageEngine);
348354
// Each namespace's `readPermission`, applied at the generic data
349355
// API's read of the settings stores too (see `settings-read-door.ts`).
350356
if (!registerSettingsReadDoor(engine, this.service!)) {

0 commit comments

Comments
 (0)