You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f89b5f5
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face (#22555)
Part of #22507
Clause-②: yes (narrowing)
This PR keys two of the three user-read flow slots that had no key in
the `flows` translation face: a screen field's option labels and the
flow's terminal toasts. It also adds the family pin. The third slot, a
screen's `description`, is server-rendered, so this PR stops there and
reports it for a decision (see "Where this PR stops"). That is why the
first line is `Part of` rather than the closing keyword the dispatch
named: merging this must leave the card open for the `description` half.
## What changes
**`@objectstack/spec`**
- `TranslationDataSchema.flows` gains three keys.
- `flows.FLOW.successMessage` and `flows.FLOW.errorMessage`: the
terminal toasts. Aliases `success` / `successText` / `error` /
`errorText` rename onto them.
- `flows.FLOW.screens.NODE_ID.fields.FIELD.options.VALUE`: a screen
field's option labels. `choices` / `values` rename onto `options`.
- Each option is keyed by its value coerced to a string,
`String(value)`, through one function: `flowScreenFieldOptionKey`
(`@objectstack/spec/automation`). This is the seat's choice in the
claim. It is also the console select's own key for an option
(`SelectItem value={String(o.value)}` in objectui's `ScreenView`), and
the coercion `translateAction` already applies to an action param's
inline options.
- **The one narrowing.** `ScreenFieldConfigSchema` refuses two options
of one field whose values collide as text, such as `1` and `"1"`, `true`
and `"true"`, or a repeated value.
- The refusal lands at the second option's `value`. At the flow door it
reads `nodes[1].config.fields[0].options[1].value`.
- It leads with `SCREEN_FIELD_OPTION_VALUE_COLLISION`, names both
options by position and value, and gives the remedy.
- Measured reach: 0 collisions. The examples hold 35 flows, 6 screens, 2
option fields and 10 options, all with string values (measured at
`37c711449` by loading the `app-crm`, `app-todo` and `app-showcase`
flows). hotcrm at `f0afcbda0` and hotclm at `14c899fc4` were read by
inspection: their option values are string literals or come from an
object field's string options.
- `translateFlow` applies the toasts, only where the flow authors one,
so a bundle cannot add a toast. It also applies the option labels,
option by option, falling back to the authored label.
- `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field, opts)`
is new, for a runner that holds a served `ScreenFieldSpec`.
- `FLOW_TERMINAL_MESSAGE_KEYS` is new, the key list the extractor and
the schema pin share.
- The exclusion texts that no longer hold were rewritten:
`FLOW_SCREEN_FIELD_NO_OPTIONS` is removed, the toast guidance is
removed, and the `description` guidance now says why it is unkeyed and
what the screen face carries.
- The liveness ledger gains `translation/flows.successMessage` and
`flows.errorMessage`, both `planned` (contract-first split, as `screens`
was), and dated notes on `flows` and `flows.screens` for the option map.
**`@objectstack/lint`**: `validateTranslationReferences` judges both new
key families.
- A translated toast over a flow that declares none is
`translation-target-unknown` (error).
- An option key that names no declared option, or names one by its
label, is `translation-option-key-unknown` (warning). This is the same
rule and severity as the object-field and action-param legs.
**`@objectstack/cli`**: `os i18n extract` and the coverage gate scaffold
and demand the new keys in the `flow` bucket.
- The option keys go through the same key function.
- The completion toast is demanded for a flow with a screen, and the
failure toast for every flow. Each predicate mirrors the reader that
will draw it (see below).
**Examples**: `app-crm` (zh-CN toasts) and `app-todo` (zh-CN and ja-JP
option labels) are translated, so `check:i18n-coverage` holds at its
baseline. Without this it grew 101 → 103 and 106 → 126. These files are
outside the claim's file surface; the report declares it.
## The enumeration, and the family pin
`packages/spec/src/system/flows-translation-face.test.ts` walks the flow
schemas mechanically and lists every text-bearing leaf. Each leaf is
held against one recorded disposition: `keyed` (a face key that must
parse and survive), `noReader`, `elsewhere` (another declared route,
which must exist) or `owed` (pinned exactly, shrink-only).
- **Tier 1** covers every text leaf of `FlowSchema` (minus `nodes`),
`FlowNodeSchema` (minus `config`), `ScreenConfigSchema` and
`EndConfigSchema`.
- **Tier 2** covers the copy-named text leaves of every other declared
node contract (the builtin executor contracts and `approval`), plus
every `FLOW_NODE_TEXT_SLOTS` entry.
It also fails on a stale row, and on a face key that no slot fills.
| Slot | Disposition |
|---|---|
| `Flow.label` | keyed `label` |
| `Flow.successMessage` / `errorMessage` | keyed (new) |
| screen `title`, and a screen node's `label` as its fallback heading |
keyed `screens.NODE_ID.title` |
| screen field `label` / `placeholder` / `inlineHelpText` | keyed |
| screen field `options[].label` | keyed `…options.VALUE` (new) |
| end `message` (refused) | keyed `refusals.NODE_ID.message` |
| **screen `description`** | **owed: server-rendered, decision pending**
|
| notify `title` / `message` | elsewhere: the notify `template` path
(sys_email_template, per recipient locale) |
| **approval `decisionOutputs[].label`** | **owed: read in the approval
decision dialog, no face at all** |
| `Flow.description`, edge `label`, variables, ids, predicates,
`defaultValue`, `defaults`, `objectName`, framework/lock fields, … |
noReader, each with its reason |
`noReader` means that no person running the flow reads the text.
`Flow.description` is drawn by Studio's metadata viewer only, so it is
recorded that way, with that reason.
**It bites** (ablation, each leg through `scripts/ablation-replace.mjs`
with an anchor hit of 1 → 0, the pin run under the verify lock, then
restored with blob == HEAD and an empty `git diff HEAD`). Predicted:
red. Observed: red.
- Planting `subtitle: z.string().optional()` on `ScreenConfigSchema`
(blob `5b4cf3fe` → `ac16dd5a` → `5b4cf3fe`) gives 1 failed / 6 passed:
`expected [ 'screen.subtitle' ] to deeply equal []`.
- Renaming the face's `successMessage` key (blob `8d5dd093` → `05480fb1`
→ `8d5dd093`) gives 2 failed / 5 passed: the keyed-row check and the
face-fill check.
## Where this PR stops: a screen's `description` is server-rendered
The dispatch orders a stop before touching `service-automation` if a
slot proves server-rendered, and `description` does.
`service-automation`'s screen executor renders `config.description` per
run through `renderTextSlot` (`{{ }}` holes over the flow's variables)
and serves the result as `ScreenSpec.description`.
An overlay on the served string would draw a translated hole literally.
The face's existing `title` key already has that boundary, as
FlowRunner's own header records. Templated descriptions are real:
app-todo's `success_screen` (`Task "{{ subject }}" created
successfully!`) and hotclm's `contract_intake` "First version" screen
(`description: '{templateNote}'`).
So the key is not added. The options for the seat:
- **A.** The engine picks the translated template in the run's locale
before `renderTextSlot`, the `refusals` route. This touches
`service-automation`, and the same change should move `title` too.
- **B.** A client overlay, with the schema refusing a `{{ }}` hole in a
translated `description`. A templated description then stays
untranslatable.
- **C.** A client overlay with `title`'s silent boundary.
The pin records `description` as owed, so whichever lands must delete
that row.
## What the objectui runner must read (for the seat's objectui card)
- `flows.FLOW.screens.NODE_ID.fields.FIELD.options.VALUE`: in
`FlowRunner.tsx` `localizeScreen`, set each field's `options` to
`resolveFlowScreenFieldOptions(bundle, flowName, screen.nodeId, field, {
locale: language })` before `ScreenView` draws its `SelectItem`s.
- Not through `FLOW_SCREEN_FIELD_COPY_KEYS`: the option map is not a
string key and is deliberately not in that list.
- `flows.FLOW.successMessage`: in `FlowRunner.tsx`, the terminal-success
toast (`outcome.successMessage || t('flowRunner.completed', …)`), via
`translateFlow({ name: flowName, successMessage: outcome.successMessage
}, bundle, { locale: language }).successMessage`.
- `flows.FLOW.errorMessage`: in `utils/flowResponse.ts`
`flowFailureMessage`, which reads `data.errorMessage`, from
`error.details.errorMessage` on the 400 `FLOW_FAILED` path. It is read
by FlowRunner's resume and by both launch hosts through
`judgeFlowLaunch` (`useConsoleActionRuntime`, `RecordDetailView`).
- The launch path has no bundle or flow name there today, so the card
threads them.
- The runner's header notes that say `description` / `inlineHelpText` /
`FlowSchema.successMessage` are off the surface are stale.
- ⛔ No runner-side guessing from an object field's options: a screen
field declares no such binding.
## Verification (final head `c17df03f0`, which merges `origin/main`
`4638625e0`)
- **Typecheck**: `pnpm --filter PKG run typecheck` exited 0 for
`@objectstack/spec` (source, scripts, and the test layer: 52 files / 246
errors / 135 pinned signatures held), `@objectstack/lint` and
`@objectstack/cli`.
- **Spec tiers in full**, under the verify lock:
- `local`: 637 files, 18977 passed and 1 todo.
- `repo`: 54 files, 915 passed.
- **Lint**: the full suite, 133 files / 6096 passed.
`validate-translation-references.test.ts` alone: 162 passed.
- **CLI**: the unit-tier `test/i18n-*` files, 24 files / 285 passed. The
new `i18n-flow-toast-option-coverage.test.ts` is among them. The
integration tier is declared to CI.
- **Generated**: `pnpm --filter @objectstack/spec check:generated`
reports "All 15 generated artifacts are up to date", measured against a
dist built by that run after the merge. The dropped-refinements count is
unchanged at 688 / 226: the collision check lives in the existing
`ScreenFieldConfigSchema` refinement.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` derived 115
commands at this head. All 115 were run with their exit codes recorded,
and `--ran` reports "115 run, 0 NOT-MEASURED (a DERIVED zero)".
- 114 exited 0.
- `check:skill-examples` and `check:dual-build-cjs-loads` first refused
(exit 3, PREREQUISITE NOT MET). They exited 0 after building the named
packages.
- `pnpm check:platform-checklist` exits 1, and it is not this diff. It
reads `ABSENT SYMBOL … attachment-access-hooks.ts#canEdit` and `SYMBOL
ANCHORS LOST — 27 … floor is 28` in `areas/attachments-storage.json`. A
clean worktree of `origin/main` `4638625e0` exits 1 with the same two
lines. This diff touches neither file.
- **ESLint**, narrowed to the 17 changed `.ts` files: `eslint
--no-inline-config --format json` reports 17 files, 0 errors and 0
warnings.
- None of the files is ignored; an ignored file would be a warning.
- `eslint.config.mjs` enables no type-aware linting, so this diff cannot
move a verdict on an untouched file.
- **Size**: about 1,690 changed lines, generated files included.
## Acceptance notes
- The two new key families are `planned`. The objectui runner reads
neither yet, so until its card lands a translated option or toast is
stored and the authored text is drawn. Meanwhile the extractor demands
them, the same contract-first window `screens` had.
- The screen `title` key has the same server-render boundary as
`description` (a translated `{{ }}` hole draws literally). Option A
above would fix both.
- Bulk-action params still exclude option labels with the old "value is
unconstrained" reasoning (`BULK_PARAM_NO_OPTIONS`). An action param's
inline options are keyed by `String(value)` with no collision refusal.
Neither is in this card.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows.<flow>.screens.<node_id>.fields.<field>.options.<value>`, `flows.<flow>.successMessage` / `.errorMessage`)
8
+
9
+
Clause-②: yes (narrowing)
10
+
11
+
<!-- adr-0087: not-required (no-migration-prescription) A value-domain narrowing on one metadata shape, ScreenFieldConfigSchema's options array. No spec key, export, option, config field or stored shape is removed, renamed or re-shaped, so there is no tombstone and no rename for `objectstack migrate meta` to apply. What narrows is which option-value combinations one screen field accepts: two options whose values read the same as text are now refused at parse. There is no lossless rewrite for such a pair (nothing in the metadata says which of the two values the author meant, or what the other should become), and the refusal itself names the pair and the fix at every door that parses a flow. Measured reach is 0: no flow in this repository's examples carries such a pair. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and no published interface or type is narrowed, the new members being additive (not runtime-interface-only / type-surface-only). -->
12
+
13
+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair.
14
+
15
+
-**What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows.<flow_name>.screens.<node_id>.fields.<field_name>.options.<value>`, and the flow's own toasts under `flows.<flow_name>.successMessage` and `flows.<flow_name>.errorMessage`.
16
+
-**How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding).
17
+
-**One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them.
18
+
-**The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error).
19
+
-**Where it is read.**`translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn.
20
+
-**`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`).
21
+
-**`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them.
22
+
-**Still not keyed: a screen's `description`.** It is a `{{ }}` template the server renders per run, so its translation has to be chosen before that render; a translated `description` is still refused by the schema with that reason.
Copy file name to clipboardExpand all lines: content/docs/references/automation/builtin-node-config.mdx
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -241,7 +241,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
241
241
|**label**|`string`| optional | Display label |
242
242
|**type**|`string`| optional | Input type |
243
243
|**required**|`boolean`| optional | Whether a value is required to submit |
244
-
|**options**|`{ value: any; label: string }[]`| optional | Choices for a select-style field |
244
+
|**options**|`{ value: any; label: string }[]`| optional | Choices for a select-style field; option values must stay distinct when read as text|
245
245
|**defaultValue**|`any`| optional | Prefilled value (interpolates `{token}` templates) |
246
246
|**placeholder**|`string`| optional | Input placeholder text |
247
247
|**visibleWhen**|`string`| optional | CEL predicate controlling visibility, evaluated client-side |
@@ -263,7 +263,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
263
263
|**label**|`string`| optional | Display label |
264
264
|**type**|`string`| optional | Input type |
265
265
|**required**|`boolean`| optional | Whether a value is required to submit |
266
-
|**options**|`{ value: any; label: string }[]`| optional | Choices for a select-style field |
266
+
|**options**|`{ value: any; label: string }[]`| optional | Choices for a select-style field; option values must stay distinct when read as text|
267
267
|**defaultValue**|`any`| optional | Prefilled value (interpolates `{token}` templates) |
268
268
|**placeholder**|`string`| optional | Input placeholder text |
269
269
|**visibleWhen**|`string`| optional | CEL predicate controlling visibility, evaluated client-side |
@@ -276,7 +276,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
276
276
277
277
| Property | Type | Required | Description |
278
278
| :--- | :--- | :--- | :--- |
279
-
|**value**|`any`| ✅ | Stored value |
279
+
|**value**|`any`| ✅ | Stored value; it also addresses the option as text (its label translation key), so it must stay distinct from its siblings as text|
|**successMessage**|`string`| optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
262
+
|**errorMessage**|`string`| optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
261
263
|**screens**|`Record<string, { title?: string; fields?: Record<string, object> }>`| optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
262
264
|**refusals**|`Record<string, { message?: string }>`| optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'`|
263
265
@@ -376,7 +378,7 @@ Per-app translation data for objects, apps, and UI messages
|**successMessage**|`string`| optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
463
+
|**errorMessage**|`string`| optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
460
464
|**screens**|`Record<string, { title?: string; fields?: Record<string, object> }>`| optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
461
465
|**refusals**|`Record<string, { message?: string }>`| optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'`|
462
466
@@ -526,7 +530,7 @@ One locale of translations — the `translation` metadata type
|**successMessage**|`string`| optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
625
+
|**errorMessage**|`string`| optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
620
626
|**screens**|`Record<string, { title?: string; fields?: Record<string, object> }>`| optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
621
627
|**refusals**|`Record<string, { message?: string }>`| optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'`|
0 commit comments