Skip to content

Commit ca0bdbc

Browse files
fix(spec)!: a builtin config value carrying a {token} is judged for its type at the build doors (#23260) (#23319)
Fixes #23260 Clause-②: no (narrowing) ## What changes The build doors' value arm (`flowNodeConfigRefusals` in `packages/spec/src/automation/flow-node-config-refusals.ts`) judged a builtin node's present config value against its executor contract, except a value carrying a `{token}`: `builtinValueJudged` returned false when `carriesInterpolationToken` found one, because "only the interpolation decides what the run will see there". #19939 deleted the flow interpolator and every call site it had, and every builtin executor parses its config as authored (an `http` node after its credential channel's `signingSecret`). So every value the carve-out admitted was refused at every run that reached the node. This PR deletes the carve-out: - **A token-carrying value is judged for its type like any literal.** `get_record` with `limit: '{x}'` is refused at `nodes.N.config.limit` ("expected number, received string") by `FlowSchema.parse`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.limit`), `objectstack validate`, the save door and `AutomationEngine.registerFlow`. - **The value-slot and text-slot judges keep their own located token refusals, and the arm exempts them explicitly.** It asks the two judges which paths they refuse (`slotJudgeRefusedPaths`: `flowNodeValueTemplateRefusals`, and `textSlotTemplateRefusal` over `flowNodeTextSlotSources`) and skips a contract issue at one of those paths. A token at a value or text slot is therefore reported once, by its judge, with its label and remedy. The judges are asked, not re-read: no second copy of the dialect's grammar. - **A literal read as written keeps its own words.** At a key whose contract issue is marked `READ_AS_WRITTEN_PARAM` (a `notify` `sourceObject` / `actorId` / `payload`, an `http` `method` / `durable` / `timeoutMs` / whole `headers` map), the marked issue speaks for the key. With the carve-out gone, the contract's type issue beside it (`timeoutMs: '{t}'` is also not a number) would otherwise reach the arm first and replace the literal's remedy. The key is refused once, as before. - **The rationale says what holds.** It is corrected in five places: the `READ_AS_WRITTEN_PARAM` docblock (`flow-template-token.ts`), the value arm's docblocks, the `FlowSchema` comment that restates the arm, the step-18 D3 entry `flow-builtin-node-config-values-refused`, and its rationale fragment in `registry.ts.template`. `carriesInterpolationToken` and `INTERPOLATION_TOKEN` are deleted: nothing else read them, and neither was exported. `check:api-surface` reads "public API surface + factory signatures unchanged". ## Measured first (the dispatch's four assumptions) - **A1, nothing interpolates any more: HOLDS.** At base `175e8d414`, `service-automation/src/builtin/template.ts` exports only `VariableMap`, `FlowTextTemplateError`, `textTemplateScope` and `renderTextSlot`. The deleted interpolator survives only in comments. Every contract-carrying executor calls `parseNodeConfig(type, id, Schema, node.config)` on the authored config: crud x4, `http` (on `{ ...authored, signingSecret: held }`), `loop` (with a `body`), `map`, `notify`, `parallel`, `screen`, `script`, `subflow` and `try_catch`. Text slots are rendered and value-slot envelopes evaluated only after the parse. Searching `service-automation/src` and `spec/src/automation` for `interpolate`, `interpolateString` and `resolveToken` finds no code caller. - **A2, the build doors share the one judge: HOLDS, and both are pinned.** `FlowSchema`'s superRefine calls `flowNodeConfigRefusals` (`flow.zod.ts`). `defineStack` parses `ObjectStackDefinitionSchema`, whose `flows` are `FlowSchema`. `registerFlow` parses first, in `canonicalizeStoredFlow`. Pins: `defineStack` in the spec test, `registerFlow` in `service-automation`'s `config-parse.test.ts`. - **A3, the double-report risk: HOLDS, with one addition.** A throwaway probe at base (39 configs, deleted after the run) measured one token at each kind of slot. At a value slot, the contract raised one issue at the slot path: a `custom` issue with the value-slot judge's message, or at a `loop` / `map` `collection` an `invalid_union` carrying the same message. The value-slot judge refused the same path, and the base arm reported nothing. Text slots measured the same way with the text-slot judge (a single brace, or a `{{ $User.Id }}` hole). Without the exemption, the arm adds `node-config-refused-by-contract` at that same path (ablation A2 below). **The addition:** at the `http` literal keys, the contract also raises a type issue beside the `READ_AS_WRITTEN_PARAM` one. Only the carve-out had kept that type issue from reaching the arm first, so the arm now prefers the marked issue (ablation A3). - **A4, lint and docs: HOLDS.** No rule in `packages/lint/src` restates the carve-out: the lint calls `flowNodeConfigRefusals`. A grep of `content/docs`, `skills` and `docs` for a token at a typed key finds nothing. Control: the same pattern finds the spec test's `limit: '{page.size}'`. The only hits are release-owned `CHANGELOG.md` entries, including `@objectstack/spec`'s `18.0.0-next.0` entry describing the carve-out. They are not edited here; the changeset carries the FROM → TO. **At the doors, after the change** (throwaway probes through the shipped `AutomationEngine.registerFlow` and `@objectstack/lint` `validateStackExpressions`, on a rebuilt `dist` at `f88ed85f6`): - The probe is refused at `config.limit`. - `limit: 10` registers. - Each token at a value slot (`fields.title`, `collection`, `recordId`) and at a text slot (`notify` `title`, `http` `url`) draws exactly one finding, the slot judge's located one. - `http` `timeoutMs: '{t}'` draws one finding, in the literal's words. **Who is affected.** The census was a TS-AST pass. It statically evaluated every builtin node literal's `config` in `examples/`, `packages/` and `apps/` at base, plus hotcrm at `0f84c9c`: 1010 nodes, 244 of them carrying a `{…}` token, 266 partly dynamic. It compared the base arm with the new one and found 0 verdict changes. Control: a synthetic `get_record` with `limit: '{x}'` moves. ## Tests (at `f88ed85f6`, the head this PR opens on) - `@objectstack/spec`, the whole `local` project in four locked runs: `src/automation` + `src/migrations` 40 files / 1769 tests; `src/ui src/ai src/security src/integration src/identity src/marketplace src/studio src/qa src/meta-spelling` 170 / 5824; `src/data src/kernel src/conversions` 191 / 5691 (+1 todo); `src/system src/api src/contracts src/shared scripts` + the root files 268 / 6998. All passed. `repo` project: `scripts/step18-rationale-merge.test.ts` 1 / 9 passed. - `@objectstack/spec` `typecheck`: exit 0. `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` all pass, and the test layer is held in its debt ledger. - `@objectstack/service-automation`: 185 files / 2450 tests passed; `typecheck` exit 0. - `@objectstack/lint`: 138 files / 6683 tests passed. - New pins in `flow-builtin-node-config-values.test.ts`: - the probe, at `nodes.1.config.limit`, in the contract's words; - the probe through `defineStack`, in the ADR-0112 envelope; - a literal of the right type, unchanged; - every spelling (`{x}`, `{{ x }}`, `${x}`) at seven typed keys; - one token at each of six value slots, reported once, by the value-slot judge; - one token at each of six text slots, reported once, by the text-slot judge; - `http` `timeoutMs` and whole `headers`, refused once, in the literal's words. The old CONTROL "a token value in a typed slot is never refused for its pre-interpolation type" is removed: it pinned the carve-out. - New pin in `config-parse.test.ts`: `get_record` `limit: '{x}'` is refused at registration (`refused at \`limit\` by the get_record contract`) and at the run (`config.limit`), and `limit: 10` registers. ## The negative control and two ablations (one-time proofs, not committed) Each leg ran through `scripts/ablation-replace.mjs` in wrap mode under `os-verify-lock`. Each anchor's count dropped and the blob changed. A shell trap restored by absolute path, and each restore was proved: blob equals HEAD `7199602764fe`, and `git diff HEAD` is empty. No `dist` leg was needed, because the spec pins import the source by relative path. - **A1, the carve-out restored (the negative control).** Expected: the probe pins go red. Observed: 3 failed / 52 passed: the probe, the `defineStack` probe, and the every-spelling pin. ⚠ The first A1 attempt was an **empty operation**: its replacement carried its own anchor, so the anchor count stayed 1 → 1, and the tool refused before running the test. It was re-run with a two-line anchor, and the marker count on disk read 1. - **A2, the slot-judge exemption deleted.** 4 failed / 51 passed: both "reported once" pins, plus the existing CONTROLs "a slot another judge owns is not re-judged here" and "a token-free sibling does not shield a refused value". - **A3, the read-as-written preference deleted.** 1 failed / 54 passed: the literal's-words pin. - **Control, the restored tree:** 55 / 55 passed. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `f88ed85f6` derived 90 commands. Every one was run, with its exit code captured before any pipe: 89 exited 0. `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (it needs every package's `dist`, and this tree built only the `service-automation` and `lint` closures), so it is NOT MEASURED. Reconcile: `✓ dispatch-gates --ran: 90 derived famil(ies) accounted for — 89 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. `pnpm --filter @objectstack/spec check:generated` reads "All 14 generated artifacts are up to date", and the build changed no tracked file. `check-adr-0087-registration` accepts `not-required (already-registered flow-builtin-node-config-values-refused)`. The tree is 4 commits behind `origin/main` `404fc477e`. None of those commits touches a file this diff touches, but three files the derivation reads changed there: `scripts/pm/dispatch-gates.mjs`, its self-test, and `scripts/tenant-audit-census.mjs`. CI's merge-ref run is the reading on current `main`. ESLint, as a measured narrowing: `eslint --no-inline-config --format json` over the 8 changed files. The config ignores two of them (the changeset and `registry.ts.template`, per eslint's own JSON). It linted the other 6 with 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. ## File surface The claim's surface, plus three files that restate the arm's rationale. Leaving them would publish a sentence this change makes false: - `flow.zod.ts`: one comment; - `migrations/entries/semantic/18.flow-builtin-node-config-values-refused.ts`: the D3 entry's `surface`, `replacement`, `reason` and `acceptanceCriteria`; - `migrations/registry.ts.template`: the step-18 fragment, order 85. Both registry files feed the generated upgrade guide, which is gitignored, so nothing generated is committed. The PR also adds one pin to `service-automation`'s `config-parse.test.ts`, the `registerFlow` door the dispatch asked to pin. No consumer fixture asserted the old carve-out: the census found 0 moved verdicts, and the only test that pinned it was the spec CONTROL above. ## Acceptance notes - **A co-located type issue at a slot its judge refuses keeps the judge's verdict first.** Measured once: a `screen` `title` written as a `{ dialect: 'template', source: 'Hi {name}' }` envelope. The contract types it as a string, and the text-slot judge refuses the token. The arm exempts the path, as the carve-out did. The type refusal appears once the token is fixed. - **One more rule finding now reaches the arm.** A token used to hide it: a `notify` `templateData` with no `template` (its rule refuses that whatever the values hold). - **The changeset's level.** It is `minor`, following the convention #21898's own changeset used for this arm's accept-set narrowing. The line is `18.0.0-next.*` in pre mode. --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 572ecfb commit ca0bdbc

8 files changed

Lines changed: 339 additions & 103 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
A builtin flow node's `config` value that carries a `{token}` is judged for its type at the build doors like any other value: `get_record` with `limit: '{x}'` is refused at `nodes.N.config.limit` ("expected number, received string") by `FlowSchema.parse`, `defineStack`, `objectstack validate`, `objectstack compile`, the metadata save door and `AutomationEngine.registerFlow`, where it used to register and then fail every run that reached the node.
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered flow-builtin-node-config-values-refused) The step-18 D3 entry registered on this line records the build doors refusing a builtin config value its executor contract refuses; this change removes the one class that entry held back, a value carrying a token, and the entry's text now says so. No key is removed and no shape is rewritten, so there is no tombstone and no D2 conversion: the platform cannot know the literal the author meant. -->
10+
11+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`).
12+
13+
**Why.** The build doors' value arm (`flowNodeConfigRefusals`) never refused a value carrying a `{token}` for its type, because the flow interpolator decided what the run would parse there. The interpolator and every call site it had are deleted on this line, and every builtin executor parses its config as authored (an `http` node after its credential channel's `signingSecret`), so the run's contract sees the token the build's sees and refuses it at every run. The value arm passed exactly those values.
14+
15+
**What is refused now.** A token-carrying value at a builtin key its executor contract refuses it at: a number or boolean key (`get_record` `limit`, `loop` `maxIterations`, `update_record` and `delete_record` `multi`, a screen field's `min`, `max` or `required`, a `try_catch` `retry.maxRetries`), a key with a declared set (`notify` `severity`, `screen` `mode`), a record key written as one token (a CRUD `filter: '{f}'`), and a rule finding the token used to hide (a `notify` `templateData` with no `template`). Each is `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key, in the contract's own words — `defineStack` throws `STACK_SCHEMA_INVALID` (422) at `flows.N.nodes.M.config.<key>`.
16+
17+
**What does not change.**
18+
19+
- A token at a plain string key (`outputVariable`, `flowName`, `objectName`) parses and registers as before: the contract takes a string there.
20+
- A token at a value slot (a CRUD `fields` value, a callee `input`, a screen's `defaults`, `defaultValue` or `recordId`, a `loop` or `map` `collection`, a `notify` `recipients`, `templateData` or `sourceId`, an `http` `body`, a filter operand) or at a text slot (a `notify` `title`, `message` or `actionUrl`, a screen `title` or `description`, an `http` `url` or header value) is refused, as before, by that slot's own judge at `registerFlow` and `objectstack validate`, once, with the slot's label and remedy: the value arm exempts each finding that judge owns, so no token is reported twice.
21+
- At a key its executor reads as written (a `notify` `sourceObject`, `actorId` or `payload`; an `http` `method`, `durable`, `timeoutMs` or whole `headers` map) the key is refused once, in the literal's own words, as before.
22+
- An `http` `signingSecret` is still never judged at the build doors: the credential channel may supply what the run signs with.
23+
24+
## FROM → TO
25+
26+
| you wrote | write instead | what changes |
27+
|:--|:--|:--|
28+
| `get_record` `limit: '{page.size}'` | `limit: 25` | refused at `config.limit` at every build door, where it registered and failed every run |
29+
| `loop` `maxIterations: '{cap}'` | `maxIterations: 50` | the same, at `config.maxIterations` |
30+
| `update_record` `multi: '{bulk}'` | `multi: true` | the same, at `config.multi` |
31+
| a screen field `min: '{m}'` | `min: 1` | the same, at `config.fields.N.min` |
32+
| `try_catch` `retry: { maxRetries: '{n}' }` | `retry: { maxRetries: 3 }` | the same, at `config.retry.maxRetries` |
33+
| `notify` `severity: '{level}'` | `severity: 'warning'` | the same, at `config.severity` |
34+
35+
**The one-line fix: write the literal the key declares.** None of these keys is evaluated per run, so no envelope replaces the token there.
36+
37+
**Who is affected, measured.** A census of every builtin node literal whose `config` evaluates statically — 1010 nodes, 244 of them carrying a `{…}` token, across `examples/`, `packages/` and `apps/` in this repository at the base and hotcrm at `0f84c9c` — finds no verdict this change moves (a control node with `limit: '{x}'` moves, so the census can see one). No published docs page and no `@objectstack/lint` rule says a token at a typed key passes the build.
38+
39+
### The kit
40+
41+
- **The judge.** `flowNodeConfigRefusals`'s builtin value arm (`flow-node-config-refusals.ts`): the token carve-out (`carriesInterpolationToken`) is deleted. In its place the arm asks the value-slot judge (`flowNodeValueTemplateRefusals`) and the text-slot judge (`textSlotTemplateRefusal`) which paths they refuse and exempts those, and reports a key whose contract marks its refusal `READ_AS_WRITTEN_PARAM` in that refusal's words alone.
42+
- **ADR-0087.** No new entry: the step-18 D3 entry `flow-builtin-node-config-values-refused` and its rationale fragment now state the class this change judges.

‎packages/services/service-automation/src/builtin/config-parse.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,20 @@ describe('execute-time config parse (#4277)', () => {
210210
expect(enqueued[0]?.timeoutMs).toBe(1500);
211211
});
212212

213+
it('[#23260] a token in a number slot is refused at registration, at config.limit, and at the run', async () => {
214+
// Until #23260 the build doors never judged a value carrying a `{token}`
215+
// for its type, so this node registered — and every builtin parses its
216+
// config as authored, so every run that reached it was refused here.
217+
const door = doorRefusal('get_record', { objectName: 'account', limit: '{x}' });
218+
expect(door).toContain('refused at `limit` by the get_record contract');
219+
expect(door).toContain('expected number, received string');
220+
const result = await runPatched(engineWith(), 'get_record', { objectName: 'account', limit: 10 }, { limit: '{x}' });
221+
expect(result.success).toBe(false);
222+
expect(result.error).toContain('config.limit');
223+
// CONTROL: the literal of the right type registers.
224+
expect(doorRefusal('get_record', { objectName: 'account', limit: 10 })).toBe('');
225+
});
226+
213227
it('http still refuses a statically wrong-typed slot', async () => {
214228
expect(doorRefusal('http', { url: 'https://example.test', timeoutMs: 'soon' })).toContain('refused at `timeoutMs`');
215229
const engine = engineWith();

‎packages/spec/src/automation/flow-builtin-node-config-values.test.ts‎

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@
1212
* `outputVariable: 42`, a screen field's `min: '1'` — used to pass
1313
* `FlowSchema`, `objectstack validate` and `objectstack compile`, register, and
1414
* then fail every run that reached the node. The pins below hold the refusal at
15-
* every door that parses a flow, and the controls hold what stays accepted:
15+
* every door that parses a flow — (#23260) a `{token}` value in a typed slot
16+
* included, judged for its type like any literal now that every builtin parses
17+
* its config as authored — and the controls hold what stays accepted:
1618
*
17-
* - a valid node of every builtin type;
18-
* - a `{token}` value in a typed slot, in each spelling an author writes —
19-
* never refused for its pre-interpolation type (except at a key its
20-
* executor reads as written, a notify or http literal — #19939);
19+
* - a valid node of every builtin type, and a literal of the right type where
20+
* a token is refused;
2121
* - the places the build cannot know what the run parses, or another judge
2222
* owns the finding (key membership, a region slot, a `predicate` / `value`
23-
* ledger slot, `http`'s run-resolved `signingSecret`, a legacy `loop` with
24-
* no `body`).
23+
* ledger slot, a value or text slot whose own judge refuses its token,
24+
* `http`'s run-resolved `signingSecret`, a legacy `loop` with no `body`).
2525
*/
2626

2727
import { describe, expect, it } from 'vitest';
@@ -30,6 +30,8 @@ import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
3030
import { MIGRATIONS_BY_MAJOR } from '../migrations/registry';
3131
import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod';
3232
import { flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals';
33+
import { flowNodeTextSlotSources, textSlotTemplateRefusal } from './flow-text-slot-template';
34+
import { flowNodeValueTemplateRefusals } from './flow-value-slot-template';
3335
import { FlowSchema } from './flow.zod';
3436

3537
const ENTRY_ID = 'flow-builtin-node-config-values-refused';
@@ -207,6 +209,133 @@ describe('one refusal per judged builtin, at each typed key', () => {
207209
});
208210
});
209211

212+
/**
213+
* Every finding the three judges `registerFlow` and `objectstack validate` call
214+
* on one node's config raise — this arm, the value-slot judge and the
215+
* text-slot judge — each tagged with its judge and located at its path.
216+
*/
217+
function judgedAtTheDoors(type: string, config: Config): string[] {
218+
return [
219+
...flowNodeConfigRefusals(type, config).map(({ path }) => `contract:${path}`),
220+
...flowNodeValueTemplateRefusals(type, config).map(({ path }) => `value-slot:${path}`),
221+
...flowNodeTextSlotSources(type, config)
222+
.filter(({ source }) => textSlotTemplateRefusal(source) !== undefined)
223+
.map(({ path }) => `text-slot:${path}`),
224+
];
225+
}
226+
227+
describe('[#23260] a value carrying a `{token}` is judged for its type, like any literal', () => {
228+
// Every builtin executor parses its config as authored since #19939 deleted
229+
// the flow interpolator, so the run's contract sees the token the build's
230+
// sees, and refuses it there at every run that reaches the node.
231+
232+
it('the probe: get_record limit {x} is refused at nodes.1.config.limit, in the contract\'s words', () => {
233+
const config = { ...VALID.get_record, limit: '{x}' };
234+
const issues = issuesOf(flowWith('get_record', config));
235+
expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'custom', path: 'nodes.1.config.limit' }]);
236+
expect(issues[0]!.message).toBe(flowNodeConfigRefusals('get_record', config)[0]!.message);
237+
expect(issues[0]!.message).toContain(contractSentence('get_record', config, 'limit'));
238+
expect(contractSentence('get_record', config, 'limit')).toMatch(/expected number, received string/);
239+
expect(flowNodeConfigRefusals('get_record', config).map(({ code, params, path }) => ({ code, params, path }))).toEqual([
240+
{ code: 'node-config-refused-by-contract', params: { nodeType: 'get_record', key: 'limit' }, path: 'limit' },
241+
]);
242+
});
243+
244+
it('defineStack refuses the probe in its ADR-0112 envelope, at flows.0.nodes.1.config.limit', () => {
245+
const stack = {
246+
manifest: { id: 'com.example.tokens', name: 'tokens', version: '1.0.0', type: 'app', namespace: 'tok' },
247+
objects: [{ name: 'tok_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }],
248+
flows: [flowWith('get_record', { objectName: 'tok_account', limit: '{x}' }, 'tok_probe')],
249+
};
250+
let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined;
251+
try {
252+
defineStack(stack as never);
253+
} catch (e) {
254+
refusal = e as typeof refusal;
255+
}
256+
expect(refusal, 'defineStack must refuse the flow').toBeDefined();
257+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 });
258+
expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([
259+
{ path: 'flows.0.nodes.1.config.limit', code: 'custom' },
260+
]);
261+
});
262+
263+
it('CONTROL: a literal of the right type is unchanged', () => {
264+
const config = { ...VALID.get_record, limit: 25 };
265+
expect(flowNodeConfigRefusals('get_record', config)).toEqual([]);
266+
expect(issuesOf(flowWith('get_record', config))).toEqual([]);
267+
});
268+
269+
it('in every spelling an author writes, at each typed key', () => {
270+
const TOKENS: ReadonlyArray<readonly [string, Config, string]> = [
271+
['get_record', { limit: '{page.size}' }, 'limit'],
272+
['get_record', { limit: '{{ page.size }}' }, 'limit'],
273+
['screen', { fields: [{ name: 'qty', min: '${minimum}' }] }, 'fields[0].min'],
274+
['update_record', { multi: '{bulk}' }, 'multi'],
275+
['notify', { severity: '{level}' }, 'severity'],
276+
['loop', { maxIterations: '{cap}' }, 'maxIterations'],
277+
['try_catch', { retry: { maxRetries: '{tries}' } }, 'retry.maxRetries'],
278+
];
279+
for (const [type, edit, key] of TOKENS) {
280+
const config = { ...VALID[type], ...edit };
281+
expect(flowNodeConfigRefusals(type, config).map(({ code, path }) => ({ code, path })), JSON.stringify(edit))
282+
.toEqual([{ code: 'node-config-refused-by-contract', path: key }]);
283+
expect(issuesOf(flowWith(type, config)), JSON.stringify(edit)).toHaveLength(1);
284+
}
285+
});
286+
287+
it('one token at a value slot is reported once — by the value-slot judge, never beside it by this arm', () => {
288+
// The contracts compose the value-slot judge, so each of these configs is
289+
// refused by its contract at the slot (a collection's through its union's
290+
// message); the arm exempts that refusal and the judge alone reports it.
291+
const CASES: ReadonlyArray<readonly [string, Config, string]> = [
292+
['create_record', { ...VALID.create_record, fields: { title: '{record.name}' } }, 'fields.title'],
293+
['screen', { objectName: 'account', mode: 'edit', recordId: '{record.id}' }, 'recordId'],
294+
['screen', { fields: [{ name: 'qty', type: 'number', defaultValue: '{qty}' }] }, 'fields[0].defaultValue'],
295+
['loop', { ...VALID.loop, collection: '{rows}' }, 'collection'],
296+
['notify', { ...VALID.notify, recipients: '{owner}' }, 'recipients'],
297+
['http', { ...VALID.http, body: '{payload}' }, 'body'],
298+
];
299+
for (const [type, config, key] of CASES) {
300+
expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, `${type} ${key}`).toBe(false);
301+
expect(judgedAtTheDoors(type, config), `${type} ${key}`).toEqual([`value-slot:${key}`]);
302+
}
303+
});
304+
305+
it('one token at a text slot is reported once — by the text-slot judge, never beside it by this arm', () => {
306+
const CASES: ReadonlyArray<readonly [string, Config, string]> = [
307+
['notify', { title: { dialect: 'template', source: 'Deal {deal.name} won' } }, 'title'],
308+
['notify', { title: { dialect: 'template', source: 'Hi {{ $User.Id }}' } }, 'title'],
309+
['notify', { actionUrl: '/crm_deal/{record.id}' }, 'actionUrl'],
310+
['http', { url: 'https://example.test/{id}' }, 'url'],
311+
['http', { headers: { 'X-Kind': '{kind}' } }, 'headers.X-Kind'],
312+
['screen', { description: 'Order {order.no}' }, 'description'],
313+
];
314+
for (const [type, edit, key] of CASES) {
315+
const config = { ...VALID[type], ...edit };
316+
expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, `${type} ${key}`).toBe(false);
317+
expect(judgedAtTheDoors(type, config), `${type} ${key}`).toEqual([`text-slot:${key}`]);
318+
}
319+
});
320+
321+
it('a literal the executor reads as written is refused once, in the literal\'s words, never its type\'s', () => {
322+
// [#19939] The contract refuses `timeoutMs: '{t}'` twice — its type, and the
323+
// literal's own refusal marked READ_AS_WRITTEN_PARAM; the key is refused once.
324+
const CASES: ReadonlyArray<readonly [Config, string, string]> = [
325+
[{ timeoutMs: '{t}' }, 'timeoutMs', '`timeoutMs` is the per-request timeout in milliseconds, a number, a literal the executor reads as written'],
326+
[{ headers: '{h}' }, 'headers', '`headers` is a map of header name to text, read as written'],
327+
];
328+
for (const [edit, key, lead] of CASES) {
329+
const config = { ...VALID.http, ...edit };
330+
const refusals = flowNodeConfigRefusals('http', config);
331+
expect(refusals.map(({ path }) => path), key).toEqual([key]);
332+
expect(refusals[0]!.message, key).toContain(lead);
333+
expect(refusals[0]!.message, key).not.toMatch(/expected (number|record), received string/);
334+
expect(judgedAtTheDoors('http', config), key).toEqual([`contract:${key}`]);
335+
}
336+
});
337+
});
338+
210339
describe('what stays accepted (lit controls)', () => {
211340
it('CONTROL: a valid node of every builtin type parses, and the judge says nothing', () => {
212341
expect(Object.keys(VALID).sort()).toEqual([...getBuiltinNodeConfigContracts().keys()].sort());
@@ -217,26 +346,6 @@ describe('what stays accepted (lit controls)', () => {
217346
}
218347
});
219348

220-
it('CONTROL: a token value in a typed slot is never refused for its pre-interpolation type', () => {
221-
// [#19939] Not `http`: its executor interpolates nothing since then, so a
222-
// token at its `timeoutMs`, `durable` or whole `headers` is read as written
223-
// and refused at every door (`io-node-config.test.ts`, the http literals).
224-
const TOKENS: ReadonlyArray<readonly [string, Config]> = [
225-
['get_record', { limit: '{page.size}' }],
226-
['screen', { fields: [{ name: 'qty', min: '${minimum}', max: '{maximum}' }] }],
227-
['update_record', { multi: '{bulk}' }],
228-
['loop', { maxIterations: '{cap}' }],
229-
['try_catch', { retry: { maxRetries: '{tries}' } }],
230-
];
231-
for (const [type, edit] of TOKENS) {
232-
const config = { ...VALID[type], ...edit };
233-
// The contract itself refuses the string — only the judge holds back.
234-
expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, JSON.stringify(edit)).toBe(false);
235-
expect(flowNodeConfigRefusals(type, config), JSON.stringify(edit)).toEqual([]);
236-
expect(issuesOf(flowWith(type, config)), JSON.stringify(edit)).toEqual([]);
237-
}
238-
});
239-
240349
it('CONTROL: a token-free sibling does not shield a refused value (http judges each slot on its own)', () => {
241350
const config = { ...VALID.http, url: 'https://example.test/{path}', timeoutMs: '5000' };
242351
expect(flowNodeConfigRefusals('http', config).map(({ path }) => path)).toEqual(['timeoutMs']);

0 commit comments

Comments
 (0)