From 08e0d91d1613b6ce9f6cda694698ff166977470c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:31:02 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat(platform-objects):=20sys=5Fexchange?= =?UTF-8?q?=5Frate=20=E2=80=94=20the=20dated=20rate=20table,=20Setup=20lis?= =?UTF-8?q?t=20under=20Localization=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .../src/apps/setup-nav.contributions.ts | 6 + packages/platform-objects/src/plugin.test.ts | 12 +- packages/platform-objects/src/plugin.ts | 10 +- packages/platform-objects/src/system/index.ts | 7 + .../system/sys-exchange-rate.object.test.ts | 161 ++++++++++++++++++ .../src/system/sys-exchange-rate.object.ts | 158 +++++++++++++++++ 6 files changed, 349 insertions(+), 5 deletions(-) create mode 100644 packages/platform-objects/src/system/sys-exchange-rate.object.test.ts create mode 100644 packages/platform-objects/src/system/sys-exchange-rate.object.ts diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index ad98264e484..707e60c76be 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -126,6 +126,12 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // both directly. No `requiredPermissions` — matches Branding (read perm is // the app's base `setup.access`). { id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, + // Multi-currency M1-1 (epic #8345): the dated rates from the home + // currency Localization sets, so the entry sits directly under it. The + // list page states the rate direction (`EXCHANGE_RATE_DIRECTION`). No + // `requiredPermissions`: the object grant decides who reads and writes + // (organization admins), and Setup itself already needs `setup.access`. + { id: 'nav_exchange_rates', type: 'object', label: 'Exchange Rates', objectName: 'sys_exchange_rate', viewName: 'all_rates', icon: 'arrow-right-left' }, { id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, // `nav_settings_branding` is contributed by `@objectstack/service-settings` // with the `branding` namespace, which is offered only where no diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 22239e33210..8c3a6c597f6 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; // the predicate into a package that depends on neither side. import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { PlatformObjectsPlugin } from './plugin.js'; -import { SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; +import { SysExchangeRate, SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -46,7 +46,7 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal and SysSecret through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal, SysSecret and SysExchangeRate through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -56,7 +56,7 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 const infra = manifests.find((m) => m.id === 'com.objectstack.platform-objects'); expect(infra).toBeDefined(); expect(infra.scope).toBe('system'); - expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret]); + expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate]); expect(infra.objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag @@ -65,8 +65,12 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // compose and others do not is a journal a boot scanner cannot rely on. 'sys_migration_journal', 'sys_secret', + // Multi-currency M1-1 (epic #8345): read by the engine's write-time + // home-currency projection, so it registers with the engine-facing + // infrastructure, on the project database. + 'sys_exchange_rate', ]); - // ⛔ The three above must NOT acquire the ledger's routing triple. They + // ⛔ The four above must NOT acquire the ledger's routing triple. They // ride the project database today; moving them is a different question. expect(infra.defaultDatasource).toBeUndefined(); }); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 9dc317736f4..1db41b5cbe8 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -6,6 +6,7 @@ import { SysMetadataActivation } from './system/sys-metadata-activation.object.j import { SysMigration } from './system/sys-migration.object.js'; import { SysMigrationJournal } from './system/sys-migration-journal.object.js'; import { SysSecret } from './system/sys-secret.object.js'; +import { SysExchangeRate } from './system/sys-exchange-rate.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; import type { II18nService, @@ -78,6 +79,13 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * ⚠️ It rides its OWN manifest, not the one above, and that is a * smooth-upgrade requirement rather than tidiness — see * {@link ACTIVATION_LEDGER_MANIFEST}. + * - **`sys_exchange_rate`** — the organization's dated exchange rates + * (multi-currency M1-1, epic #8345, ruling 6465431966). Its one reader is + * the ObjectQL engine's write-time home-currency projection — present in + * every composition that can write a record — so it registers with the + * engine-facing infrastructure above, not with an optional service. Rides + * the first manifest (the project database), like every per-organization + * table here. * - **Fresh-datastore attestation** (#3438, ADR-0104 2026-07-30) — * travels with the ledger registration: a store this boot created from * empty is attested once that boot's own data has settled @@ -187,7 +195,7 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret], + objects: [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate], }); ctx?.getService?.('manifest')?.register?.({ ...ACTIVATION_LEDGER_MANIFEST, diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 95839163f25..6210d9b6750 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -20,6 +20,13 @@ export { SysSettingAudit } from './sys-setting-audit.object.js'; export { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; +// Multi-currency M1-1 (epic #8345): the dated rate table the write-time +// home-currency projection reads. +export { + SysExchangeRate, + EXCHANGE_RATE_DIRECTION, + EXCHANGE_RATE_SCALE, +} from './sys-exchange-rate.object.js'; export { readDataMigrationFlag, isDataMigrationVerified, diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts new file mode 100644 index 00000000000..8c3c2f9e472 --- /dev/null +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + IndexSchema, + ObjectSchema, + resolveCrudAffordances, + resolveInjectedSystemColumns, +} from '@objectstack/spec/data'; +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { + EXCHANGE_RATE_DIRECTION, + EXCHANGE_RATE_SCALE, + SysExchangeRate, +} from './sys-exchange-rate.object.js'; +import { SETUP_NAV_CONTRIBUTIONS } from '../apps/setup-nav.contributions.js'; + +/** + * Multi-currency M1-1 (epic #8345): `sys_exchange_rate`'s declaration. + * + * The four facts the card asks for — uniqueness, tenant isolation, who may + * write, audit — are each decided by a mechanism that lives elsewhere and is + * pinned there: the driver materializes an `'organization'` unique key + * (`driver-sql` tenant-scoped declared-unique suite), the Layer 0 wall scopes + * every tenant-columned object (ADR-0095 D1), `organization_admin`'s wildcard + * reaches every object that is not `access.default: 'private'` while + * `member_default` names no business object (plugin-security + * `default-permission-sets.ts`), and the audit writer captures every object + * not on its skip list (plugin-audit `AUDIT_EXCLUDED_OBJECTS`). What this + * package owns is the DECLARATION each of those mechanisms reads, so that is + * what is pinned here — asserted on the BUILT value, never on source text. + */ +describe('sys_exchange_rate — declaration (multi-currency M1-1)', () => { + const fields = SysExchangeRate.fields as Record; + + it('parses as an ObjectSchema', () => { + expect(() => ObjectSchema.parse(SysExchangeRate)).not.toThrow(); + }); + + it('carries exactly the ruled columns: currency, rate, effective_date, source', () => { + expect(Object.keys(fields).sort()).toEqual(['currency', 'effective_date', 'id', 'rate', 'source']); + for (const name of ['currency', 'rate', 'effective_date', 'source']) { + expect(fields[name].required, name).toBe(true); + } + }); + + describe('uniqueness', () => { + const uniqueIndexes = (SysExchangeRate.indexes ?? []).filter((i: any) => i.unique); + + it('declares exactly one unique key, on (currency, effective_date)', () => { + expect(uniqueIndexes).toHaveLength(1); + expect((uniqueIndexes[0] as any).fields).toEqual(['currency', 'effective_date']); + }); + + it("spells its scope 'organization' — not bare `true`, which is the installation-wide `'global'`", () => { + // Asserted by EQUALITY: a truthy check passes on the very spelling that + // would let one organization's EUR row refuse another organization's. + expect((uniqueIndexes[0] as any).unique).toBe('organization'); + expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ unique: 'organization' }); + }); + }); + + describe('tenant isolation', () => { + it('takes no tenancy opt-out, so organization_id is injected and the key has a column to scope by', () => { + const plan = resolveInjectedSystemColumns(SysExchangeRate); + expect((SysExchangeRate as any).tenancy).toBeUndefined(); + expect((SysExchangeRate as any).systemFields?.tenant).not.toBe(false); + expect(plan.tenant).toBe(true); + expect(plan.names.has('organization_id')).toBe(true); + }); + }); + + describe('who writes', () => { + it('stays reachable by the organization admin wildcard grant — not private, no extra capability gate', () => { + // `access.default: 'private'` opts an object OUT of the `'*'` grant + // organization admins hold; a `requiredPermissions` gate would demand a + // capability `organization_admin` does not carry. Either one would + // leave the rate table with no writer below platform admin. + expect((SysExchangeRate as any).access?.default).not.toBe('private'); + expect((SysExchangeRate as any).requiredPermissions).toBeUndefined(); + }); + + it('offers New / Edit / Delete to whoever holds the grant, and no CSV import until #27190 proves it', () => { + const affordances = resolveCrudAffordances(SysExchangeRate as any); + expect(SysExchangeRate.managedBy).toBe('system-data'); + expect(affordances).toMatchObject({ create: true, edit: true, delete: true, import: false }); + }); + + it('exposes the generic data API, so Setup can list and write it', () => { + expect(SysExchangeRate.enable?.apiEnabled).toBe(true); + expect(SysExchangeRate.enable?.apiMethods).toBeUndefined(); + }); + }); + + describe('audit', () => { + it('surfaces the History tab, with every ruled column summarized', () => { + expect(SysExchangeRate.enable?.trackHistory).toBe(true); + for (const name of ['currency', 'rate', 'effective_date', 'source']) { + expect(fields[name].trackHistory, name).toBe(true); + } + }); + }); + + describe('values', () => { + it('currency is an ISO 4217 code, checked on write by the value domain', () => { + expect(fields.currency.type).toBe('text'); + expect(fields.currency.valueDomain).toBe('iso_4217_currency'); + // The predicate the write seam runs: a real code passes, a shape-valid + // non-member and a lowercase spelling do not. + expect(isValueDomainMember('iso_4217_currency', 'EUR')).toBe(true); + expect(isValueDomainMember('iso_4217_currency', 'ZZZ')).toBe(false); + expect(isValueDomainMember('iso_4217_currency', 'eur')).toBe(false); + }); + + it('rate is an exact decimal with 12 decimal places, strictly positive', () => { + // `number` takes the platform's exact-decimal column; ⛔ never `float`. + expect(fields.rate.type).toBe('number'); + expect(EXCHANGE_RATE_SCALE).toBe(12); + expect(fields.rate.scale).toBe(EXCHANGE_RATE_SCALE); + expect(fields.rate.min).toBe(10 ** -EXCHANGE_RATE_SCALE); + }); + + it('effective_date is a date, and source is manual / import / app defaulting to manual', () => { + expect(fields.effective_date.type).toBe('date'); + expect(fields.source.type).toBe('select'); + expect(fields.source.options.map((o: any) => o.value)).toEqual(['manual', 'import', 'app']); + expect(fields.source.defaultValue).toBe('manual'); + }); + }); + + describe('rate direction', () => { + it('is one sentence, home → foreign, carried verbatim by the field and the Setup list page', () => { + expect(EXCHANGE_RATE_DIRECTION).toMatch(/^1 unit of the organization's home currency/); + expect(fields.rate.description).toBe(EXCHANGE_RATE_DIRECTION); + expect((SysExchangeRate.listViews as any).all_rates.description).toBe(EXCHANGE_RATE_DIRECTION); + }); + }); + + describe('Setup', () => { + const configuration = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === 'group_configuration'); + const items = (configuration?.items ?? []) as any[]; + + it('lists the rates directly under Localization, opening the all-rates view', () => { + const at = items.findIndex((i) => i.id === 'nav_exchange_rates'); + expect(at).toBeGreaterThan(0); + expect(items[at - 1].id).toBe('nav_settings_localization'); + expect(items[at]).toMatchObject({ + type: 'object', + objectName: 'sys_exchange_rate', + viewName: 'all_rates', + }); + }); + + it('names a view the object declares, listing the four ruled columns', () => { + const view = (SysExchangeRate.listViews as any).all_rates; + expect(view.data).toEqual({ provider: 'object', object: 'sys_exchange_rate' }); + expect(view.columns).toEqual(['currency', 'rate', 'effective_date', 'source']); + expect(view.emptyState?.title).toBeTruthy(); + }); + }); +}); diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts new file mode 100644 index 00000000000..ec9d51f85cf --- /dev/null +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * The rate direction, stated once and read by every face that has to say it: + * the `rate` field's description and the Setup list page's description both + * carry this sentence verbatim, so the two can never disagree about which way + * a rate points (ruling 6465431966, parameter 3). + */ +export const EXCHANGE_RATE_DIRECTION = + "1 unit of the organization's home currency (Localization → Default currency) = `rate` units of this currency. " + + 'Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.'; + +/** + * Decimal places a `rate` keeps (ruling 6465431966, parameter 4). A write + * carrying more is refused at the engine's `scale` seam, never rounded. + */ +export const EXCHANGE_RATE_SCALE = 12; + +/** + * sys_exchange_rate — Exchange Rate (multi-currency M1-1, epic #8345) + * + * One row per (currency, effective date) per organization: the rate that + * converts the organization's home currency into a foreign currency from that + * date on. It is the only source the write-time home-currency projection reads + * (shape C′, ruling 6465431966): a record-mode amount is converted at write + * from the rate in force on the record's own document date, and a missing + * rate refuses the write. Cross rates derive through the home currency — there + * is no pair table. + * + * - **Direction** — {@link EXCHANGE_RATE_DIRECTION}; one direction only, so + * no reader has to guess whether a row is the rate or its reciprocal. + * - **Precision** — `rate` is a `number` field (the platform's exact-decimal + * column, never a float column) with `scale` {@link EXCHANGE_RATE_SCALE}. + * - **Uniqueness** — `(currency, effective_date)` within an organization. Two + * organizations both holding EUR on the same day is the normal case, so the + * key is spelled `'organization'` (ADR-0120 D1), never bare `true`. + * - **Tenancy** — no tenancy opt-out, so `organization_id` is injected and the + * Layer 0 tenant wall (ADR-0095 D1) scopes every read and write. + * - **Who writes** — organization administrators: `organization_admin`'s + * wildcard grant reaches this object and `member_default` names no business + * object (explicit-allow baseline), so a rank-and-file member neither reads + * nor writes it (ruling 6465431966, parameter 2). Changing a rate changes + * the basis of every amount converted after it, which is why it is an admin + * act. + * - **Audit** — not on the audit writer's skip list, so every create, edit + * and delete lands in `sys_audit_log`; `enable.trackHistory` plus the + * per-field flags surface the History tab with readable diffs. + * - **Sources** — `source` records how the row arrived: typed in Setup + * (`manual`), loaded from a file (`import`), or written by an app flow that + * fetches a provider feed (`app`). The platform ships no provider feed. + * + * @namespace sys + */ +export const SysExchangeRate = ObjectSchema.create({ + name: 'sys_exchange_rate', + label: 'Exchange Rate', + pluralLabel: 'Exchange Rates', + icon: 'arrow-right-left', + isSystem: true, + // Platform-defined schema, organization-administered data. + managedBy: 'system-data', + description: + "Exchange rates from the organization's home currency to a foreign currency, by effective date — " + + 'the rates foreign-currency amounts are converted to the home currency with.', + displayNameField: 'currency', + nameField: 'currency', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{currency} · {effective_date}', + highlightFields: ['currency', 'rate', 'effective_date', 'source'], + + fields: { + id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + + currency: Field.text({ + label: 'Currency', + required: true, + searchable: true, + maxLength: 3, + valueDomain: 'iso_4217_currency', + trackHistory: true, + description: 'ISO 4217 code of the foreign currency, uppercase (EUR, JPY, CHF).', + group: 'Rate', + }), + + rate: Field.number({ + label: 'Rate', + required: true, + // The smallest positive value at this scale: a zero or negative rate has + // no meaning, and a conversion through one would silently zero or flip + // every amount it touched. + min: 0.000000000001, + scale: EXCHANGE_RATE_SCALE, + trackHistory: true, + description: EXCHANGE_RATE_DIRECTION, + group: 'Rate', + }), + + effective_date: Field.date({ + label: 'Effective Date', + required: true, + trackHistory: true, + description: + 'First date this rate applies to. It stays in force until a later row for the same currency takes over.', + group: 'Rate', + }), + + source: Field.select( + [ + { value: 'manual', label: 'Manual' }, + { value: 'import', label: 'Import' }, + { value: 'app', label: 'App' }, + ], + { + label: 'Source', + required: true, + defaultValue: 'manual', + trackHistory: true, + description: + 'How the row arrived: typed in Setup (manual), loaded from a file (import), or written by an app flow (app).', + group: 'Rate', + }, + ), + }, + + indexes: [ + { fields: ['currency', 'effective_date'], unique: 'organization' }, + ], + + listViews: { + all_rates: { + type: 'grid', + name: 'all_rates', + label: 'All', + description: EXCHANGE_RATE_DIRECTION, + data: { provider: 'object', object: 'sys_exchange_rate' }, + columns: ['currency', 'rate', 'effective_date', 'source'], + sort: [ + { field: 'effective_date', order: 'desc' }, + { field: 'currency', order: 'asc' }, + ], + pagination: { pageSize: 50 }, + emptyState: { + title: 'No exchange rates yet', + message: + 'Add one row per currency for each date its rate changes, for example one row per currency at the start of each month.', + }, + }, + }, + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + // A rate is reference data, not a discussion surface. + feeds: false, + }, +}); From 6fe2aadab2c2822932eb0f9c1b8cf9d056b2ec94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:35:40 +0000 Subject: [PATCH 02/10] feat(platform-objects): sys_exchange_rate docs line, changeset, nav-target classification (WIP) Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .changeset/27185-exchange-rate-table.md | 18 ++++++++++++++++++ content/docs/ui/setup-app.mdx | 10 +++++++++- .../src/platform-objects.test.ts | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .changeset/27185-exchange-rate-table.md diff --git a/.changeset/27185-exchange-rate-table.md b/.changeset/27185-exchange-rate-table.md new file mode 100644 index 00000000000..592a0781c9c --- /dev/null +++ b/.changeset/27185-exchange-rate-table.md @@ -0,0 +1,18 @@ +--- +'@objectstack/platform-objects': patch +--- + +feat(platform-objects): `sys_exchange_rate` — an organization's dated exchange rates, with a Setup list under Localization + +Clause-②: no +Metadata-protocol: none + +A new system object holds the rates multi-currency amounts are converted with, one row per currency and effective date: + +- **Columns.** `currency` (an ISO 4217 code, checked on write), `rate` (exact decimal, 12 decimal places, strictly positive), `effective_date` (date) and `source` (`manual` / `import` / `app`, default `manual`). +- **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale is exported as `EXCHANGE_RATE_SCALE`. +- **One rate per currency per day.** `(currency, effective_date)` is unique within an organization; two organizations may each hold their own EUR rate for the same day. +- **Who.** Per organization (its `organization_id` is injected and the tenant wall scopes it). Organization administrators read and write it; rank-and-file members do not see it. Every change is captured in the audit log and shown on the record's History tab. +- **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. + +Nothing reads the table yet: the write-time home-currency conversion that uses it ships in the same release. diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index 12f6fe28d9f..07c36d9310b 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -52,7 +52,7 @@ anchors are: | **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` | | **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | | **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` | -| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects`; Branding — `service-settings`, only where the `branding` settings namespace is offered (the `single` tenancy posture) | +| **Configuration** (`group_configuration`) | All Settings · Localization · Exchange Rates · Company · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects`; Branding — `service-settings`, only where the `branding` settings namespace is offered (the `single` tenancy posture) | | **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Log — `plugin-audit` | | **Integrations** (`group_integrations`) | `plugin-webhooks`; [Cloud Connection](/docs/deployment/cloud-connection) — `cloud-connection`, except on a runtime holding the environment service key, which registers no entry | | **Advanced** (`group_advanced`) | OAuth Applications — `platform-objects` | @@ -60,6 +60,14 @@ anchors are: The exact rendered menu depends on which capability plugins are loaded. A few notable entries: +- **Exchange Rates** (`sys_exchange_rate`) — the organization's dated rates, + editable by organization administrators, contributed into + `group_configuration` under Localization. One row means + 1 unit of the home currency (Localization → Default currency) = `rate` + units of the row's currency, from its effective date until a later row for + that currency. To record a month's rates, add one row per currency with + the first day of the month as the effective date; a correction is an edit + to that row, and every change lands in the audit log. - **Sessions** (`sys_session`) — better-auth session records, contributed into `group_diagnostics`. - **OAuth Applications** (`sys_oauth_application`) — third-party OAuth diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 50326ce0c6c..7034e5bf0c1 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -37,7 +37,7 @@ import { SysMetadata, SysMetadataHistoryObject, } from './metadata/index.js'; -import { SysSecret, SysSetting } from './system/index.js'; +import { SysExchangeRate, SysSecret, SysSetting } from './system/index.js'; import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from './apps/index.js'; import { AppSchema } from '@objectstack/spec/ui'; import { resolveEffectiveApiMethods, isApiOperationAllowed, canServeApiOperation } from '@objectstack/spec/data'; @@ -555,6 +555,7 @@ describe('@objectstack/platform-objects', () => { sys_account: SysAccount, sys_api_key: SysApiKey, sys_business_unit: SysBusinessUnit, + sys_exchange_rate: SysExchangeRate, sys_invitation: SysInvitation, sys_jwks: SysJwks, sys_oauth_application: SysOauthApplication, From 75fe89252b561a8962a29466beb79c764d65166f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:48:17 +0000 Subject: [PATCH 03/10] feat(platform-objects): sys_exchange_rate translations for the object, its list and the Setup entry Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .../scripts/i18n-extract.config.ts | 3 +- .../apps/translations/en.objects.generated.ts | 41 +++++++++++++++++++ .../src/apps/translations/en.ts | 1 + .../translations/es-ES.objects.generated.ts | 41 +++++++++++++++++++ .../es-ES.source-hashes.generated.ts | 2 + .../src/apps/translations/es-ES.ts | 1 + .../translations/ja-JP.objects.generated.ts | 41 +++++++++++++++++++ .../ja-JP.source-hashes.generated.ts | 1 + .../src/apps/translations/ja-JP.ts | 1 + .../translations/zh-CN.objects.generated.ts | 41 +++++++++++++++++++ .../zh-CN.source-hashes.generated.ts | 1 + .../src/apps/translations/zh-CN.ts | 1 + .../src/system/sys-exchange-rate.object.ts | 2 +- 13 files changed, 175 insertions(+), 2 deletions(-) diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 543d7336df7..755bb7ce502 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -187,7 +187,7 @@ import { } from '../src/metadata/index.js'; // ── System ──────────────────────────────────────────────────────────────── -import { SysSetting, SysSecret, SysSettingAudit, SysMigration } from '../src/system/index.js'; +import { SysSetting, SysSecret, SysSettingAudit, SysMigration, SysExchangeRate } from '../src/system/index.js'; // ── Existing Setup app + dashboards + translations ──────────────────────── import { SETUP_APP } from '../src/apps/setup.app.js'; @@ -317,6 +317,7 @@ const config: ObjectStackDefinition = defineStack({ SysSecret, SysSettingAudit, SysMigration, + SysExchangeRate, ] as any, apps: [SETUP_APP, STUDIO_APP, ACCOUNT_APP] as any, diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 3c10dcf93a5..578eadb5091 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3530,5 +3530,46 @@ export const enObjects: NonNullable = { label: "Updated At" } } + }, + sys_exchange_rate: { + label: "Exchange Rate", + pluralLabel: "Exchange Rates", + description: "Exchange rates from the organization's home currency to a foreign currency, by effective date — the rates foreign-currency amounts are converted to the home currency with.", + fields: { + id: { + label: "ID" + }, + currency: { + label: "Currency", + help: "ISO 4217 code of the foreign currency, uppercase (EUR, JPY, CHF)." + }, + rate: { + label: "Rate", + help: "1 unit of the organization's home currency (Localization → Default currency) = Rate units of this currency. Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR." + }, + effective_date: { + label: "Effective Date", + help: "First date this rate applies to. It stays in force until a later row for the same currency takes over." + }, + source: { + label: "Source", + help: "How the row arrived: typed in Setup (manual), loaded from a file (import), or written by an app flow (app).", + options: { + manual: "Manual", + import: "Import", + app: "App" + } + } + }, + _views: { + all_rates: { + label: "All", + description: "1 unit of the organization's home currency (Localization → Default currency) = Rate units of this currency. Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.", + emptyState: { + title: "No exchange rates yet", + message: "Add one row per currency for each date its rate changes, for example one row per currency at the start of each month." + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 038d5223433..217829e44a0 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -124,6 +124,7 @@ export const en: TranslationData = { // Configuration nav_settings_hub: { label: 'All Settings' }, nav_settings_localization: { label: 'Localization' }, + nav_exchange_rates: { label: 'Exchange Rates' }, nav_settings_company: { label: 'Company' }, nav_settings_mail: { label: 'Email' }, nav_settings_branding: { label: 'Branding' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 9a732d8e98a..2244f814744 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3530,5 +3530,46 @@ export const esESObjects: NonNullable = { label: "Actualizado el" } } + }, + sys_exchange_rate: { + label: "Tipo de cambio", + pluralLabel: "Tipos de cambio", + description: "Tipos de cambio de la moneda local de la organización a una moneda extranjera, por fecha de entrada en vigor: los tipos con los que los importes en moneda extranjera se convierten a la moneda local.", + fields: { + id: { + label: "ID" + }, + currency: { + label: "Moneda", + help: "Código ISO 4217 de la moneda extranjera, en mayúsculas (EUR, JPY, CHF)." + }, + rate: { + label: "Tipo", + help: "1 unidad de la moneda local de la organización (Localización → Moneda predeterminada) = Tipo unidades de esta moneda. Ejemplo: moneda local USD, moneda EUR, tipo 0,92 significa 1 USD = 0,92 EUR." + }, + effective_date: { + label: "Fecha de entrada en vigor", + help: "Primera fecha a la que se aplica este tipo. Sigue vigente hasta que una fila posterior de la misma moneda lo sustituya." + }, + source: { + label: "Origen", + help: "Cómo llegó la fila: introducida en Setup (manual), cargada desde un archivo (importación) o escrita por un flujo de una aplicación (aplicación).", + options: { + manual: "Manual", + import: "Importación", + app: "Aplicación" + } + } + }, + _views: { + all_rates: { + label: "Todos", + description: "1 unidad de la moneda local de la organización (Localización → Moneda predeterminada) = Tipo unidades de esta moneda. Ejemplo: moneda local USD, moneda EUR, tipo 0,92 significa 1 USD = 0,92 EUR.", + emptyState: { + title: "Aún no hay tipos de cambio", + message: "Añade una fila por moneda para cada fecha en que cambie su tipo, por ejemplo una fila por moneda al inicio de cada mes." + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index e3712999715..4086c3c4669 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -23,6 +23,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_email_template.fields.variables_json.label": "c2cfff50e8151e3d", + "objects.sys_exchange_rate.fields.id.label": "00b0385c9c152888", + "objects.sys_exchange_rate.fields.source.options.manual": "c2e7a98c066bec4a", "objects.sys_job_run.fields.error.label": "786fed84bd8d5a32", "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 6fea2e06981..394e1091327 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -83,6 +83,7 @@ export const esES: TranslationData = { nav_settings_hub: { label: 'Todos los Ajustes' }, nav_settings_localization: { label: 'Localización' }, + nav_exchange_rates: { label: 'Tipos de cambio' }, nav_settings_company: { label: 'Empresa' }, nav_settings_mail: { label: 'Correo' }, nav_settings_branding: { label: 'Marca' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 3d835cd861e..e7ea8fc062a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3530,5 +3530,46 @@ export const jaJPObjects: NonNullable = { label: "更新日時" } } + }, + sys_exchange_rate: { + label: "為替レート", + pluralLabel: "為替レート", + description: "組織の基準通貨から外貨への為替レートを適用開始日ごとに記録します。外貨金額を基準通貨に換算する際に使われるレートです。", + fields: { + id: { + label: "ID" + }, + currency: { + label: "通貨", + help: "外貨の ISO 4217 コード(大文字。EUR、JPY、CHF)。" + }, + rate: { + label: "レート", + help: "組織の基準通貨 1 単位(ローカリゼーション → 既定の通貨)= この通貨のレート単位。例:基準通貨 USD、通貨 EUR、レート 0.92 は 1 USD = 0.92 EUR を意味します。" + }, + effective_date: { + label: "適用開始日", + help: "このレートが適用され始める日付。同じ通貨のより新しい行に置き換わるまで有効です。" + }, + source: { + label: "ソース", + help: "行の登録方法:Setup で入力(手動)、ファイルから読み込み(インポート)、またはアプリのフローが書き込み(アプリ)。", + options: { + manual: "手動", + import: "インポート", + app: "アプリ" + } + } + }, + _views: { + all_rates: { + label: "すべて", + description: "組織の基準通貨 1 単位(ローカリゼーション → 既定の通貨)= この通貨のレート単位。例:基準通貨 USD、通貨 EUR、レート 0.92 は 1 USD = 0.92 EUR を意味します。", + emptyState: { + title: "為替レートはまだありません", + message: "通貨ごとに、レートが変わる日付ごとに 1 行追加します。例:毎月初めに通貨ごとに 1 行。" + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 3de8b1983d8..1582af3d0ee 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -22,6 +22,7 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", + "objects.sys_exchange_rate.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index da18e58ad85..642907766b7 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -83,6 +83,7 @@ export const jaJP: TranslationData = { nav_settings_hub: { label: 'すべての設定' }, nav_settings_localization: { label: 'ローカリゼーション' }, + nav_exchange_rates: { label: '為替レート' }, nav_settings_company: { label: '会社情報' }, nav_settings_mail: { label: 'メール' }, nav_settings_branding: { label: 'ブランディング' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 879f0bd3e15..07cb18a01a2 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3530,5 +3530,46 @@ export const zhCNObjects: NonNullable = { label: "更新时间" } } + }, + sys_exchange_rate: { + label: "汇率", + pluralLabel: "汇率", + description: "从组织本位币到外币的汇率,按生效日期记录——外币金额折算为本位币时所用的汇率。", + fields: { + id: { + label: "ID" + }, + currency: { + label: "币种", + help: "外币的 ISO 4217 代码,大写(EUR、JPY、CHF)。" + }, + rate: { + label: "汇率", + help: "1 单位组织本位币(本地化 → 默认币种)= 汇率 单位的该币种。示例:本位币 USD、币种 EUR、汇率 0.92,表示 1 USD = 0.92 EUR。" + }, + effective_date: { + label: "生效日期", + help: "此汇率开始适用的日期。在同一币种的更晚一行接替之前一直有效。" + }, + source: { + label: "来源", + help: "该行的来源:在 Setup 中录入(手工)、从文件导入(导入),或由应用流程写入(应用)。", + options: { + manual: "手工", + import: "导入", + app: "应用" + } + } + }, + _views: { + all_rates: { + label: "全部", + description: "1 单位组织本位币(本地化 → 默认币种)= 汇率 单位的该币种。示例:本位币 USD、币种 EUR、汇率 0.92,表示 1 USD = 0.92 EUR。", + emptyState: { + title: "还没有汇率", + message: "每个币种在汇率变动的每个日期各录一行,例如每月初为每个币种录一行。" + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 0212e9df053..a953e376fb1 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -20,6 +20,7 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", + "objects.sys_exchange_rate.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 7c350a2f433..4e2bd05b5db 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -90,6 +90,7 @@ export const zhCN: TranslationData = { nav_settings_hub: { label: '全部设置' }, nav_settings_localization: { label: '本地化' }, + nav_exchange_rates: { label: '汇率' }, nav_settings_company: { label: '公司信息' }, nav_settings_mail: { label: '邮件' }, nav_settings_branding: { label: '品牌' }, diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts index ec9d51f85cf..436d59b15e4 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -9,7 +9,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * a rate points (ruling 6465431966, parameter 3). */ export const EXCHANGE_RATE_DIRECTION = - "1 unit of the organization's home currency (Localization → Default currency) = `rate` units of this currency. " + + "1 unit of the organization's home currency (Localization → Default currency) = Rate units of this currency. " + 'Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.'; /** From faf1b6e36adb07bd0182530f5ac8eee372f05e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:50:22 +0000 Subject: [PATCH 04/10] test(platform-objects): sys_exchange_rate joins the objects bundle ownership roster Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .../src/apps/translations/bundle-ownership.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts index bc87738e140..54ab6875527 100644 --- a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -38,7 +38,7 @@ const OWNED_OBJECTS = new Set([ // metadata (sys_view_definition retired as inert, ADR-0131 D13) 'sys_metadata', 'sys_metadata_history', 'sys_metadata_audit', // system - 'sys_setting', 'sys_secret', 'sys_setting_audit', 'sys_migration', + 'sys_setting', 'sys_secret', 'sys_setting_audit', 'sys_migration', 'sys_exchange_rate', ]); describe('objects translation bundle ownership (ADR-0029 D8)', () => { From 86e39faf0691d0d0ceadac1cc71027c1f442e12f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:20:05 +0000 Subject: [PATCH 05/10] feat(platform-objects): sys_exchange_rate keeps 18 decimal places; tenancy census counts it Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .changeset/27185-exchange-rate-table.md | 4 ++-- .../system/sys-exchange-rate.object.test.ts | 5 +++-- .../src/system/sys-exchange-rate.object.ts | 18 ++++++++++++++---- scripts/platform-object-tenancy-census.json | 11 +++++++++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.changeset/27185-exchange-rate-table.md b/.changeset/27185-exchange-rate-table.md index 592a0781c9c..2abfc7a40e8 100644 --- a/.changeset/27185-exchange-rate-table.md +++ b/.changeset/27185-exchange-rate-table.md @@ -9,8 +9,8 @@ Metadata-protocol: none A new system object holds the rates multi-currency amounts are converted with, one row per currency and effective date: -- **Columns.** `currency` (an ISO 4217 code, checked on write), `rate` (exact decimal, 12 decimal places, strictly positive), `effective_date` (date) and `source` (`manual` / `import` / `app`, default `manual`). -- **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale is exported as `EXCHANGE_RATE_SCALE`. +- **Columns.** `currency` (an ISO 4217 code, checked on write), `rate` (exact decimal, up to 18 decimal places, strictly positive), `effective_date` (date) and `source` (`manual` / `import` / `app`, default `manual`). +- **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency, so a home amount is the foreign amount divided by `rate`. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale is exported as `EXCHANGE_RATE_SCALE`. - **One rate per currency per day.** `(currency, effective_date)` is unique within an organization; two organizations may each hold their own EUR rate for the same day. - **Who.** Per organization (its `organization_id` is injected and the tenant wall scopes it). Organization administrators read and write it; rank-and-file members do not see it. Every change is captured in the audit log and shown on the record's History tab. - **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts index 8c3c2f9e472..07c9c504618 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts @@ -112,10 +112,11 @@ describe('sys_exchange_rate — declaration (multi-currency M1-1)', () => { expect(isValueDomainMember('iso_4217_currency', 'eur')).toBe(false); }); - it('rate is an exact decimal with 12 decimal places, strictly positive', () => { + it('rate is an exact decimal keeping at least 12 decimal places, strictly positive', () => { // `number` takes the platform's exact-decimal column; ⛔ never `float`. + // ≥ 12 satisfies both readings of parameter 4 (ADR-0144 Open item 5). expect(fields.rate.type).toBe('number'); - expect(EXCHANGE_RATE_SCALE).toBe(12); + expect(EXCHANGE_RATE_SCALE).toBe(18); expect(fields.rate.scale).toBe(EXCHANGE_RATE_SCALE); expect(fields.rate.min).toBe(10 ** -EXCHANGE_RATE_SCALE); }); diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts index 436d59b15e4..4961e3ae016 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -13,10 +13,18 @@ export const EXCHANGE_RATE_DIRECTION = 'Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.'; /** - * Decimal places a `rate` keeps (ruling 6465431966, parameter 4). A write - * carrying more is refused at the engine's `scale` seam, never rounded. + * Decimal places a `rate` keeps. A write carrying more is refused at the + * engine's `scale` seam, never rounded. + * + * Ruling 6465431966 parameter 4 says "12 significant decimals", which reads + * two ways (ADR-0144 Open item 5): 12 digits after the point, or 12 + * significant digits. 18 satisfies both: it keeps 12 places for every rate, + * and 12 significant digits for every rate down to 10⁻⁶ (a weak home currency + * against a strong one, e.g. 1 VND = 0.0000393 USD, needs 17). The column is + * the platform's exact decimal whatever this says, so settling the open item + * later is an edit here and moves no data. */ -export const EXCHANGE_RATE_SCALE = 12; +export const EXCHANGE_RATE_SCALE = 18; /** * sys_exchange_rate — Exchange Rate (multi-currency M1-1, epic #8345) @@ -33,6 +41,8 @@ export const EXCHANGE_RATE_SCALE = 12; * no reader has to guess whether a row is the rate or its reciprocal. * - **Precision** — `rate` is a `number` field (the platform's exact-decimal * column, never a float column) with `scale` {@link EXCHANGE_RATE_SCALE}. + * A home amount is the record amount DIVIDED by `rate` (ADR-0144, parameter + * 3 as arithmetic). * - **Uniqueness** — `(currency, effective_date)` within an organization. Two * organizations both holding EUR on the same day is the normal case, so the * key is spelled `'organization'` (ADR-0120 D1), never bare `true`. @@ -89,7 +99,7 @@ export const SysExchangeRate = ObjectSchema.create({ // The smallest positive value at this scale: a zero or negative rate has // no meaning, and a conversion through one would silently zero or flip // every amount it touched. - min: 0.000000000001, + min: 10 ** -EXCHANGE_RATE_SCALE, scale: EXCHANGE_RATE_SCALE, trackHistory: true, description: EXCHANGE_RATE_DIRECTION, diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 65173976f70..737914c1bda 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -26,8 +26,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 87, - "inReach": 44, + "registered": 88, + "inReach": 45, "outOfReach": 43 }, "reasonTotals": { @@ -186,6 +186,13 @@ "tenantField": "organization_id", "reasons": [] }, + { + "name": "sys_exchange_rate", + "file": "packages/platform-objects/src/system/sys-exchange-rate.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_file", "file": "packages/services/service-storage/src/objects/system-file.object.ts", From 9faf0425a3da5c8e22771dad62117f373ba4120c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:39:44 +0000 Subject: [PATCH 06/10] fix(platform-objects): sys_exchange_rate comments cite the multi-currency ADR by its card, not an unwritten number Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .../src/system/sys-exchange-rate.object.test.ts | 2 +- .../src/system/sys-exchange-rate.object.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts index 07c9c504618..a0599a861dc 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts @@ -114,7 +114,7 @@ describe('sys_exchange_rate — declaration (multi-currency M1-1)', () => { it('rate is an exact decimal keeping at least 12 decimal places, strictly positive', () => { // `number` takes the platform's exact-decimal column; ⛔ never `float`. - // ≥ 12 satisfies both readings of parameter 4 (ADR-0144 Open item 5). + // ≥ 12 satisfies both readings of parameter 4 (the multi-currency ADR's open item 5, #27183). expect(fields.rate.type).toBe('number'); expect(EXCHANGE_RATE_SCALE).toBe(18); expect(fields.rate.scale).toBe(EXCHANGE_RATE_SCALE); diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts index 4961e3ae016..b7fd3295d5e 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -17,7 +17,8 @@ export const EXCHANGE_RATE_DIRECTION = * engine's `scale` seam, never rounded. * * Ruling 6465431966 parameter 4 says "12 significant decimals", which reads - * two ways (ADR-0144 Open item 5): 12 digits after the point, or 12 + * two ways (the multi-currency ADR's open item 5, #27183): 12 digits after + * the point, or 12 * significant digits. 18 satisfies both: it keeps 12 places for every rate, * and 12 significant digits for every rate down to 10⁻⁶ (a weak home currency * against a strong one, e.g. 1 VND = 0.0000393 USD, needs 17). The column is @@ -41,8 +42,8 @@ export const EXCHANGE_RATE_SCALE = 18; * no reader has to guess whether a row is the rate or its reciprocal. * - **Precision** — `rate` is a `number` field (the platform's exact-decimal * column, never a float column) with `scale` {@link EXCHANGE_RATE_SCALE}. - * A home amount is the record amount DIVIDED by `rate` (ADR-0144, parameter - * 3 as arithmetic). + * A home amount is the record amount DIVIDED by `rate` (parameter 3 as + * arithmetic, recorded by the multi-currency ADR, #27183). * - **Uniqueness** — `(currency, effective_date)` within an organization. Two * organizations both holding EUR on the same day is the normal case, so the * key is spelled `'organization'` (ADR-0120 D1), never bare `true`. From c8d84fd706a656ac6960243725d6cf6a94d0832d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:50:13 +0000 Subject: [PATCH 07/10] feat(platform-objects): sys_exchange_rate rate is scale 12, precision 24 (ruling 6469382727) Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .changeset/27185-exchange-rate-table.md | 4 +-- packages/platform-objects/src/system/index.ts | 1 + .../system/sys-exchange-rate.object.test.ts | 9 ++++--- .../src/system/sys-exchange-rate.object.ts | 27 ++++++++++--------- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.changeset/27185-exchange-rate-table.md b/.changeset/27185-exchange-rate-table.md index 2abfc7a40e8..bc17a733593 100644 --- a/.changeset/27185-exchange-rate-table.md +++ b/.changeset/27185-exchange-rate-table.md @@ -9,8 +9,8 @@ Metadata-protocol: none A new system object holds the rates multi-currency amounts are converted with, one row per currency and effective date: -- **Columns.** `currency` (an ISO 4217 code, checked on write), `rate` (exact decimal, up to 18 decimal places, strictly positive), `effective_date` (date) and `source` (`manual` / `import` / `app`, default `manual`). -- **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency, so a home amount is the foreign amount divided by `rate`. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale is exported as `EXCHANGE_RATE_SCALE`. +- **Columns.** `currency` (an ISO 4217 code, checked on write), `rate` (exact decimal, 12 decimal places and 24 digits in all, strictly positive), `effective_date` (date) and `source` (`manual` / `import` / `app`, default `manual`). +- **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency, so a home amount is the foreign amount divided by `rate`. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale and precision are exported as `EXCHANGE_RATE_SCALE` and `EXCHANGE_RATE_PRECISION`. - **One rate per currency per day.** `(currency, effective_date)` is unique within an organization; two organizations may each hold their own EUR rate for the same day. - **Who.** Per organization (its `organization_id` is injected and the tenant wall scopes it). Organization administrators read and write it; rank-and-file members do not see it. Every change is captured in the audit log and shown on the record's History tab. - **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 6210d9b6750..e2a4e7072e7 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -26,6 +26,7 @@ export { SysExchangeRate, EXCHANGE_RATE_DIRECTION, EXCHANGE_RATE_SCALE, + EXCHANGE_RATE_PRECISION, } from './sys-exchange-rate.object.js'; export { readDataMigrationFlag, diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts index a0599a861dc..314204448f1 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts @@ -10,6 +10,7 @@ import { import { isValueDomainMember } from '@objectstack/spec/shared'; import { EXCHANGE_RATE_DIRECTION, + EXCHANGE_RATE_PRECISION, EXCHANGE_RATE_SCALE, SysExchangeRate, } from './sys-exchange-rate.object.js'; @@ -112,12 +113,14 @@ describe('sys_exchange_rate — declaration (multi-currency M1-1)', () => { expect(isValueDomainMember('iso_4217_currency', 'eur')).toBe(false); }); - it('rate is an exact decimal keeping at least 12 decimal places, strictly positive', () => { + it('rate is an exact decimal, scale 12 and precision 24, strictly positive', () => { // `number` takes the platform's exact-decimal column; ⛔ never `float`. - // ≥ 12 satisfies both readings of parameter 4 (the multi-currency ADR's open item 5, #27183). + // Parameter 4 = twelve digits after the point (ruling 6469382727). expect(fields.rate.type).toBe('number'); - expect(EXCHANGE_RATE_SCALE).toBe(18); + expect(EXCHANGE_RATE_SCALE).toBe(12); + expect(EXCHANGE_RATE_PRECISION).toBe(24); expect(fields.rate.scale).toBe(EXCHANGE_RATE_SCALE); + expect(fields.rate.precision).toBe(EXCHANGE_RATE_PRECISION); expect(fields.rate.min).toBe(10 ** -EXCHANGE_RATE_SCALE); }); diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts index b7fd3295d5e..a1c502b81cc 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -13,19 +13,18 @@ export const EXCHANGE_RATE_DIRECTION = 'Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.'; /** - * Decimal places a `rate` keeps. A write carrying more is refused at the - * engine's `scale` seam, never rounded. - * - * Ruling 6465431966 parameter 4 says "12 significant decimals", which reads - * two ways (the multi-currency ADR's open item 5, #27183): 12 digits after - * the point, or 12 - * significant digits. 18 satisfies both: it keeps 12 places for every rate, - * and 12 significant digits for every rate down to 10⁻⁶ (a weak home currency - * against a strong one, e.g. 1 VND = 0.0000393 USD, needs 17). The column is - * the platform's exact decimal whatever this says, so settling the open item - * later is an edit here and moves no data. + * Decimal places a `rate` keeps — twelve digits after the point (ruling + * 6465431966 parameter 4, its unit settled by ruling 6469382727). A write + * carrying more is refused at the engine's `scale` seam, never rounded. + */ +export const EXCHANGE_RATE_SCALE = 12; + +/** + * Total digits a `rate` may carry, integer and fraction together (ruling + * 6469382727): twelve on each side of the point. A write needing more is + * refused at the engine's `precision` seam (`max_precision`). */ -export const EXCHANGE_RATE_SCALE = 18; +export const EXCHANGE_RATE_PRECISION = 24; /** * sys_exchange_rate — Exchange Rate (multi-currency M1-1, epic #8345) @@ -41,7 +40,8 @@ export const EXCHANGE_RATE_SCALE = 18; * - **Direction** — {@link EXCHANGE_RATE_DIRECTION}; one direction only, so * no reader has to guess whether a row is the rate or its reciprocal. * - **Precision** — `rate` is a `number` field (the platform's exact-decimal - * column, never a float column) with `scale` {@link EXCHANGE_RATE_SCALE}. + * column, never a float column) with `scale` {@link EXCHANGE_RATE_SCALE} + * and `precision` {@link EXCHANGE_RATE_PRECISION}. * A home amount is the record amount DIVIDED by `rate` (parameter 3 as * arithmetic, recorded by the multi-currency ADR, #27183). * - **Uniqueness** — `(currency, effective_date)` within an organization. Two @@ -102,6 +102,7 @@ export const SysExchangeRate = ObjectSchema.create({ // every amount it touched. min: 10 ** -EXCHANGE_RATE_SCALE, scale: EXCHANGE_RATE_SCALE, + precision: EXCHANGE_RATE_PRECISION, trackHistory: true, description: EXCHANGE_RATE_DIRECTION, group: 'Rate', From 4bc2a793aa4d1a34adc64ef32f83f52b8539777b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:58:29 +0000 Subject: [PATCH 08/10] fix(spec): PLATFORM_OBJECTS_BY_PACKAGE lists sys_exchange_rate under platform-objects Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .changeset/27185-exchange-rate-table.md | 3 ++- packages/spec/src/system/constants/platform-object-names.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/27185-exchange-rate-table.md b/.changeset/27185-exchange-rate-table.md index bc17a733593..33e1f3bef6c 100644 --- a/.changeset/27185-exchange-rate-table.md +++ b/.changeset/27185-exchange-rate-table.md @@ -1,5 +1,6 @@ --- '@objectstack/platform-objects': patch +'@objectstack/spec': patch --- feat(platform-objects): `sys_exchange_rate` — an organization's dated exchange rates, with a Setup list under Localization @@ -13,6 +14,6 @@ A new system object holds the rates multi-currency amounts are converted with, o - **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency, so a home amount is the foreign amount divided by `rate`. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale and precision are exported as `EXCHANGE_RATE_SCALE` and `EXCHANGE_RATE_PRECISION`. - **One rate per currency per day.** `(currency, effective_date)` is unique within an organization; two organizations may each hold their own EUR rate for the same day. - **Who.** Per organization (its `organization_id` is injected and the tenant wall scopes it). Organization administrators read and write it; rank-and-file members do not see it. Every change is captured in the audit log and shown on the record's History tab. -- **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. +- **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. `PLATFORM_OBJECTS_BY_PACKAGE` (`@objectstack/spec`) lists it under `platform-objects`, so a reference to `sys_exchange_rate` resolves as a real platform object. Nothing reads the table yet: the write-time home-currency conversion that uses it ships in the same release. diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index eb1ab60ebf5..75646f06041 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -52,6 +52,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly Date: Wed, 28 Oct 2026 13:59:44 +0000 Subject: [PATCH 09/10] fix(cli): organization-ownership inventory lists sys_exchange_rate The new tenant-scoped platform table carries organization_id, so `os migrate organization-ownership` refused every plan as carrying an uninventoried platform table. It takes fate 3 (attribution, no anchors: the Default Organization under single, otherwise reported), as the other organization-authored tables added since the first census do. Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .changeset/27185-exchange-rate-table.md | 2 ++ packages/cli/src/utils/organization-ownership-inventory.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.changeset/27185-exchange-rate-table.md b/.changeset/27185-exchange-rate-table.md index 33e1f3bef6c..9c924139a34 100644 --- a/.changeset/27185-exchange-rate-table.md +++ b/.changeset/27185-exchange-rate-table.md @@ -1,6 +1,7 @@ --- '@objectstack/platform-objects': patch '@objectstack/spec': patch +'@objectstack/cli': patch --- feat(platform-objects): `sys_exchange_rate` — an organization's dated exchange rates, with a Setup list under Localization @@ -15,5 +16,6 @@ A new system object holds the rates multi-currency amounts are converted with, o - **One rate per currency per day.** `(currency, effective_date)` is unique within an organization; two organizations may each hold their own EUR rate for the same day. - **Who.** Per organization (its `organization_id` is injected and the tenant wall scopes it). Organization administrators read and write it; rank-and-file members do not see it. Every change is captured in the audit log and shown on the record's History tab. - **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. `PLATFORM_OBJECTS_BY_PACKAGE` (`@objectstack/spec`) lists it under `platform-objects`, so a reference to `sys_exchange_rate` resolves as a real platform object. +- **Upgrade ceremony.** The organization-ownership inventory (`@objectstack/cli`) lists `sys_exchange_rate`, so `os migrate organization-ownership` plans it instead of refusing it as an uninventoried platform table. Nothing reads the table yet: the write-time home-currency conversion that uses it ships in the same release. diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 354ee2064f7..56617e5aefd 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -765,6 +765,9 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_queue', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_queue_member', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its queue by name, not by id; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_assignment_cursor', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its rule by name, not by id; ${D10_SINGLE}`, anchors: [] }, + // Exchange rates (`@objectstack/platform-objects`, #27185): an organization's + // administrators author its own rate table; no parent row anchors a rate. + { object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, From 3e26252e655f21fb2e990efc111c16e383e38774 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:59:44 +0000 Subject: [PATCH 10/10] test(cli): the Setup tree the platform admin is served holds 32 entries SETUP_NAV_CONTRIBUTIONS gains nav_exchange_rates, so the composition's Setup tree grows from 31 entries to 32. Claude-Session: https://claude.ai/code/session_01Utp6CZ8iiopaXHsTY7SiRf Co-authored-by: Claude --- .../cli/test/connect-agent-both-halves-wire.pin.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index dfece4d7c95..072258585c7 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -254,14 +254,14 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( .toContain('nav_connect_agent'); // The Setup tree the platform admin is served BY THIS COMPOSITION: 9 // authored groups less `group_approvals`, which nothing contributes - // into and the filter collapses, plus the 22 entries + // into and the filter collapses, plus the 23 entries // `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one. // `nav_settings_branding` is not among them, and not because of a // tenancy wall (this harness boots none): since #24766 it is // contributed by `@objectstack/service-settings` together with the // `branding` namespace, offered only where no organization wall stands, // and this harness composes platform-objects and the MCP bundle alone. - expect(allowedIds).toHaveLength(31); + expect(allowedIds).toHaveLength(32); expect(allowedIds).not.toContain('nav_settings_branding'); // The two per-user stores are reached from the User record page, not // from Setup's menu (#23803). @@ -284,8 +284,8 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - // 31: the composition the count in the test above states. - expect(navIds(withAccountHalf.body?.item)).toHaveLength(31); + // 32: the composition the count in the test above states. + expect(navIds(withAccountHalf.body?.item)).toHaveLength(32); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at