Skip to content

Commit 68d0ce6

Browse files
committed
fix(spec,service-automation)!: enforce the screen-field bound's value shape and require reference on a lookup screen field
Maintainer ruling A′ (decision batch #130 item 1, 2026-09-13, verbatim 「同意」), applied across the five items it names. 1. Server-side enforcement at resume STAYS — `min_value` / `max_value`, both already in the ADR-0114 D2 catalog, no new error code. 2. The string gap closes. `validateScreenInputs` gains its own pass, BEFORE the bound: a present value for a `type: 'number'` screen field that is not a finite JSON number is refused with `invalid_type` — ⛔ never coerced. The bound pass compares numbers, so before this every non-number satisfied it by never reaching it (`"25"` under a `max` of 20 was conformant). The pin that recorded that silence is INVERTED in place, not deleted, so a later re-widening has to come back through it. Narrow in two directions on purpose: it keys off `type: 'number'` and not off the presence of a bound, and it is presence-conditioned exactly as the bound is. 3. `ScreenFieldConfigSchema` requires `reference` when `type` is `lookup`, via a `superRefine` that leaves `.shape` enumerable and the key set unmoved. This REVERSES the optionality the card first shipped; ADR-0078's own example of silently-inert metadata is a `lookup` with no `reference`, and a degraded shipped twin is not a reason to bend the contract to it. A stored bare lookup has NO lossless conversion — nothing in the metadata says which object the author meant — so it registers as an ADR-0087 SEMANTIC entry (`screen-field-lookup-reference-required`, protocol 18), ⛔ never a D2 conversion that would have to invent a target. The entry is one file under `migrations/entries/semantic/`; `registry.ts` is its GENERATED projection (`gen:migration-registry`), never hand-merged. 4. The wording items, in every carrier: the bound's "re-checked when the submitted value is a number" qualifier is gone from the changeset, the flows guide, the generated node-config reference, both `.describe()` pairs, the `ScreenFieldSpec` doc block and the Studio designer form, because the qualifier is no longer true. `reference`'s requirement is stated wherever its optionality was. 5. `check:reference-carrier-shape` is green (exit 0): both `reference` sites this PR introduced reach their value through a name, which is the population the gate documents as unjudged. ⛔ No path ignore, and the `['a']` fixture still tests what it tested — it widened to four shapes, including the `{ object: 'x' }` carrier shape the gate exists for. Also repaired, each falsified by the above rather than pre-existing: - The changeset declared no break. It now carries `**BREAKING**` and the `<!-- adr-0087: registered screen-field-lookup-reference-required -->` disposition marker — a semantic entry with no declaration on the changeset is exactly what `check-adr-0087-registration` exists to notice. `minor` stays: the launch-window guard keeps breaks off `major` outside pre-mode. - `ScreenInputIssue.code` enumerated `required` and `unknown_field` only and spoke of "the same two conditions". This PR put three more codes through that field. - `ScreenFieldSpec.reference` claimed absence was "what every `lookup` screen field did before this key existed". It now states that the authoring schema refuses that shape, and why the WIRE type stays optional: a run suspended before the upgrade rehydrates a `ScreenSpec` stored under the old accept set. - `api-surface/automation.json` and `export-origins/automation.json` were stale — the new exported refusal constant had never been propagated. Both regenerated with the repo's own generators; one additive line each. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
1 parent c6ea7ea commit 68d0ce6

14 files changed

Lines changed: 427 additions & 105 deletions

File tree

‎.changeset/17306-screen-field-bound-help-lookup.md‎

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
A flow screen field can now express a numeric bound, help text and a lookup target — spelled with the object field's own key names
77

8+
<!-- adr-0087: registered screen-field-lookup-reference-required -->
9+
810
`ScreenFieldConfigSchema` was `.strict` over exactly
911
`name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`,
1012
so three ordinary authoring intents had **no expression at all**. They did not
@@ -25,33 +27,52 @@ the same thing on a screen field:
2527
| `inlineHelpText` | `FieldSchema.inlineHelpText` | help under the input — `FieldSchema` renames `help`/`helpText`/`hint`/`tooltip` onto it, so a screen-local `helpText` would have been a second contract for one question |
2628
| `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from |
2729

28-
**The bound is re-checked on resume when the submitted value is a number.**
29-
It rides to the client on `ScreenFieldSpec` so the user is stopped at the
30-
input, **and** `validateScreenInputs` re-checks it when the run resumes
31-
(`min_value` / `max_value`, both already in the ADR-0114 D2 field-error catalog
32-
— no new error code). A screen field's declared contract is the only contract
33-
behind it, so a bound the dialog alone applied would be bypassed by any caller
34-
posting a number to `resume` directly — the gap #4477 closed for `required`.
30+
**The bound is enforced, not advisory.** It rides to the client on
31+
`ScreenFieldSpec` so the user is stopped at the input, **and**
32+
`validateScreenInputs` re-checks it when the run resumes (`min_value` /
33+
`max_value`, both already in the ADR-0114 D2 field-error catalog — no new error
34+
code). A screen field's declared contract is the only contract behind it, so a
35+
bound the dialog alone applied would be bypassed by any caller posting to
36+
`resume` directly — the gap #4477 closed for `required`.
3537

36-
⚠️ The re-check is as wide as what this surface can judge, and no wider: it
37-
fires only on a value that is already a finite number, because a screen field's
38-
`type` is an open widget hint with no closed vocabulary. A numeric string
39-
(`"25"`) is not coerced and passes the bound silently — so this is a narrower
40-
guarantee than "enforced server-side" on its own would claim.
38+
That sentence needs no "when the value is a number" qualifier, because the
39+
value SHAPE is checked first: on a `type: 'number'` field a present value that
40+
is not a finite JSON number is refused with `invalid_type` (also already in the
41+
catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that
42+
compares numbers was satisfied by anything that never reached it, so `"25"`
43+
under a `max` of `20` was conformant. One member of the open `type` vocabulary
44+
is read as a value domain; every other widget hint stays open, and a bound on a
45+
non-numeric field still constrains nothing.
4146

4247
**Delivered with its rendering, not ahead of it.** The executor forwards all
4348
four onto the wire and the Studio designer form offers all four as repeater
4449
columns; `builtin-node-form-zod-ledger.test.ts` reconciles the two key sets
4550
against the Zod in both directions, so a key declared here and absent from the
4651
form fails that test rather than shipping as a field nobody can author.
4752

48-
**Additive by construction.** `reference` is **optional** on `type: 'lookup'` —
49-
unlike `FieldSchema`, where it is required — because screen fields declaring a
50-
bare `lookup` with no target already exist in shipped flows, and refusing them
51-
would break metadata that parses today. Such a field keeps exactly its current
52-
behaviour. Nothing that parsed before this change stops parsing, and no resume
53-
bag that was accepted before is refused now: the bound fires only on a field
54-
that declares one, which nothing did before this release.
53+
**BREAKING** in the accept-set sense, in TWO places — landing as `minor` on
54+
both packages because the launch-window guard (`check-changeset-no-major`)
55+
keeps breaking changes off `major` outside pre-mode, not because the narrowing
56+
is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1,
57+
2026-09-13); this release is **not** purely additive.
58+
59+
1. `reference` is **required** when `type` is `lookup`, as it is on an object
60+
field. A picker with no target object resolves nothing — ADR-0078's own
61+
example of silently-inert metadata — and a degraded shape that ships today
62+
is not a reason to bend the contract to it. A stored flow with a bare
63+
`lookup` screen field parsed before and does not now. There is **no lossless
64+
conversion**: nothing in the metadata says which object the author meant, so
65+
this is an ADR-0087 **semantic** migration entry — a structured TODO
66+
(`screen-field-lookup-reference-required`) that names the flow and the field
67+
for a human to answer — and ⛔ never a D2 conversion that would have to
68+
invent a target.
69+
2. A non-number submitted for a `type: 'number'` screen field is refused on
70+
resume (`invalid_type`) instead of passing silently. A resume bag that was
71+
accepted before can be refused now; it was never doing what its author
72+
declared.
73+
74+
Everything else is additive: the bound itself fires only on a field that
75+
declares one, which nothing did before this release.
5576

5677
The neighbouring spellings are refused **with their landing key** rather than
5778
with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`,

‎content/docs/automation/flows.mdx‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,9 @@ field means the same thing here:
404404

405405
| Key | Type | What it does |
406406
|:---|:---|:---|
407-
| `min` / `max` | `number` | Numeric bound. Applied by the client at the input **and re-checked server-side on resume when the submitted value is a number**. |
407+
| `min` / `max` | `number` | Numeric bound. Applied by the client at the input **and enforced server-side when the run resumes**. |
408408
| `inlineHelpText` | `string` | Help text under the input. Unlike `placeholder`, it stays readable after the user types. |
409-
| `reference` | `string` | Object whose records a `type: 'lookup'` field picks from, so the field renders a record picker. |
409+
| `reference` | `string` | Object whose records a `type: 'lookup'` field picks from, so the field renders a record picker. **Required** on a `lookup` field. |
410410

411411
```typescript
412412
{
@@ -424,18 +424,19 @@ field means the same thing here:
424424
}
425425
```
426426

427-
**The bound is re-checked on resume — for a value that arrives as a number.**
428-
`min` / `max` ride to the client so the user is stopped at the input, and
429-
`validateScreenInputs` re-checks them on resume, so a caller that skips the
430-
dialog and posts a *number* to `resume` directly is refused too (`min_value` /
431-
`max_value`, inside the run's `INVALID_SCREEN_INPUT`). A screen field's declared
432-
contract is the only contract behind it — there is no object schema to catch a
433-
bad bag downstream.
427+
**The bound is not advice.** `min` / `max` ride to the client so the user is
428+
stopped at the input, and `validateScreenInputs` re-checks them on resume, so a
429+
caller that skips the dialog and posts to `resume` directly is refused too
430+
(`min_value` / `max_value`, inside the run's `INVALID_SCREEN_INPUT`). A screen
431+
field's declared contract is the only contract behind it — there is no object
432+
schema to catch a bad bag downstream.
434433

435-
⚠️ The re-check reads only finite numbers. `"25"` — the same quantity as a
436-
*string* — is not coerced and passes the bound in silence, for the reason the
437-
next list gives: this surface has no closed `type` vocabulary and so does not
438-
judge value shape. Outside that, the bound is the client's alone.
434+
That sentence carries no "if the value is a number" qualifier, because the
435+
shape is checked first: on a `type: 'number'` field a submitted value that is
436+
not a JSON number is **refused** on resume with `invalid_type`. `"25"` — the
437+
same quantity as a *string* — is **not coerced** into `25` and does not reach
438+
the bound; it is rejected as the wrong shape. The bound then compares numbers
439+
only, which is all a bound can do.
439440

440441
**What these keys refuse:**
441442

@@ -450,17 +451,23 @@ judge value shape. Outside that, the bound is the client's alone.
450451
(render that object's whole form), on a screen **field** it can only mean the
451452
lookup target.
452453
- **A non-string `inlineHelpText` or `reference`.**
454+
- **A `lookup` field with no `reference`.** A picker with no target object
455+
resolves nothing, which is ADR-0078's own example of silently-inert
456+
metadata, so `reference` is **required** when `type` is `lookup` — as it is
457+
on an object field. The refusal names the key and shows the spelling.
458+
⚠️ This is a **narrowing**: a stored flow declaring a bare `lookup` screen
459+
field parsed before this release and does not now. There is no lossless
460+
conversion — nothing in the metadata says which object the author meant — so
461+
the migration chain carries it as a structured TODO
462+
(`screen-field-lookup-reference-required`) naming the flow and the field,
463+
not an automatic rewrite.
453464

454465
**What they deliberately do NOT refuse:**
455466

456-
- **A `lookup` field with no `reference`.** Unlike an object field — where the
457-
reference is required — this stays optional, because screen fields declaring a
458-
bare `lookup` type already exist in shipped flows and refusing them would break
459-
metadata that parses today. Such a field keeps its current behaviour: no
460-
picker target to resolve.
461-
- **A bound on a non-numeric field, or a non-numeric value under a bound.** A
462-
screen field's `type` has no closed vocabulary, so neither this schema nor the
463-
resume check judges value shape; the bound simply never fires.
467+
- **A bound on a non-numeric field.** A screen field's `type` has no closed
468+
vocabulary, so this schema cannot judge which types a bound is meaningful on.
469+
A bound on a `text` field constrains nothing and is not an error — the shape
470+
check above reads `type: 'number'` alone, never the presence of a bound.
464471
- **An absent value.** A bound constrains a value that is present — an optional
465472
bounded field left empty is conformant. Presence is `required`'s question.
466473
- **A bound on a field the user was never shown.** Like `required`, a bound on a

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,10 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
227227
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
228228
| **placeholder** | `string` | optional | Input placeholder text |
229229
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
230-
| **min** | `number` | optional | Minimum accepted value (numeric fields); re-checked on resume when the submitted value is a number |
231-
| **max** | `number` | optional | Maximum accepted value (numeric fields); re-checked on resume when the submitted value is a number |
230+
| **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume |
231+
| **max** | `number` | optional | Maximum accepted value (numeric fields); enforced on resume |
232232
| **inlineHelpText** | `string` | optional | Help text displayed below the field |
233-
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from |
233+
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from; REQUIRED when `type` is `lookup` |
234234

235235

236236
---
@@ -249,10 +249,10 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
249249
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
250250
| **placeholder** | `string` | optional | Input placeholder text |
251251
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
252-
| **min** | `number` | optional | Minimum accepted value (numeric fields); re-checked on resume when the submitted value is a number |
253-
| **max** | `number` | optional | Maximum accepted value (numeric fields); re-checked on resume when the submitted value is a number |
252+
| **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume |
253+
| **max** | `number` | optional | Maximum accepted value (numeric fields); enforced on resume |
254254
| **inlineHelpText** | `string` | optional | Help text displayed below the field |
255-
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from |
255+
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from; REQUIRED when `type` is `lookup` |
256256

257257
### Nested Shape: `ScreenFieldConfig.options[number]`
258258

‎packages/services/service-automation/src/builtin/screen-nodes.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const LOOKUP_TARGET_COLUMN = {
6969
type: 'string',
7070
title: 'Lookup object',
7171
xRef: { kind: 'object' },
72-
description: "Object whose records a `lookup` field picks from.",
72+
description: "Object whose records a `lookup` field picks from. Required on a `lookup` field — a picker with no target object resolves nothing.",
7373
};
7474

7575
export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void {
@@ -132,8 +132,8 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
132132
// a form that omitted them would leave the keys authorable
133133
// only by hand. `builtin-node-form-zod-ledger.test.ts`
134134
// reconciles this column set against the Zod both ways.
135-
min: { type: 'number', title: 'Min', description: 'Minimum accepted value (numeric fields). Re-checked when the run resumes, for a submitted value that is a number.' },
136-
max: { type: 'number', title: 'Max', description: 'Maximum accepted value (numeric fields). Re-checked when the run resumes, for a submitted value that is a number.' },
135+
min: { type: 'number', title: 'Min', description: 'Minimum accepted value (numeric fields). Enforced when the run resumes.' },
136+
max: { type: 'number', title: 'Max', description: 'Maximum accepted value (numeric fields). Enforced when the run resumes.' },
137137
inlineHelpText: { type: 'string', title: 'Help text', description: 'Help text shown under the input. Unlike the placeholder, it stays readable once the user types.' },
138138
reference: LOOKUP_TARGET_COLUMN,
139139
visibleWhen: { type: 'string', title: 'Visible when', xExpression: 'expression' },

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
* would be bypassed by any caller that posts to `resume` directly. That is the
1010
* gap #4477 closed for `required`, and these pin the same closure for the
1111
* bound: the client stops the user at the input, the server stops everyone.
12+
*
13+
* ⚠️ Ruling A′ (2026-09-13) closed the second half of that sentence. The bound
14+
* pass compares numbers, so a caller posting a non-number satisfied it by
15+
* never reaching it — `"25"` under a `max` of 20 was conformant. A present
16+
* value for a `type: 'number'` field is now refused unless it is a finite JSON
17+
* number (`invalid_type`, ⛔ not coerced), and the pin that recorded the old
18+
* silence is inverted in place rather than removed.
1219
*/
1320

1421
import { describe, expect, it } from 'vitest';
@@ -55,10 +62,51 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => {
5562
expect(validateScreenInputs([discount], {}, ALWAYS_VISIBLE)).toEqual([]);
5663
});
5764

58-
it('does not fire on a non-numeric value — `type` has no closed vocabulary here', () => {
59-
// The surface deliberately does not judge value shape; inventing an
60-
// `invalid_number` on this path would promise a check it never declared.
61-
expect(validateScreenInputs([discount], { discount: 'twenty' }, ALWAYS_VISIBLE)).toEqual([]);
65+
// ── REVERSED (ruling A′, 2026-09-13): this pin used to assert the SILENCE ──
66+
// It read 'does not fire on a non-numeric value — `type` has no closed
67+
// vocabulary here', and asserted `[]` for `{ discount: 'twenty' }`. That
68+
// silence was the string gap: under a `max` of 20 the string `"25"` satisfied
69+
// the bound pass (which compares numbers) and no other pass looked at it, so
70+
// the guarantee "skipping the dialog is refused too" held only for callers
71+
// that already sent a number. The maintainer closed it — refuse, ⛔ never
72+
// coerce — so the same input now yields an issue, and the assertion is
73+
// inverted rather than deleted: a later widening that re-admits the string
74+
// has to come back through this case.
75+
it('refuses a non-number for a `type: \'number\'` field — the string gap is closed', () => {
76+
const issues = validateScreenInputs([discount], { discount: 'twenty' }, ALWAYS_VISIBLE);
77+
expect(issues).toHaveLength(1);
78+
expect(issues[0]!.code).toBe('invalid_type');
79+
expect(issues[0]!.field).toBe('discount');
80+
// ⛔ Not coerced: a numeric STRING is the shape being refused, so it must
81+
// not be read as the number it spells and then bound-checked.
82+
const numericString = validateScreenInputs([discount], { discount: '25' }, ALWAYS_VISIBLE);
83+
expect(numericString.map((i) => i.code)).toEqual(['invalid_type']);
84+
// …and the non-finite numbers, which are not JSON numbers either.
85+
for (const value of [Number.NaN, Number.POSITIVE_INFINITY]) {
86+
expect(validateScreenInputs([discount], { discount: value }, ALWAYS_VISIBLE).map((i) => i.code),
87+
`value ${String(value)}`).toEqual(['invalid_type']);
88+
}
89+
});
90+
91+
it('reads only `type: \'number\'` as a value domain — every other widget hint stays open', () => {
92+
// The closure is one member of an open vocabulary, not a new rule that
93+
// every `type` constrains its value. A bound on a non-numeric field still
94+
// constrains nothing, exactly as `FieldSchema.min`/`.max` do.
95+
const texty: ScreenFieldSpec = { name: 'discount', type: 'text', min: 0, max: 20 };
96+
expect(validateScreenInputs([texty], { discount: 'twenty' }, ALWAYS_VISIBLE)).toEqual([]);
97+
const untyped: ScreenFieldSpec = { name: 'discount', min: 0, max: 20 };
98+
expect(validateScreenInputs([untyped], { discount: 'twenty' }, ALWAYS_VISIBLE)).toEqual([]);
99+
});
100+
101+
it('leaves the shape check to `required` when the value is absent, and off a hidden field', () => {
102+
// Absence is `required`'s question — reading it as a bad shape would make
103+
// every optional numeric field secretly required.
104+
expect(validateScreenInputs([discount], {}, ALWAYS_VISIBLE)).toEqual([]);
105+
expect(validateScreenInputs([discount], { discount: null }, ALWAYS_VISIBLE)).toEqual([]);
106+
const conditional: ScreenFieldSpec = { ...discount, visibleWhen: 'wantsDiscount == true' };
107+
expect(validateScreenInputs([conditional], { discount: 'twenty' }, () => false)).toEqual([]);
108+
// …and the control, or the reading above proves nothing.
109+
expect(validateScreenInputs([conditional], { discount: 'twenty' }, () => true)).toHaveLength(1);
62110
});
63111

64112
it('does not fire on a field the user was never shown', () => {

0 commit comments

Comments
 (0)