Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
59ac9e8
feat(spec): currencyMode 'record' + currencyField — a record-mode amo…
claude Oct 28, 2026
2537aff
chore(spec): regenerate artifacts and declare the record-mode refinem…
claude Oct 28, 2026
1d9a533
chore(spec): dropped-refinements ledger header totals for the record-…
claude Oct 28, 2026
d1316dd
docs(skills): field-types — the record currency mode and its sibling-…
claude Oct 28, 2026
5232c8b
chore(platform-objects): metadata-form translation bundles for the re…
claude Oct 28, 2026
696c3f2
feat(objectql): record-currency guards — blank code refuses the write…
claude Oct 28, 2026
285f047
test(dogfood): record-currency PROBE-A / PROBE-R on both SQLite drivers
claude Oct 28, 2026
9416158
Merge origin/main (#27218 landed) into the record-mode branch
claude Oct 28, 2026
a195484
chore(changeset): record-currency guards
claude Oct 28, 2026
a9f5abf
chore(spec): regenerate artifacts after bringing main in
claude Oct 28, 2026
77bc11a
chore(spec): regenerate the reference pages for ERR_CURRENCY_CODE_REQ…
claude Oct 28, 2026
901cbb0
test(objectql,dogfood): record-currency suites type their query optio…
claude Oct 28, 2026
4201bb5
test(objectql): the record-currency find double applies the limit bef…
claude Oct 28, 2026
da1ebc7
fix(i18n): resolveFieldCurrency accepts the record currency mode — th…
claude Oct 28, 2026
f174d4b
test(platform-objects): move the metadata-form catalog pins for the c…
claude Oct 28, 2026
bf7f2b7
fix(i18n): a record-mode field resolves no currency, never the tenant…
claude Oct 28, 2026
e527c3b
Merge origin/main (#27233, #27239) into claude/project-thread-0wmk7j
claude Oct 29, 2026
9011464
chore(spec): regenerate artifacts after bringing main in
claude Oct 29, 2026
3084024
Merge commit '90114641aeaeaa9a71a153be5051bba23326e8ad' into claude/p…
claude Oct 29, 2026
d36d8b7
chore(spec): regenerate the reference pages after bringing #27268 in
claude Oct 29, 2026
f3d286c
fix(spec): record currency mode refuses an empty pointer and an inert…
claude Oct 29, 2026
b6f152b
docs(spec): three-mode currency help texts, translated; the skill's c…
claude Oct 29, 2026
cf5be50
Merge commit 'b6f152bd17b1d7e70bed8c6b59098b3353f38691' into claude/p…
claude Oct 29, 2026
2749517
fix(objectql): the validate() preview refuses a blank record-currency…
claude Oct 29, 2026
3134286
Merge commit '7614f8f05ca626b13d1e75320fe4f04d9b404f4e' into claude/p…
claude Oct 29, 2026
45e6b26
chore(spec): regenerate artifacts after bringing main in
claude Oct 29, 2026
9d9f7ea
Merge commit 'f7188b562bb718217c9122b6c4874fcd11a5a105' into claude/p…
claude Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/27186-record-currency-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/objectql': patch
'@objectstack/formula': patch
'@objectstack/spec': patch
---

feat(objectql): a record-currency amount is refused without its code, refused in an ungrouped raw aggregate, and refused under a summary roll-up

Clause-②: yes (widening)
Metadata-protocol: none

- **A `record`-mode amount is stored with its currency code (ADR-0144 D8 rule 4).** An insert or update that would store a value in a `currency` field declaring `currencyConfig: { currencyMode: 'record', currencyField }` while that code field is blank (absent, `null` or white space) is refused `ERR_CURRENCY_CODE_REQUIRED` / 400, with `field` naming the code field and `amountField` the amount. The server never fills a currency in. On an update, only a write that carries the amount or its code is judged, against the stored row with the payload laid over it, so an edit of another field never meets it. The `validate()` preview (`validateData`) answers the same refusal as the row's finding for an insert and for a by-id update, so a dry run does not pass a row the write refuses. The code is registered under `@objectstack/objectql` in the ADR-0112 error-code ledger.
- **A raw aggregate over a `record`-mode amount groups by its code field (ADR-0144 D6).** `sum`, `avg`, `min` and `max` over such a field through `engine.aggregate` are refused `INVALID_FIELD` / 400 unless `groupBy` names the code field; grouped, each currency totals on its own. `count` and `count_distinct` are not judged. Before, USD 100 and EUR 100 summed to 200 under one label.
- **A `summary` roll-up over a `record`-mode amount is refused at registration (ADR-0144 D6).** `sum` / `avg` / `min` / `max` of such a child field is refused `INVALID_METADATA` / 422 (`RecordCurrencySummaryRefusedError`) at whichever object's registration completes the pair. Before, child rows of USD 100 and JPY 15000 rolled up to 15100. Use `count`, or roll up a stored home-currency amount.
- **`inferFormulaReturn` carries a record currency (`@objectstack/formula`).** A formula over a `record`-mode amount now declares `returnType: 'currency'` with `currencyConfig: { currencyMode: 'record', currencyField }`, the code field its sources name, instead of a tenant-default (`dynamic`) amount. Two record-mode amounts naming different code fields, or a record-mode amount meeting a `fixed` or `dynamic` one, are not one currency and are not stamped as money.
- **Nothing to migrate.** `currencyMode: 'record'` is new; no field declared before it meets any of these refusals.
3 changes: 2 additions & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ const result = AutomationApiErrorCode.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +349 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +350 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |

### Allowed Values: `FlowInnerRefusal.code`
Expand Down Expand Up @@ -479,6 +479,7 @@ const result = AutomationApiErrorCode.parse(data);
* `ERR_AUTONUMBER_COLLISION`
* `ERR_BULK_RESULT_MISMATCH`
* `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`
* `ERR_CURRENCY_CODE_REQUIRED`
* `ERR_DATASOURCE_UNAVAILABLE`
* `ERR_DRIVER_CONNECT`
* `ERR_FILE_CONSTRAINT`
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +349 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +350 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -167,6 +167,7 @@ const result = ApiErrorSchema.parse(data);
* `ERR_AUTONUMBER_COLLISION`
* `ERR_BULK_RESULT_MISMATCH`
* `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`
* `ERR_CURRENCY_CODE_REQUIRED`
* `ERR_DATASOURCE_UNAVAILABLE`
* `ERR_DRIVER_CONNECT`
* `ERR_FILE_CONSTRAINT`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ const result = ErrorCode.parse(data);
* `ERR_AUTONUMBER_COLLISION`
* `ERR_BULK_RESULT_MISMATCH`
* `ERR_CROSS_DATASOURCE_TRANSACTION_WRITE`
* `ERR_CURRENCY_CODE_REQUIRED`
* `ERR_DATASOURCE_UNAVAILABLE`
* `ERR_DRIVER_CONNECT`
* `ERR_FILE_CONSTRAINT`
Expand Down
37 changes: 37 additions & 0 deletions packages/formula/src/formula-return.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,40 @@ describe('CONTROL — a numeric formula stays `number`', () => {
expect(inferFormulaReturn('record.amount *', FIELDS)).toEqual({});
});
});

describe('a formula over a record-mode amount carries the record currency (ADR-0144 D2)', () => {
const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const;
const RECORD_FIELDS: Readonly<Record<string, FormulaSourceField>> = {
currency_code: { type: 'text' },
other_code: { type: 'text' },
amount: { type: 'currency', currencyConfig: RECORD },
tax: { type: 'currency', currencyConfig: RECORD },
fee: { type: 'currency', currencyConfig: { currencyMode: 'record', currencyField: 'other_code' } },
budget: { type: 'currency' },
usd: { type: 'currency', currencyConfig: USD },
quantity: { type: 'number' },
};

it('stamps `record` mode with the source amount\'s code field, never the tenant default', () => {
expect(inferFormulaReturn('record.amount * 0.1', RECORD_FIELDS))
.toEqual({ returnType: 'currency', currencyConfig: RECORD });
expect(inferFormulaReturn('record.amount + record.tax', RECORD_FIELDS))
.toEqual({ returnType: 'currency', currencyConfig: RECORD });
expect(inferFormulaReturn('round(record.amount * record.quantity)', RECORD_FIELDS))
.toEqual({ returnType: 'currency', currencyConfig: RECORD });
});

it('an amount over its own currency is a plain ratio', () => {
expect(inferFormulaReturn('record.tax / record.amount', RECORD_FIELDS).returnType).not.toBe('currency');
});

it('two different currencies meeting are not one amount', () => {
for (const source of [
'record.amount + record.fee', // two code fields
'record.amount + record.budget', // record + the tenant default
'record.amount + record.usd', // record + fixed USD
]) {
expect(inferFormulaReturn(source, RECORD_FIELDS).returnType, source).not.toBe('currency');
}
});
});
51 changes: 38 additions & 13 deletions packages/formula/src/formula-return.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,25 @@ export interface FormulaSourceField {
*
* - `returnType` — absent when no concrete type can be proven.
* - `currencyConfig` — present only for a `currency` result whose currency is
* `fixed`. A `dynamic` result (the tenant default currency) declares no config,
* exactly as a `dynamic` currency field may carry none.
* `fixed`, or `record` (ADR-0144 D2: each record's own currency, carried as
* the same `currencyField` its source amounts name). A `dynamic` result (the
* tenant default currency) declares no config, exactly as a `dynamic`
* currency field may carry none.
*/
export interface FormulaReturnDeclaration {
readonly returnType?: NonNullable<Field['returnType']>;
readonly currencyConfig?: { readonly currencyMode: 'fixed'; readonly defaultCurrency: string };
readonly currencyConfig?:
| { readonly currencyMode: 'fixed'; readonly defaultCurrency: string }
| { readonly currencyMode: 'record'; readonly currencyField: string };
}

type MoneyCurrency = { readonly mode: 'dynamic' } | { readonly mode: 'fixed'; readonly code: string };
type MoneyCurrency =
| { readonly mode: 'dynamic' }
| { readonly mode: 'fixed'; readonly code: string }
// [ADR-0144 D2] Each record's own currency, the code in the sibling field
// `field` names. Two record-mode amounts are one currency only when they
// name the same code field.
| { readonly mode: 'record'; readonly field: string };

type Dim =
| { readonly kind: 'money'; readonly currency: MoneyCurrency }
Expand Down Expand Up @@ -125,8 +135,9 @@ function isNode(v: unknown): v is Node {
}

function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean {
if (a.mode === 'dynamic' || b.mode === 'dynamic') return a.mode === b.mode;
return a.code === b.code;
if (a.mode === 'fixed' && b.mode === 'fixed') return a.code === b.code;
if (a.mode === 'record' && b.mode === 'record') return a.field === b.field;
return a.mode === b.mode;
}

/**
Expand All @@ -137,8 +148,16 @@ function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean {
function currencyOf(config: unknown): MoneyCurrency | null {
const parsed = CurrencyConfigSchema.safeParse(config ?? {});
if (!parsed.success) return null;
if (parsed.data.currencyMode !== 'fixed') return { mode: 'dynamic' };
return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency };
if (parsed.data.currencyMode === 'fixed') {
return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency };
}
// [ADR-0144 D2] A `record`-mode amount is in the currency its sibling code
// field holds — never the tenant default a `dynamic` stamp would claim. The
// schema requires the pointer under `record`, so a parsed config carries it.
if (parsed.data.currencyMode === 'record') {
return parsed.data.currencyField ? { mode: 'record', field: parsed.data.currencyField } : null;
}
return { mode: 'dynamic' };
}

/** Is `d` a number that is not an amount (a quantity or a constant)? */
Expand Down Expand Up @@ -256,8 +275,10 @@ function sourceOf(input: ExprInput): string | null {
*
* - Provably money ⇒ `{ returnType: 'currency' }`, plus
* `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when the
* source amounts are in one fixed currency. Sources in two different
* currencies are not provably one amount, and fall through.
* source amounts are in one fixed currency, or
* `currencyConfig: { currencyMode: 'record', currencyField }` when they are
* record-mode amounts naming one code field (ADR-0144 D2). Sources in two
* different currencies are not provably one amount, and fall through.
* - Otherwise ⇒ the {@link inferExpressionType} answer as `returnType`
* (`number` / `text` / `boolean` / `date`), or `{}` when that is `unknown` —
* unchanged from before this function existed, so a numeric formula stays
Expand All @@ -281,9 +302,13 @@ export function inferFormulaReturn(
if (celType !== null && MONEY_CEL_TYPES.has(celType)) {
const dim = dimOf(parseCelToAst(source), fields);
if (dim.kind === 'money') {
return dim.currency.mode === 'fixed'
? { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } }
: { returnType: 'currency' };
if (dim.currency.mode === 'fixed') {
return { returnType: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: dim.currency.code } };
}
if (dim.currency.mode === 'record') {
return { returnType: 'currency', currencyConfig: { currencyMode: 'record', currencyField: dim.currency.field } };
}
return { returnType: 'currency' };
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions packages/objectql/src/engine-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStringValues, ValidationError, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js';
import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js';
import { assertRecordCurrencyCodes, RecordCurrencyCodeRequiredError } from './validation/record-currency-code.js';
import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js';
import {
droppedFieldEvents,
Expand Down Expand Up @@ -393,11 +394,15 @@ export async function validate(
// `undefined` = no stored row: the row is judged on the patch alone, as
// it was before these reads existed.
const storedRows: Array<Record<string, unknown> | undefined> = [];
// The by-id address each stored row was read from, for a refusal that names the record.
const storedIds: unknown[] = [];
for (const submitted of submittedRows) {
let stored: Record<string, unknown> | undefined;
let storedId: unknown;
if (mode === 'update' && submitted && typeof submitted === 'object') {
const dispatch = resolveEngineUpdateDispatch(submitted as EngineUpdateDispatchData, undefined);
if (dispatch.kind === 'by-id') {
storedId = dispatch.id;
const visible = await this.findOne(object, {
where: { id: dispatch.id },
context: { ...(options?.context ?? {}), [RAW_FILE_VALUES_CONTEXT_KEY]: true },
Expand All @@ -417,6 +422,7 @@ export async function validate(
}
}
storedRows.push(stored);
storedIds.push(storedId);
}
// The merged view each rule evaluates, and the one the reference reads
// below key on: the write reads a traversed FK off the same merge
Expand Down Expand Up @@ -543,6 +549,21 @@ export async function validate(
// preview admits exactly what the write would.
keptOptionValues: options?.context?.keptOptionValues,
});
// [ADR-0144 D8 rule 4] The write's blank-code refusal, at the write's
// step and on the write's image: the insert row as stored, or the stored
// row with the patch over it (`judgedViews`, the by-id update's
// `priorRecord ⊕ payload`), judging only the pairs the patch touches.
// An update with no stored row here (a predicate dispatch, an id this
// caller cannot read) is not judged: the image the write judges is a
// row the preview does not hold. A code column the read door withholds
// from this caller is absent from the stored row, so the preview reads
// it as blank — stricter than the write, never laxer.
if (mode === 'insert' || storedRows[i]) {
assertRecordCurrencyCodes(
object, (schemaForValidation as { fields?: unknown } | undefined)?.fields,
mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i],
);
}
warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, {
// [#22445] The stored row, when this row named one: the by-id
// update's `previous: priorRecord`. Absent, the record is the patch.
Expand Down Expand Up @@ -571,6 +592,10 @@ export async function validate(
if (e instanceof ValidationError) {
return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings };
}
// The write throws this refusal whole; the preview answers it as the row's finding.
if (e instanceof RecordCurrencyCodeRequiredError) {
return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings };
}
throw e;
}
batchSummaries?.accept(row);
Expand Down
Loading
Loading