From 890ec13d9b798ed93baa9ba4d642f44f572fdb7f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:13:07 +0000 Subject: [PATCH 1/2] fix(service-analytics): refuse a derived measure currency that contradicts its operands' fixed code Guard 1 of measure-currency-coherence skipped derived measures for the fixed-code comparison, so a derived sum of two EUR-fixed measures that declared `currency: 'USD'` compiled and its column printed euros labelled USD. A derived measure whose operands resolve to one `fixed` code now refuses a different declared `currency` with DATASET_INVALID / 400, naming the operands' code and the fix (delete the measure's `currency`). Restating the same code stays legal. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL --- .../28019-derived-measure-fixed-currency.md | 19 ++++++++ .../record-currency-analytics.test.ts | 43 +++++++++++++++++++ .../src/measure-currency-coherence.ts | 17 +++++++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .changeset/28019-derived-measure-fixed-currency.md diff --git a/.changeset/28019-derived-measure-fixed-currency.md b/.changeset/28019-derived-measure-fixed-currency.md new file mode 100644 index 00000000000..6cd43e0dc2a --- /dev/null +++ b/.changeset/28019-derived-measure-fixed-currency.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics): a dataset refuses a derived measure `currency` that contradicts the one `fixed` code its operands resolve to + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + +**BREAKING** (`@objectstack/service-analytics`): one more compile-time refusal, `DATASET_INVALID` / 400, for dataset metadata that compiled before and printed a wrong label. It reaches boot registration, `queryDataset` and the Studio draft preview, which compile through the one `compileDataset` call. + +- A derived `sum` / `difference` whose monetary operands all resolve to one `fixed` code, directly or through a derived measure built on them, and which declares its own `currency` with another code. The column printed the operands' amounts under the declared code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures printing euros labelled `USD`. The message names the dataset, the measure, its operands and their code, and the fix: delete the measure's `currency`, and the column takes the operands' code. +- A derived measure `currency` that restates the operands' `fixed` code is still accepted. + +Not judged, unchanged: a derived measure whose operands resolve to no one `fixed` code (a `fixed` amount with a tenant-currency one, a `ratio` or `product`), and the record-mode and mixed-code refusals of both guards. + +No in-tree dataset declares this shape. A deployed tenant's stored dataset with it now refuses at registration and at query, and its message names the edit. + + diff --git a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts index a70812a0445..980bcede5b0 100644 --- a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts +++ b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts @@ -370,6 +370,48 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { ); }); + for (const op of ['sum', 'difference'] as const) { + it(`a derived ${op} declaring a currency other than its operands' one fixed code is refused, naming the fix`, () => { + const measures = [ + ...base, + { name: 'eur_restated', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }, + { name: 'eur_as_usd', derived: { op, of: ['eur', 'eur_restated'] }, currency: 'USD' }, + ]; + expectRefused(measures, 'eur_as_usd'); + expect(compileRefusal(measures)?.message).toContain( + `[dataset-compiler] dataset "os27187_guard" derived measure "eur_as_usd" declares currency "USD" over ` + + 'measures "eur", "eur_restated", whose amounts are fixed to EUR.', + ); + expect(compileRefusal(measures)?.message) + .toContain("delete the measure's `currency` (the column takes the operands' EUR)"); + }); + } + + it('a derived measure declaring a currency over a fixed-code derived sum is judged too', () => { + expectRefused( + [...base, { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur'] } }, { name: 'eur_as_jpy', derived: { op: 'sum', of: ['eur_twice', 'eur'] }, currency: 'JPY' }], + 'eur_as_jpy', + ); + }); + + it("CONTROL: a derived measure restating its operands' one fixed code is accepted", () => { + expect( + compileRefusal([...base, { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'EUR' }]), + ).toBeUndefined(); + }); + + it('CONTROL: a derived measure currency is not judged where its operands resolve to no one fixed code', () => { + expect( + compileRefusal([ + ...base, + // a fixed amount with the tenant currency: one currency, but not a `fixed` one + { name: 'usd_and_tenant_as_jpy', derived: { op: 'sum', of: ['usd', 'tenant'] }, currency: 'JPY' }, + // a ratio resolves to no currency + { name: 'eur_per_invoice_as_usd', derived: { op: 'ratio', of: ['eur', 'invoices'] }, currency: 'USD' }, + ]), + ).toBeUndefined(); + }); + it('CONTROL: a fixed operand restated by its measure currency compares by that one code', () => { expect( compileRefusal([...base, { name: 'usd_restated', aggregate: 'sum', field: 'amt_usd', currency: 'USD' }, { name: 'both', derived: { op: 'sum', of: ['usd', 'usd_restated'] } }]), @@ -400,6 +442,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { const fixedShapes = [ ['a measure currency contradicting a fixed field', [{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy'], ['a derived measure across two different fixed codes', [...base, { name: 'mixed', derived: { op: 'difference', of: ['usd', 'eur'] } }], 'mixed'], + ["a derived measure currency contradicting its operands' fixed code", [...base, { name: 'eur_as_usd', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'USD' }], 'eur_as_usd'], ] as const; for (const [label, measures, named] of fixedShapes) { for (const [door, options] of [['queryDataset', undefined], ['the draft preview', { previewDrafts: true }]] as const) { diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts index 54b80f02b19..5b4faee7b60 100644 --- a/packages/services/service-analytics/src/measure-currency-coherence.ts +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -17,7 +17,10 @@ * field): its column carries no per-row pointer, so the one code would * label every row. * - Over a `fixed` field, a code other than the field's. The same code is - * redundant, not wrong, and is accepted. + * redundant, not wrong, and is accepted. That holds for a derived measure + * whose resolved currency is one `fixed` code (a sum of `fixed` amounts in + * one code): `computeDerived` keeps the operands' amounts, so the measure's + * code would label them as another currency. * 2. **A derived measure across currencies.** `computeDerived` adds, * subtracts, multiplies and divides bare numbers, so such a value is not a * number in any currency (probe A, `m_mixed_sum`: `200` with no currency). @@ -147,7 +150,8 @@ export function assertMeasureCurrenciesCoherent( }); // Guard 1 — a measure `currency` over a record-mode amount (aggregated or - // derived), or contradicting a `fixed` field's code. + // derived), or contradicting a `fixed` code (a field's, or the one its + // derived operands resolve to). for (const m of dataset.measures) { if (m.duration || !m.currency) continue; const field = m.derived ? undefined : fieldOf(m); @@ -176,6 +180,15 @@ export function assertMeasureCurrenciesCoherent( + "currencies: delete the measure's `currency`.", ); } + if (resolved?.kind === 'code' && resolved.fixed && resolved.code !== m.currency) { + const operands = [...new Set(monetaryOperands(m).map((o) => `"${o.name}"`))].join(', '); + throw datasetInvalidError( + `[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency ` + + `"${m.currency}" over measures ${operands}, whose amounts are fixed to ${resolved.code}. The amounts are ` + + `${resolved.code}, so the column would print them as ${m.currency}: delete the measure's \`currency\` ` + + `(the column takes the operands' ${resolved.code}).`, + ); + } } // Guard 2 — a derived measure combining a record-mode amount with another From dc86f2a26809015e45c948c0c1086d6e2418feb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:39:19 +0000 Subject: [PATCH 2/2] fix(service-analytics): the derived-measure refusal prescribes what the column really carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A derived column takes no currency code from its operands on the wire, so deleting the measure's currency leaves it unlabelled. The message now prescribes restating the operands' code, or deleting it with that consequence named. Changeset Clause-② reads no (narrowing). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL --- .changeset/28019-derived-measure-fixed-currency.md | 4 ++-- .../src/__tests__/record-currency-analytics.test.ts | 5 +++-- .../service-analytics/src/measure-currency-coherence.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/28019-derived-measure-fixed-currency.md b/.changeset/28019-derived-measure-fixed-currency.md index 6cd43e0dc2a..018c5dbcb10 100644 --- a/.changeset/28019-derived-measure-fixed-currency.md +++ b/.changeset/28019-derived-measure-fixed-currency.md @@ -4,12 +4,12 @@ fix(service-analytics): a dataset refuses a derived measure `currency` that contradicts the one `fixed` code its operands resolve to -Clause-②: yes (narrowing) +Clause-②: no (narrowing) — not a widening; a dataset that compiled before is refused now Metadata-protocol: narrowed **BREAKING** (`@objectstack/service-analytics`): one more compile-time refusal, `DATASET_INVALID` / 400, for dataset metadata that compiled before and printed a wrong label. It reaches boot registration, `queryDataset` and the Studio draft preview, which compile through the one `compileDataset` call. -- A derived `sum` / `difference` whose monetary operands all resolve to one `fixed` code, directly or through a derived measure built on them, and which declares its own `currency` with another code. The column printed the operands' amounts under the declared code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures printing euros labelled `USD`. The message names the dataset, the measure, its operands and their code, and the fix: delete the measure's `currency`, and the column takes the operands' code. +- A derived `sum` / `difference` whose monetary operands all resolve to one `fixed` code, directly or through a derived measure built on them, and which declares its own `currency` with another code. The column printed the operands' amounts under the declared code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures printing euros labelled `USD`. The message names the dataset, the measure, its operands and their code, and the fix: restate the operands' code as the measure's `currency`, or delete it (the column then carries no currency code; a derived column takes no code from its operands on the wire). - A derived measure `currency` that restates the operands' `fixed` code is still accepted. Not judged, unchanged: a derived measure whose operands resolve to no one `fixed` code (a `fixed` amount with a tenant-currency one, a `ratio` or `product`), and the record-mode and mixed-code refusals of both guards. diff --git a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts index 980bcede5b0..9fa94d73de9 100644 --- a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts +++ b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts @@ -21,7 +21,8 @@ * column-description seam. * - The two compile-time guards (`measure-currency-coherence.ts`): * `DATASET_INVALID` / 400 for a measure `currency` over a record-mode amount - * or contradicting a `fixed` field's code, and for a derived measure + * or contradicting a `fixed` field's code or a derived measure's operands' + * one `fixed` code, and for a derived measure * combining a record-mode amount with another currency or two different * `fixed` codes. The `fixed` arms reach boot registration, `queryDataset` * and the draft preview alike (ruling 6486427200 on #8345). @@ -383,7 +384,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { + 'measures "eur", "eur_restated", whose amounts are fixed to EUR.', ); expect(compileRefusal(measures)?.message) - .toContain("delete the measure's `currency` (the column takes the operands' EUR)"); + .toContain("restate the operands' EUR as the measure's `currency`, or delete it (the column then carries no currency code)"); }); } diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts index 5b4faee7b60..b070dd36b7d 100644 --- a/packages/services/service-analytics/src/measure-currency-coherence.ts +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -185,8 +185,8 @@ export function assertMeasureCurrenciesCoherent( throw datasetInvalidError( `[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency ` + `"${m.currency}" over measures ${operands}, whose amounts are fixed to ${resolved.code}. The amounts are ` - + `${resolved.code}, so the column would print them as ${m.currency}: delete the measure's \`currency\` ` - + `(the column takes the operands' ${resolved.code}).`, + + `${resolved.code}, so the column would print them as ${m.currency}: restate the operands' ` + + `${resolved.code} as the measure's \`currency\`, or delete it (the column then carries no currency code).`, ); } }