Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions .changeset/30178-numeric-screen-fields-refuse-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-automation': patch
'@object-ui/app-shell': patch
---

fix(automation)!: a `currency` or `percent` screen field refuses a non-number on resume, as a `number` field does, and the console renders `percent` as a numeric input (#30178)

Clause-②: yes (narrowing) — `POST …/runs/RUN/resume` refuses a string under a `type: 'currency'` or `type: 'percent'` screen field, a bag it accepted before. Nothing is widened.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored row changes: ScreenFieldConfigSchema keeps its shape, and the screen-field type stays an open widget hint. What narrows is the resume door's accept set for one value shape on two existing members, so there is nothing at rest for a migration to rewrite; the caller sends a JSON number. -->

**BREAKING** accept-set narrowing at the screen-flow resume door.

- **Before.** A `type: 'percent'` or `type: 'currency'` screen field took any string on resume: `"75"` under a `max` of 60, `"abc"` too. The bound compares numbers, so it never judged a string, and the run consumed its suspension on the value. The console rendered `percent` as a text input and posted a string, so a `percent` field's `min` / `max` held for no console user.
- **Now.** `number`, `currency` and `percent` are one value domain: a present value that is not a finite JSON number is refused with `invalid_type` inside `400 INVALID_SCREEN_INPUT`, the suspension is kept, and a corrected resume still lands. A numeric string is not read as the number it spells. The console renders `percent` as a numeric input carrying `min` / `max`, so it posts a number and the browser stops an over-limit value before any request.
- **Unchanged.** An absent value and a hidden field are not judged; `rating`, `slider`, `progress`, `text` and untyped fields still take a string; a JSON number on `currency` / `percent` is judged against the bound as before.
- **For a caller that skips the console.** The resume bag carries the JSON number (`60`), not a string; the refusal names the field and says it must be a number. No stored metadata changes, so there is nothing for `objectstack migrate meta` to rewrite.
12 changes: 12 additions & 0 deletions .changeset/30178-screen-field-numeric-kinds-contract-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': patch
---

docs(spec)!: the screen-field contract names `number`, `currency` and `percent` as the numeric value domain the resume enforces (#30178)

Clause-②: yes (narrowing) — the resume door refuses a string under a `type: 'currency'` or `type: 'percent'` screen field (`@objectstack/service-automation`); this package's `ScreenFieldSpec` and `ScreenFieldConfigSchema` text now states that rule. No schema shape changes.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) Only TSDoc and one describe string change: ScreenFieldConfigSchema and ScreenFieldSpec keep every key and type, so nothing an author wrote is refused at parse time and nothing at rest moves. The narrowing they describe is the resume door's, on a submitted value, which no migration can rewrite. -->

**BREAKING** (described here, enforced in `@objectstack/service-automation`): a `currency` or `percent` screen field takes a finite JSON number on resume, as a `number` field does; a numeric string is refused with `invalid_type`, never coerced. `ScreenFieldSpec.type` / `.min` / `.max` and `ScreenFieldConfigSchema.type` / `.min` / `.max` say so, and the `type` describe text names the three kinds.
19 changes: 11 additions & 8 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -749,11 +749,12 @@ field's declared contract is the only contract behind it — there is no object
schema to catch a bad bag downstream.

That sentence carries no "if the value is a number" qualifier, because the
shape is checked first: on a `type: 'number'` field a submitted value that is
not a JSON number is **refused** on resume with `invalid_type`. `"25"` — the
same quantity as a *string* — is **not coerced** into `25` and does not reach
the bound; it is rejected as the wrong shape. The bound then compares numbers
only, which is all a bound can do.
shape is checked first: on a numeric field — `type: 'number'`, `'currency'` or
`'percent'` — a submitted value that is not a JSON number is **refused** on
resume with `invalid_type`. `"25"` — the same quantity as a *string* — is **not
coerced** into `25` and does not reach the bound; it is rejected as the wrong
shape. The bound then compares numbers only, which is all a bound can do. The
console renders all three kinds as a numeric input, which posts a number.

**What these keys refuse:**

Expand Down Expand Up @@ -784,7 +785,8 @@ only, which is all a bound can do.
- **A bound on a non-numeric field.** A screen field's `type` has no closed
vocabulary, so this schema cannot judge which types a bound is meaningful on.
A bound on a `text` field constrains nothing and is not an error — the shape
check above reads `type: 'number'` alone, never the presence of a bound.
check above reads the three numeric kinds alone (not `rating`, `slider` or
`progress`), never the presence of a bound.
- **An absent value.** A bound constrains a value that is present — an optional
bounded field left empty is conformant. Presence is `required`'s question.
- **A bound on a field the user was never shown.** Like `required`, a bound on a
Expand Down Expand Up @@ -1302,8 +1304,9 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume
```

These conditions are refused, all reported at once and all with
`code: 'INVALID_SCREEN_INPUT'` (the value shapes of `type: 'number'` and
`type: 'file'`, and the `min` / `max` bounds, are described with those fields
`code: 'INVALID_SCREEN_INPUT'` (the value shapes of the numeric kinds
`number` / `currency` / `percent` and of `type: 'file'`, and the `min` / `max`
bounds, are described with those fields
above):

- a **`required` field the caller was actually asked for** is missing (an empty
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
Expand All @@ -311,7 +311,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => {
});

it('reads only the closed value-domain members as value domains — every other widget hint stays open', () => {
// The closure is a closed set of members of an open vocabulary (`number`,
// and since #25801 `file` — `screen-input-file-field.test.ts`), not a new
// The closure is a closed set of members of an open vocabulary (the
// numeric kinds — `number`, and since #30178 `currency` / `percent`, pinned
// below — and since #25801 `file`, `screen-input-file-field.test.ts`), not a new
// rule that every `type` constrains its value. A bound on a non-numeric
// field still constrains nothing, exactly as `FieldSchema.min`/`.max` do.
const texty: ScreenFieldSpec = { name: 'discount', type: 'text', min: 0, max: 20 };
Expand Down Expand Up @@ -126,3 +127,52 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => {
expect(issues.map((i) => i.code).sort()).toEqual(['max_value', 'required', 'unknown_field']);
});
});

// [#30178] `currency` and `percent` joined `number` in the value domain, by
// ruling, under ruling A′'s own rule: a present value that is not a finite JSON
// number is refused with `invalid_type`, ⛔ never coerced. Before it, a
// `percent` field (which the console rendered as text and posted as a string)
// and a `currency` field took ANY string — `"75"` under a `max` of 60, `"abc"`
// too — so the run consumed its suspension on a value the bound never judged.
describe('validateScreenInputs — `currency` and `percent` are numeric value domains (#30178)', () => {
for (const type of ['percent', 'currency'] as const) {
const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 };
const codes = (value: unknown, visibility: () => boolean | undefined = ALWAYS_VISIBLE) =>
validateScreenInputs([field], { discount: value }, visibility).map((i) => i.code);

it(`refuses a string on a \`${type}\` field — out of bound, in bound or not numeric at all`, () => {
// ⛔ Not coerced: `"75"` is refused for its SHAPE, not judged against the
// bound as the 75 it spells, and an in-bound `"60"` is refused the same.
for (const value of ['75', '60', 'abc']) {
expect(codes(value), `value ${JSON.stringify(value)}`).toEqual(['invalid_type']);
}
const [issue] = validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE);
expect(issue!.field).toBe('discount');
expect(issue!.message).toContain('must be a number');
});

it(`judges a number on a \`${type}\` field against its bound, endpoints included`, () => {
expect(codes(75)).toEqual(['max_value']);
expect(codes(-1)).toEqual(['min_value']);
for (const value of [0, 60]) expect(codes(value), `value ${value}`).toEqual([]);
});

it(`leaves an absent value and a hidden \`${type}\` field alone`, () => {
expect(codes(undefined)).toEqual([]);
expect(codes('')).toEqual([]);
const conditional: ScreenFieldSpec = { ...field, visibleWhen: 'wantsDiscount == true' };
expect(validateScreenInputs([conditional], { discount: 'abc' }, () => false)).toEqual([]);
expect(validateScreenInputs([conditional], { discount: 'abc' }, () => undefined)).toEqual([]);
// …and the control, or the two readings above prove nothing.
expect(validateScreenInputs([conditional], { discount: 'abc' }, () => true).map((i) => i.code))
.toEqual(['invalid_type']);
});
}

it('does not widen past the numeric kinds — `rating`, `slider`, `progress`, `text` and untyped still take a string', () => {
for (const type of ['rating', 'slider', 'progress', 'text', undefined]) {
const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 };
expect(validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE), `type ${String(type)}`).toEqual([]);
}
});
});
42 changes: 31 additions & 11 deletions packages/services/service-automation/src/screen-input-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ function isPresent(v: unknown): boolean {
return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '');
}

/** A finite JSON number: the one value the numeric screen-field kinds admit. */
function isFiniteNumber(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value);
}

/**
* The closed set of screen-field `type` members that are VALUE domains, each
* with the predicate a present value must meet and the sentence a value that
Expand All @@ -67,7 +72,13 @@ function isPresent(v: unknown): boolean {
*/
const SCREEN_FIELD_VALUE_DOMAINS: ReadonlyMap<string, { admits: (value: unknown) => boolean; expected: string }> =
new Map([
['number', { admits: (value) => typeof value === 'number' && Number.isFinite(value), expected: 'a number' }],
// The three numeric kinds, on one predicate: the same group the object
// protocol names (`MEASURE_FIELD_TYPES`). `number` is ruling A′; `currency`
// and `percent` joined it by ruling (#30178). A finite JSON number only, ⛔
// never a numeric string read as one.
['number', { admits: isFiniteNumber, expected: 'a number' }],
['currency', { admits: isFiniteNumber, expected: 'a number' }],
['percent', { admits: isFiniteNumber, expected: 'a number' }],
// [#25801] The uploaded `sys_file` id — ADR-0104 D3's stored form — judged
// by the spec's one arbiter, the predicate the record write's claim path
// reads too, so an id this door admits is one a later write can claim.
Expand Down Expand Up @@ -111,8 +122,9 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
*
* Enforced, and nothing beyond it:
* - `required` presence for every field the caller was actually asked for;
* - the VALUE SHAPE of a `type: 'number'` field (#17306) and of a
* `type: 'file'` field (#25801);
* - the VALUE SHAPE of a numeric field — `type: 'number'` (#17306),
* `'currency'` or `'percent'` (#30178) — and of a `type: 'file'` field
* (#25801);
* - the declared numeric bound pair `min` / `max` (#17306);
* - no value at all under a `type: 'record_link'` field;
* - undeclared keys.
Expand All @@ -130,9 +142,12 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
* Value SHAPE (`type`) is out of scope here with ONE closed exception, and the
* exception is the maintainer's (ruling A′, 2026-09-13): a screen field's
* `type` is an open widget hint, so no member of it constrains a value —
* EXCEPT `'number'`, whose meaning is not a rendering preference but a value
* domain the bound pair beside it already assumes. A present value for a
* `type: 'number'` field that is not a finite JSON number is refused with
* EXCEPT the numeric kinds, whose meaning is not a rendering preference but a
* value domain the bound pair beside it already assumes. Ruling A′ named
* `'number'`; `'currency'` and `'percent'` joined it under the same rule by a
* later ruling (#30178), so the three numeric kinds the object protocol groups
* (`MEASURE_FIELD_TYPES`) answer one question one way. A present value for a
* numeric field that is not a finite JSON number is refused with
* `invalid_type`, the catalog's existing member for a value of the wrong
* primitive type (ADR-0114 D2 — no new code, and deliberately not
* `invalid_number`, which names a PARSE failure rather than a type mismatch).
Expand All @@ -143,9 +158,12 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
* That closes the gap the bound pass alone left open. The bound pass compares
* numbers, so before this every non-number slipped past BOTH — under a `max`
* of 20, `"25"` was conformant. The refusal now lands on the shape, and the
* bound pass is left comparing numbers only, which is all it can do.
* bound pass is left comparing numbers only, which is all it can do. Until
* `percent` joined, the console posted it as a string, so a `percent` field's
* bound held for no console user at all; the console now gives it the numeric
* input `number` and `currency` have.
*
* ⚠️ Narrow on purpose in two directions. It keys off `type: 'number'`, not
* ⚠️ Narrow on purpose in two directions. It keys off the numeric `type`, not
* off the presence of a bound: a bound declared on a non-numeric field still
* constrains nothing (`FieldSchema.min`/`.max` are unconditioned on `type`
* too). And it is a PRESENCE-conditioned check, like the bound: an absent
Expand All @@ -167,7 +185,8 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
* write's question, asked there under the write's own caller (#25692) — not
* this door's, which holds no storage service.
*
* No other widget hint is read as a value domain: the set is
* No other widget hint is read as a value domain — `rating`, `slider` and
* `progress` included, which the console has no numeric input for: the set is
* {@link SCREEN_FIELD_VALUE_DOMAINS}, and a member joins it by ruling.
*
* `'record_link'` is the one member that takes NO value (ruling B on the
Expand Down Expand Up @@ -206,8 +225,9 @@ export function validateScreenInputs(
});
}

// Value shape for a value-domain `type` — `'number'` (#17306, ruling A′) and
// `'file'` (#25801). Its own pass, BEFORE the bound: a value that is not a
// Value shape for a value-domain `type` — the numeric kinds (`'number'`,
// #17306 ruling A′; `'currency'` / `'percent'`, #30178) and `'file'`
// (#25801). Its own pass, BEFORE the bound: a value that is not a
// number cannot be compared against one, so the shape is the first thing
// true or false about it. A caller that posts `"25"` under a `max` of 20 gets
// one issue naming the shape, not a silent pass and not a bound verdict
Expand Down
Loading
Loading