Skip to content

Commit 4be9734

Browse files
fix(automation)!: a currency or percent screen field refuses a non-number on resume, and the console renders percent as a numeric input (#30269)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgPDswMz5dggCgdCaEMC4bNX)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Fixes #30178 Ruling-ref: 6648655128 Clause-②: yes (narrowing) — on ruling 6648655128 (letter A): a `percent` or `currency` screen field now refuses any non-number value with `invalid_type` (no coercion, A′ of 2026-09-13), where it passed every string before; owes a contract-review-tier review before the queue, a BREAKING banner and the ADR-0087 disposition marker. Amended on the ruling; the earlier reading is superseded. Metadata-protocol: none Skills-docs: none — `skills/**` teaches no screen-field value shape (the `objectstack-automation` screen row names `type` an open widget hint and states no `number` rule either), so nothing there turns false; `content/docs/automation/flows.mdx` is updated here. ## 维护者速读 - **改了什么:** 流程屏幕字段 `currency`、`percent` 并入 `number` 的数值值域:恢复时值不是有限 JSON 数字就拒(`invalid_type`,400 `INVALID_SCREEN_INPUT`,挂起保留),不把数字字符串当数字;控制台把 `percent` 渲染成数字输入,带上下限、提交数字;spec 契约文字与 flows 文档同步写成三种数值类型。 - **为什么改:** HotCRM 报价折扣上限 60:控制台把 `percent` 当文本发 `"75"`,客户端和引擎都不查字符串,运行吃掉挂起,下游规则拒单,销售只能关窗重来。Bill 裁定 A(6648655128):补全 A′,拒,不强转,控制台同 PR 改。 - **风险与回滚:** 收窄:往 `currency`/`percent` 屏幕字段 POST 字符串的 REST/MCP 调用方会被 400 拒(可重试);控制台本 PR 起发数字。不改 Zod 形状、不加错误码、不动存量数据。回滚即撤 PR。 - **先看:** `packages/services/service-automation/src/screen-input-contract.ts` 的值域表两行;`ui/packages/app-shell/src/views/ScreenView.tsx` 的 `htmlType` 一行;两个 changeset 的 BREAKING 段。 - **两分钟验证:** `pnpm --filter @objectstack/service-automation exec vitest run src/screen-input-contract.test.ts`,预期 17 个测试全绿,其中 `percent`/`currency` 的 `"75"`、`"60"`、`"abc"` 都得 `invalid_type`。 - **需要你做的:** 无,席位安排入队前契约复审后落地。 ## What changes **Engine (`@objectstack/service-automation`).** `SCREEN_FIELD_VALUE_DOMAINS` gains `currency` and `percent`, on `number`'s own predicate (a finite JSON number) and sentence (`must be a number`). A present value that is not one is refused with `invalid_type` inside `INVALID_SCREEN_INPUT` (400, retryable, the suspension kept), ⛔ never coerced: `"75"` under a `max` of 60 is refused for its shape, not judged as 75, and `"60"` is refused the same way. A JSON number on either kind is judged against the bound as before. `rating`, `slider`, `progress`, `text` and untyped fields still take a string (ruling: not widened past the numeric kinds). The module docstring now names the three numeric kinds as one value domain, the same group the object protocol names (`MEASURE_FIELD_TYPES`). **Console (`@object-ui/app-shell`), the one `ui/` file the ruling opens.** `ScreenView.tsx`: `percent` takes the numeric input path with `number` and `currency`, so it carries `min` / `max` as native attributes, the browser stops an over-limit value before any request, and the runner receives a number (the existing `Number(...)` on the numeric input), which the client bound check `screenFieldBoundViolations` already compares. No dedicated percent widget (ruling: the ui lane's later choice). **Spec prose (`@objectstack/spec`), no shape change.** `ScreenFieldSpec.type` / `.min` jsdoc (`contracts/automation-service.ts`), `ScreenFieldConfigSchema.type` / `.min` / `.max` jsdoc and the `type` `.describe()` (`automation/builtin-node-config.zod.ts`) say the three numeric kinds take a finite number on resume and a numeric string is refused. `content/docs/references/automation/builtin-node-config.mdx` is regenerated from the describe (`gen:schema && gen:docs`). Every key and type is unchanged. **Docs.** `content/docs/automation/flows.mdx`: the three sentences that named `type: 'number'` alone as the checked shape now name the three numeric kinds (the shape paragraph under the bound, the "a bound on a non-numeric field" bullet, and the refusal list's parenthesis). The ruling named "the sentence"; the other two would have been false after this PR. **Changesets.** `30178-numeric-screen-fields-refuse-strings.md` (`@objectstack/service-automation`, `@object-ui/app-shell`) and `30178-screen-field-numeric-kinds-contract-text.md` (`@objectstack/spec`): both `patch` (`Metadata-protocol: none` takes `patch`, AGENTS.md Post-Task Checklist step 3), both with the BREAKING banner, both declaration lines, and the ADR-0087 disposition `not-required (no-migration-prescription)`: no schema key, export or stored row moves, so there is nothing for `objectstack migrate meta` to rewrite. ## Tests - `screen-input-contract.test.ts`, new top-level `describe` (#30178), for each of `percent` and `currency`: `"75"`, `"60"`, `"abc"` → `invalid_type` (field named, message `must be a number`); `75` → `max_value`, `-1` → `min_value`, `0` and `60` → `[]`; absent and `''` → `[]`; a hidden field (`visibleWhen` false or unevaluable) is not judged, with the visible control; and `rating` / `slider` / `progress` / `text` / untyped still admit `"75"`. The closure comment in the existing "closed value-domain members" case is updated. - `ScreenView.boundHelpReference-9248.test.tsx` (the existing bound harness), new top-level `describe`: a `percent` field renders `type="number"` carrying `min="0"` / `max="60"`, typing `75` calls `onValueChange('discount', 75)` with a number, and `screenFieldBoundViolations` flags it. - **Ablations** (`scripts/ablation-replace.mjs`, restore proven by blob hash == HEAD and an empty `git diff HEAD`): 1. Delete the `currency` and `percent` rows → `screen-input-contract.test.ts` 4 failed | 13 passed (both "refuses a string" cases and both hidden-field controls); restored. 2. Delete ` || kind === 'percent'` from `ScreenView.tsx` → the new console pin fails (1 failed | 14 passed); restored. Both subjects are imported by relative path from `src/`, so no `dist/` leg applies. ## Acceptance notes - **Caller-facing narrowing.** A REST or MCP caller that posted a numeric string to a `currency` or `percent` screen field is now refused 400 `INVALID_SCREEN_INPUT` (`invalid_type`); it posts the JSON number. In-tree example flows declare no `currency` / `percent` screen field (`git grep`); the known declaration is hotcrm's Generate Quote `discount`, which re-measures on this pin (card body), nothing in that repository touched here. - **Changeset wording.** The first draft carried a metadata-style FROM → TO block for the caller's request bag; `check-adr-0087-registration` reads that label as a migration prescription and refuses `no-migration-prescription` beside it. The caller's fix is now a plain bullet in the body: nothing at rest moves, so the ledger has nothing to carry. - **Changeset level.** `patch` on all three packages, by the AGENTS.md rule (`Metadata-protocol: none` ⇒ `patch`, BREAKING banner and disposition kept). The ruling noted A′'s landing took `minor`; that landing declared `Metadata-protocol` kinds this one does not. The seat can re-level if the gates say otherwise. - **`ui/` scope.** Exactly `ScreenView.tsx` and its existing bound test harness; every other `ui/**` path untouched. ## Verification Head `3eb877a57` (`claude/project-thread-xq1cua`), `origin/main` merged once at `797f9257` (merge `0d70b581`); the last commit changes the two changeset bodies only, and the changeset gates were re-run on it. <details><summary>Package tests and type-checks</summary> - `pnpm --filter @objectstack/service-automation test`: exit 0. `pnpm --filter @objectstack/service-automation typecheck`: exit 0. - `screen-input-contract.test.ts` + `screen-input-file-field.test.ts`: 2 files, 23 tests passed. - `ui`: `pnpm exec vitest run packages/app-shell/src/views/ScreenView packages/app-shell/src/views/__tests__/FlowRunner` (under the verify lock): 22 files, 152 tests passed. - `pnpm --filter @objectstack/spec build` (declarations on), then `check:generated`: two artifacts stale, `content/docs/references/**` (regenerated, `gen:schema && gen:docs`, committed) and `check:test-typecheck`, which had failed without diagnostics at the 4096 MB heap and passed alone at 6144 MB (52 files / 240 errors / 131 signatures held, unchanged). `pnpm --filter @objectstack/spec typecheck`: exit 0. - NOT MEASURED: `pnpm --filter @object-ui/app-shell type-check` (exit 2, every error `TS2307 Cannot find module '@object-ui/…'`: the `ui/` sibling packages are not built in this worktree). `Type Check UI` on this PR is the reading. </details> <details><summary>Derived gates: 109 commands, 108 green, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 109 commands from the 10 changed paths at `0d70b581`; each ran with its exit code captured before any pipe, and `--ran` reconciles: 109 derived, 108 run, 1 NOT-MEASURED, 0 UNRUN. - Red on the first pass and fixed: `node scripts/check-adr-0087-registration.mjs --base origin/main` (both changesets carried a FROM → TO label beside `no-migration-prescription`; reworded, exit 0 on `3eb877a57`). - Exit 3 on the first pass, re-run green after their prerequisite: `check-plugin-teardown-shape --self-test` (shallow clone, after fetching its fixture commit), `spec check:skill-examples` (after `@objectstack/client` / `client-react` were built), and `pnpm --dir ui exec vitest run scripts/` (170 files / 4985 tests passed once `@objectstack/client` was built). - NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, needs a whole-workspace build). - Re-run on `3eb877a57`: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check-closing-keyword-parity`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check:issue-citations`, `check:doc-authoring`: all exit 0. - `pnpm lint`: exit 0. </details> <details><summary>Full dogfood (the Clause-② line carries the narrowing arm)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock at `0d70b581` (the closure built first): VERDICT command-exit 1, 20m44s. Test Files 1 failed | 418 passed | 3 skipped (422); Tests 1 failed | 3491 passed | 36 skipped (3528). The one failure is `test/permission-set-clone-door.dogfood.test.ts` › "a platform set … copies into its shipping package's pair" (`422 INVALID_METADATA`, `my_member_baseline` grants on `sys_comment`, which no package registers). It is main's own red: the merge base `797f9257` fails `Dogfood Regression Gate (2/3)` on `main`, filed as #30255 after PR #30222, with the revert open as PR #30262. It re-runs red alone (1 failed | 10 passed), and nothing in this diff touches permission sets or object registration. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZJd2mrcp8CfPkSEEs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5db77f7 commit 4be9734

10 files changed

Lines changed: 181 additions & 36 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
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)
7+
8+
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.
9+
Metadata-protocol: none
10+
11+
<!-- 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. -->
12+
13+
**BREAKING** accept-set narrowing at the screen-flow resume door.
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec)!: the screen-field contract names `number`, `currency` and `percent` as the numeric value domain the resume enforces (#30178)
6+
7+
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.
8+
Metadata-protocol: none
9+
10+
<!-- 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. -->
11+
12+
**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.

‎content/docs/automation/flows.mdx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -800,11 +800,12 @@ field's declared contract is the only contract behind it — there is no object
800800
schema to catch a bad bag downstream.
801801

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

809810
**What these keys refuse:**
810811

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

13551357
These conditions are refused, all reported at once and all with
1356-
`code: 'INVALID_SCREEN_INPUT'` (the value shapes of `type: 'number'` and
1357-
`type: 'file'`, and the `min` / `max` bounds, are described with those fields
1358+
`code: 'INVALID_SCREEN_INPUT'` (the value shapes of the numeric kinds
1359+
`number` / `currency` / `percent` and of `type: 'file'`, and the `min` / `max`
1360+
bounds, are described with those fields
13581361
above):
13591362

13601363
- a **`required` field the caller was actually asked for** is missing (an empty

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
289289
| :--- | :--- | :--- | :--- |
290290
| **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 }}` |
291291
| **label** | `string` | optional | Display label |
292-
| **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 |
292+
| **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 |
293293
| **required** | `boolean` | optional | Whether a value is required to submit |
294294
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
295295
| **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 |
@@ -311,7 +311,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
311311
| :--- | :--- | :--- | :--- |
312312
| **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 }}` |
313313
| **label** | `string` | optional | Display label |
314-
| **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 |
314+
| **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 |
315315
| **required** | `boolean` | optional | Whether a value is required to submit |
316316
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
317317
| **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 |

‎packages/services/service-automation/src/screen-input-contract.test.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => {
8989
});
9090

9191
it('reads only the closed value-domain members as value domains — every other widget hint stays open', () => {
92-
// The closure is a closed set of members of an open vocabulary (`number`,
93-
// and since #25801 `file` — `screen-input-file-field.test.ts`), not a new
92+
// The closure is a closed set of members of an open vocabulary (the
93+
// numeric kinds — `number`, and since #30178 `currency` / `percent`, pinned
94+
// below — and since #25801 `file`, `screen-input-file-field.test.ts`), not a new
9495
// rule that every `type` constrains its value. A bound on a non-numeric
9596
// field still constrains nothing, exactly as `FieldSchema.min`/`.max` do.
9697
const texty: ScreenFieldSpec = { name: 'discount', type: 'text', min: 0, max: 20 };
@@ -126,3 +127,52 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => {
126127
expect(issues.map((i) => i.code).sort()).toEqual(['max_value', 'required', 'unknown_field']);
127128
});
128129
});
130+
131+
// [#30178] `currency` and `percent` joined `number` in the value domain, by
132+
// ruling, under ruling A′'s own rule: a present value that is not a finite JSON
133+
// number is refused with `invalid_type`, ⛔ never coerced. Before it, a
134+
// `percent` field (which the console rendered as text and posted as a string)
135+
// and a `currency` field took ANY string — `"75"` under a `max` of 60, `"abc"`
136+
// too — so the run consumed its suspension on a value the bound never judged.
137+
describe('validateScreenInputs — `currency` and `percent` are numeric value domains (#30178)', () => {
138+
for (const type of ['percent', 'currency'] as const) {
139+
const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 };
140+
const codes = (value: unknown, visibility: () => boolean | undefined = ALWAYS_VISIBLE) =>
141+
validateScreenInputs([field], { discount: value }, visibility).map((i) => i.code);
142+
143+
it(`refuses a string on a \`${type}\` field — out of bound, in bound or not numeric at all`, () => {
144+
// ⛔ Not coerced: `"75"` is refused for its SHAPE, not judged against the
145+
// bound as the 75 it spells, and an in-bound `"60"` is refused the same.
146+
for (const value of ['75', '60', 'abc']) {
147+
expect(codes(value), `value ${JSON.stringify(value)}`).toEqual(['invalid_type']);
148+
}
149+
const [issue] = validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE);
150+
expect(issue!.field).toBe('discount');
151+
expect(issue!.message).toContain('must be a number');
152+
});
153+
154+
it(`judges a number on a \`${type}\` field against its bound, endpoints included`, () => {
155+
expect(codes(75)).toEqual(['max_value']);
156+
expect(codes(-1)).toEqual(['min_value']);
157+
for (const value of [0, 60]) expect(codes(value), `value ${value}`).toEqual([]);
158+
});
159+
160+
it(`leaves an absent value and a hidden \`${type}\` field alone`, () => {
161+
expect(codes(undefined)).toEqual([]);
162+
expect(codes('')).toEqual([]);
163+
const conditional: ScreenFieldSpec = { ...field, visibleWhen: 'wantsDiscount == true' };
164+
expect(validateScreenInputs([conditional], { discount: 'abc' }, () => false)).toEqual([]);
165+
expect(validateScreenInputs([conditional], { discount: 'abc' }, () => undefined)).toEqual([]);
166+
// …and the control, or the two readings above prove nothing.
167+
expect(validateScreenInputs([conditional], { discount: 'abc' }, () => true).map((i) => i.code))
168+
.toEqual(['invalid_type']);
169+
});
170+
}
171+
172+
it('does not widen past the numeric kinds — `rating`, `slider`, `progress`, `text` and untyped still take a string', () => {
173+
for (const type of ['rating', 'slider', 'progress', 'text', undefined]) {
174+
const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 };
175+
expect(validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE), `type ${String(type)}`).toEqual([]);
176+
}
177+
});
178+
});

‎packages/services/service-automation/src/screen-input-contract.ts‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ function isPresent(v: unknown): boolean {
5959
return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '');
6060
}
6161

62+
/** A finite JSON number: the one value the numeric screen-field kinds admit. */
63+
function isFiniteNumber(value: unknown): boolean {
64+
return typeof value === 'number' && Number.isFinite(value);
65+
}
66+
6267
/**
6368
* The closed set of screen-field `type` members that are VALUE domains, each
6469
* with the predicate a present value must meet and the sentence a value that
@@ -67,7 +72,13 @@ function isPresent(v: unknown): boolean {
6772
*/
6873
const SCREEN_FIELD_VALUE_DOMAINS: ReadonlyMap<string, { admits: (value: unknown) => boolean; expected: string }> =
6974
new Map([
70-
['number', { admits: (value) => typeof value === 'number' && Number.isFinite(value), expected: 'a number' }],
75+
// The three numeric kinds, on one predicate: the same group the object
76+
// protocol names (`MEASURE_FIELD_TYPES`). `number` is ruling A′; `currency`
77+
// and `percent` joined it by ruling (#30178). A finite JSON number only, ⛔
78+
// never a numeric string read as one.
79+
['number', { admits: isFiniteNumber, expected: 'a number' }],
80+
['currency', { admits: isFiniteNumber, expected: 'a number' }],
81+
['percent', { admits: isFiniteNumber, expected: 'a number' }],
7182
// [#25801] The uploaded `sys_file` id — ADR-0104 D3's stored form — judged
7283
// by the spec's one arbiter, the predicate the record write's claim path
7384
// reads too, so an id this door admits is one a later write can claim.
@@ -111,8 +122,9 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
111122
*
112123
* Enforced, and nothing beyond it:
113124
* - `required` presence for every field the caller was actually asked for;
114-
* - the VALUE SHAPE of a `type: 'number'` field (#17306) and of a
115-
* `type: 'file'` field (#25801);
125+
* - the VALUE SHAPE of a numeric field — `type: 'number'` (#17306),
126+
* `'currency'` or `'percent'` (#30178) — and of a `type: 'file'` field
127+
* (#25801);
116128
* - the declared numeric bound pair `min` / `max` (#17306);
117129
* - no value at all under a `type: 'record_link'` field;
118130
* - undeclared keys.
@@ -130,9 +142,12 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
130142
* Value SHAPE (`type`) is out of scope here with ONE closed exception, and the
131143
* exception is the maintainer's (ruling A′, 2026-09-13): a screen field's
132144
* `type` is an open widget hint, so no member of it constrains a value —
133-
* EXCEPT `'number'`, whose meaning is not a rendering preference but a value
134-
* domain the bound pair beside it already assumes. A present value for a
135-
* `type: 'number'` field that is not a finite JSON number is refused with
145+
* EXCEPT the numeric kinds, whose meaning is not a rendering preference but a
146+
* value domain the bound pair beside it already assumes. Ruling A′ named
147+
* `'number'`; `'currency'` and `'percent'` joined it under the same rule by a
148+
* later ruling (#30178), so the three numeric kinds the object protocol groups
149+
* (`MEASURE_FIELD_TYPES`) answer one question one way. A present value for a
150+
* numeric field that is not a finite JSON number is refused with
136151
* `invalid_type`, the catalog's existing member for a value of the wrong
137152
* primitive type (ADR-0114 D2 — no new code, and deliberately not
138153
* `invalid_number`, which names a PARSE failure rather than a type mismatch).
@@ -143,9 +158,12 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
143158
* That closes the gap the bound pass alone left open. The bound pass compares
144159
* numbers, so before this every non-number slipped past BOTH — under a `max`
145160
* of 20, `"25"` was conformant. The refusal now lands on the shape, and the
146-
* bound pass is left comparing numbers only, which is all it can do.
161+
* bound pass is left comparing numbers only, which is all it can do. Until
162+
* `percent` joined, the console posted it as a string, so a `percent` field's
163+
* bound held for no console user at all; the console now gives it the numeric
164+
* input `number` and `currency` have.
147165
*
148-
* ⚠️ Narrow on purpose in two directions. It keys off `type: 'number'`, not
166+
* ⚠️ Narrow on purpose in two directions. It keys off the numeric `type`, not
149167
* off the presence of a bound: a bound declared on a non-numeric field still
150168
* constrains nothing (`FieldSchema.min`/`.max` are unconditioned on `type`
151169
* too). And it is a PRESENCE-conditioned check, like the bound: an absent
@@ -167,7 +185,8 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo
167185
* write's question, asked there under the write's own caller (#25692) — not
168186
* this door's, which holds no storage service.
169187
*
170-
* No other widget hint is read as a value domain: the set is
188+
* No other widget hint is read as a value domain — `rating`, `slider` and
189+
* `progress` included, which the console has no numeric input for: the set is
171190
* {@link SCREEN_FIELD_VALUE_DOMAINS}, and a member joins it by ruling.
172191
*
173192
* `'record_link'` is the one member that takes NO value (ruling B on the
@@ -206,8 +225,9 @@ export function validateScreenInputs(
206225
});
207226
}
208227

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

0 commit comments

Comments
 (0)