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
94 changes: 94 additions & 0 deletions .changeset/17306-screen-field-bound-help-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
---

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

<!-- adr-0087: registered screen-field-lookup-reference-required -->

`ScreenFieldConfigSchema` was `.strict` over exactly
`name`/`label`/`type`/`required`/`options`/`defaultValue`/`placeholder`/`visibleWhen`,
so three ordinary authoring intents had **no expression at all**. They did not
degrade quietly — `max`, `helpText` and every lookup-target spelling were
refused BY NAME — but a loud refusal with no landing key is still a dead end,
and the reference app worked around all three in prose: a discount ceiling
interpolated into the `label` and the `placeholder` (with a comment explaining
why there was no `max`), and a `type: 'lookup'` field whose `placeholder` asked
a human to type a record id because the picker could not be pointed anywhere.

Four keys land, and **their names are derived from `FieldSchema`, not invented**
— one platform, one field vocabulary, so a name learned on an object field means
the same thing on a screen field:

| Key | Derived from | |
|:---|:---|:---|
| `min` / `max` | `FieldSchema.min` / `.max` | the bound pair |
| `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 |
| `reference` | `FieldSchema.reference` | the object a `type: 'lookup'` field picks records from |

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

That sentence needs no "when the value is a number" qualifier, because the
value SHAPE is checked first: on a `type: 'number'` field a present value that
is not a finite JSON number is refused with `invalid_type` (also already in the
catalog — still no new code), ⛔ **not coerced**. Before this, a bound pass that
compares numbers was satisfied by anything that never reached it, so `"25"`
under a `max` of `20` was conformant. One member of the open `type` vocabulary
is read as a value domain; every other widget hint stays open, and a bound on a
non-numeric field still constrains nothing.

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

**BREAKING** in the accept-set sense, in TWO places — landing as `minor` on
both packages because the launch-window guard (`check-changeset-no-major`)
keeps breaking changes off `major` outside pre-mode, not because the narrowing
is small. Both were ruled (maintainer ruling A′, decision batch #130 item 1,
2026-09-13); this release is **not** purely additive.

1. `reference` is **required** when `type` is `lookup`, as it is on an object
field. A picker with no target object resolves nothing — ADR-0078's own
example of silently-inert metadata — and a degraded shape that ships today
is not a reason to bend the contract to it. A stored flow with a bare
`lookup` screen field parsed before and does not now. There is **no lossless
conversion**: nothing in the metadata says which object the author meant, so
this is an ADR-0087 **semantic** migration entry — a structured TODO
(`screen-field-lookup-reference-required`) that names the flow and the field
for a human to answer — and ⛔ never a D2 conversion that would have to
invent a target.
2. A non-number submitted for a `type: 'number'` screen field is refused on
resume (`invalid_type`) instead of passing silently. A resume bag that was
accepted before can be refused now; it was never doing what its author
declared.

Everything else is additive: the bound itself fires only on a field that
declares one, which nothing did before this release.

The neighbouring spellings are refused **with their landing key** rather than
with a bare key list: `help`/`helpText`/`hint`/`tooltip` name `inlineHelpText`,
and `object`/`referenceTo`/`targetObject`/`lookupObject`/`relatedTo`/`target`
name `reference`. ⚠️ `object` means different things one level apart — on the
screen **node** it renames to `objectName`, on a screen **field** it can only
mean the lookup target — so it earns its own row on both.

**One stale claim corrected in passing, because this change falsified it.** The
flows translation surface documented `help`'s exclusion as *"`ScreenFieldConfig`
declares nothing help-shaped at all"*, in `translation.zod.ts`'s guidance string
(which enumerated the old key set verbatim), its doc block, and
`i18n-resolver.ts`'s `FLOW_SCREEN_FIELD_COPY_KEYS`. The screen field now
declares `inlineHelpText`, so the copy is real. The exclusion **stands** — the
flows bundle still carries `label` and `placeholder` only, and growing that face
is a ruled step against the #7646 enumeration, not a resolver-side accretion —
but its reason is now stated as a not-yet instead of telling an author the field
has no help copy when it has. ⛔ No translation key was added and no resolver
behaviour moved.
82 changes: 82 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,88 @@ Two things worth knowing:
`config.fields[N].visibleWhen` — but a predicate that *parses* and simply
names the wrong field still shows up as a field that never hides.

#### A field's bound, help text and lookup target

A screen field spells these three with **the object field's own key names** —
one platform, one field vocabulary, so a name you learned declaring an object
field means the same thing here:

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

```typescript
{
id: 'quote_terms',
type: 'screen',
config: {
fields: [
{ name: 'discount', label: 'Discount %', type: 'number',
min: 0, max: 20,
inlineHelpText: 'Above 20% the quote is refused — this is a hard ceiling.' },
{ name: 'resolved_by_article', label: 'Resolved by Article', type: 'lookup',
reference: 'crm_knowledge_article' },
],
},
}
```

**The bound is not advice.** `min` / `max` ride to the client so the user is
stopped at the input, and `validateScreenInputs` re-checks them on resume, so a
caller that skips the dialog and posts to `resume` directly is refused too
(`min_value` / `max_value`, inside the run's `INVALID_SCREEN_INPUT`). A screen
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.

**What these keys refuse:**

- **A bound that is not a number.** `max: '20'` is refused at parse time
(`invalid_type`). The bound is a closed constraint even though the `type`
beside it is an open widget hint.
- **The neighbouring spellings, by name and with their target.** `helpText`,
`help`, `hint` and `tooltip` are refused and named onto `inlineHelpText`;
`object`, `referenceTo`, `targetObject`, `lookupObject`, `relatedTo` and
`target` are refused and named onto `reference`. ⚠️ `object` means different
things one level apart: on the screen **node** it renames to `objectName`
(render that object's whole form), on a screen **field** it can only mean the
lookup target.
- **A non-string `inlineHelpText` or `reference`.**
- **A `lookup` field with no `reference`.** A picker with no target object
resolves nothing, which is ADR-0078's own example of silently-inert
metadata, so `reference` is **required** when `type` is `lookup` — as it is
on an object field. The refusal names the key and shows the spelling.
⚠️ This is a **narrowing**: a stored flow declaring a bare `lookup` screen
field parsed before this release and does not now. There is no lossless
conversion — nothing in the metadata says which object the author meant — so
the migration chain carries it as a structured TODO
(`screen-field-lookup-reference-required`) naming the flow and the field,
not an automatic rewrite.

**What they deliberately do NOT refuse:**

- **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.
- **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
hidden `visibleWhen` field does not fire — the client is the authority on what
was on screen.

Not translatable yet: the flows translation bundle carries `label` and
`placeholder` per field, so `inlineHelpText` renders in the authored language
until that face grows a key for it.

**Screen (object form):**

Set `config.objectName`
Expand Down
8 changes: 8 additions & 0 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ Value the variable takes: a string (`{token}` flow interpolation — a sole toke
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
| **placeholder** | `string` | optional | Input placeholder text |
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
| **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume |
| **max** | `number` | optional | Maximum accepted value (numeric fields); enforced on resume |
| **inlineHelpText** | `string` | optional | Help text displayed below the field |
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from; REQUIRED when `type` is `lookup` |


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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,12 @@ describe('unknown node config keys are rejected (#4277)', () => {
// `visibleWhen` is edit-distance 4 against `nearestName`'s threshold of 3,
// so this exact typo gets no did-you-mean. Printing the declared set is
// what makes the diagnostic actionable regardless.
// #17306 added `min`/`max`/`inlineHelpText`/`reference`; the enumeration is
// pinned in full rather than sampled, so a key that arrives or vanishes on
// this surface has to be acknowledged here.
expect(msg).toContain(
'Declared here: name, label, type, required, options, defaultValue, placeholder, visibleWhen.',
'Declared here: name, label, type, required, options, defaultValue, placeholder, '
+ 'min, max, inlineHelpText, reference, visibleWhen.',
);
// …and this particular key has a documented incident, so it also carries
// its tombstone (the UNKNOWN_KEY_GUIDANCE pattern).
Expand Down
47 changes: 47 additions & 0 deletions packages/services/service-automation/src/builtin/screen-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ import { judgeHeadlessScreen } from '../screen-input-contract.js';
* that parse is what makes the retirement audible to stored metadata.
*/

/**
* The `fields[].reference` repeater column of the `screen` designer form: the
* object PICKER (that is what `xRef` marks) whose collected value is the target
* object's NAME — the same string `FieldSchema.reference` carries.
*
* Named rather than written inline at its `reference:` key, and the naming is
* load-bearing. `check:reference-carrier-shape` classifies the object HOLDING a
* `reference` key; here that holder is a JSON-Schema `properties` map, keyed by
* property NAME — the same class the gate excludes by shape for a `fields` map
* (N1) and for a field-key class map (N3), but a shape its position rules have
* no case for. Written inline, the holder resolved under neither reading and
* the gate refused to guess (exit 3 — correctly). None of its three site
* remedies is spellable here: the holder is not under `fields:`, its own `type`
* key holds a sub-schema rather than a string literal, and all twelve of its
* keys are `data/Field` authorable keys, so no key can prove it is not a field
* definition. A value reached through a name is UNJUDGED by the gate's stated
* predicate, which judges LITERALS only. That narrows the gate nowhere else in
* the tree; the missing `properties`-map rule is reported to the maintainer
* rather than patched from inside this PR.
*/
const LOOKUP_TARGET_COLUMN = {
type: 'string',
title: 'Lookup object',
xRef: { kind: 'object' },
description: "Object whose records a `lookup` field picks from. Required on a `lookup` field — a picker with no target object resolves nothing.",
};

export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void {
// screen — server-side pass-through (input vars already injected by engine).
engine.registerNodeExecutor({
Expand Down Expand Up @@ -98,6 +125,17 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
},
defaultValue: { title: 'Default', description: 'Prefilled value. Interpolates {var} references.' },
placeholder: { type: 'string', title: 'Placeholder' },
// Declared in #17306 — the bound pair, the help text and the
// lookup target, spelled as the object field spells them.
// Offered here for the same reason `options`/`defaultValue`/
// `placeholder` were in #4045: the executor forwards them, so
// a form that omitted them would leave the keys authorable
// only by hand. `builtin-node-form-zod-ledger.test.ts`
// reconciles this column set against the Zod both ways.
min: { type: 'number', title: 'Min', description: 'Minimum accepted value (numeric fields). Enforced when the run resumes.' },
max: { type: 'number', title: 'Max', description: 'Maximum accepted value (numeric fields). Enforced when the run resumes.' },
inlineHelpText: { type: 'string', title: 'Help text', description: 'Help text shown under the input. Unlike the placeholder, it stays readable once the user types.' },
reference: LOOKUP_TARGET_COLUMN,
visibleWhen: { type: 'string', title: 'Visible when', xExpression: 'expression' },
},
},
Expand Down Expand Up @@ -208,6 +246,15 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
options: f.options as Array<{ value: unknown; label: string }> | undefined,
defaultValue: f.defaultValue !== undefined ? interpolate(f.defaultValue, variables, context) : undefined,
placeholder: f.placeholder,
// #17306. `min`/`max` go on the wire for the client to apply at the
// input, and are re-checked server-side on resume
// (`validateScreenInputs`) so the bound is not dialog-only.
// `inlineHelpText`/`reference` are presentation and picker-target
// hints the client alone acts on.
min: f.min,
max: f.max,
inlineHelpText: f.inlineHelpText,
reference: f.reference,
// Forwarded RAW — deliberately not interpolated. `visibleWhen` is a
// predicate the client re-evaluates on every keystroke against the
// values collected SO FAR; the server has no view of those, so
Expand Down
Loading
Loading