From 8139de719f2a8553198e7ef9a9489e63e312b56f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:56:34 +0000 Subject: [PATCH 1/3] wip(spec): the builtin value arm judges a token-carrying value like any literal (#23260) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H --- .../automation/flow-node-config-refusals.ts | 147 +++++++++++++----- .../src/automation/flow-template-token.ts | 16 +- 2 files changed, 114 insertions(+), 49 deletions(-) diff --git a/packages/spec/src/automation/flow-node-config-refusals.ts b/packages/spec/src/automation/flow-node-config-refusals.ts index d42c9bc6da0..7199602764f 100644 --- a/packages/spec/src/automation/flow-node-config-refusals.ts +++ b/packages/spec/src/automation/flow-node-config-refusals.ts @@ -35,6 +35,12 @@ import { STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject } from '. import { FLOW_REGION_SLOTS_BY_TYPE } from './region-slots'; import { FLOW_NODE_EXPRESSION_PATHS, isExpressionEnvelopeShaped } from './flow-node-expression-paths'; import { READ_AS_WRITTEN_PARAM } from './flow-template-token'; +// [#23260] The two slot judges, ASKED by the value arm for the paths they own +// (`slotJudgeRefusedPaths`) — never a second reading of their grammar here. +// Both are already in this module's import graph through the contracts below, +// which compose them. +import { flowNodeTextSlotSources, textSlotTemplateRefusal } from './flow-text-slot-template'; +import { flowNodeValueTemplateRefusals } from './flow-value-slot-template'; import type { FlowNodeConfigRefusal, FlowSlotRefusalParams, NodeConfigValueKind } from './flow-node-expression-paths'; // The executor contracts. Read only inside `getBuiltinNodeConfigContracts`, // never at module load: `control-flow.zod.ts` sits in the flow-schema import @@ -232,7 +238,8 @@ function insideRegion(nodeType: string, path: ReadonlyArray): boole * Does an issue path descend INTO a ledger `value` slot (`fields.total.source` * under `fields.*`; since #19939 `fields[0].defaultValue.source` under a * screen's `fields[].defaultValue`)? Such a slot holds an authored VALUE — a - * literal, a `{token}` template, or an expression envelope — and a key missing + * literal or an expression envelope (a `{token}` in a literal is refused by + * the value-slot judge, #19939) — and a key missing * inside it is a malformed value, judged at `registerFlow` and `objectstack * validate` by the value-envelope pass with its own refusal, never a config * key left out. Matched segment by segment ({@link startsWithLedgerSlot}), so @@ -248,27 +255,6 @@ function insideValueSlot(nodeType: string, path: ReadonlyArray): bo // ─── The builtin VALUE arm (#21898) ─────────────────────────────────── -/** - * [#21898] A `{token}` the run interpolates — the interpolator's own token - * shape (`service-automation` `builtin/template.ts`, `interpolateString`, and - * the lint's `TEMPLATE_TOKEN_RE`), which also matches the double-brace and - * dollar-brace spellings an author may write. A string carrying one means - * something only after interpolation, so the build never judges its type. - */ -const INTERPOLATION_TOKEN = /\{[^{}]+\}/; - -/** - * [#21898] Does `value`, or anything inside it, carry a {@link - * INTERPOLATION_TOKEN}? Cycle-safe: a flow arriving as hand-built objects - * rather than parsed JSON may hold a self-reference. - */ -function carriesInterpolationToken(value: unknown, seen: Set = new Set()): boolean { - if (typeof value === 'string') return INTERPOLATION_TOKEN.test(value); - if (value === null || typeof value !== 'object' || seen.has(value)) return false; - seen.add(value); - return (Array.isArray(value) ? value : Object.values(value)).some((v) => carriesInterpolationToken(v, seen)); -} - /** The authored value an issue path names, or `undefined` when the walk leaves the config. */ function authoredAt(config: Readonly>, path: ReadonlyArray): unknown { let at: unknown = config; @@ -327,9 +313,10 @@ function startsWithLedgerSlot(segments: readonly string[], path: ReadonlyArray

>; } +/** Does the contract mark this issue {@link READ_AS_WRITTEN_PARAM} — a literal's own refusal of what it holds? */ +function isReadAsWrittenIssue(issue: ContractIssue): boolean { + return issue.code === 'custom' && issue.params?.[READ_AS_WRITTEN_PARAM] === true; +} + +/** + * [#23260] Every config path at which a slot's OWN judge refuses the authored + * value, in the ledger spelling ({@link ledgerPathOf}): each refusal of the + * value-slot judge ({@link flowNodeValueTemplateRefusals}, the ledger's `value` + * slots) and each text slot the text-slot judge refuses + * ({@link textSlotTemplateRefusal} over {@link flowNodeTextSlotSources}: a + * single-brace token, or a `{{ }}` hole over a `$` root the engine does not + * bind). + * + * Both judges are asked, never re-read: their slots, their option per slot (a + * collection, a filter operand) and their grammar are theirs. The contracts + * compose the same two judges, so a contract issue at one of these paths is + * that judge's refusal arriving through the parse — or, at a `loop`'s or a + * `map`'s `collection`, the same refusal arriving as the union's message — + * and `registerFlow` and `objectstack validate` call both judges themselves, + * with the slot's label and, where it has one, its callee, screen or recipient + * sentence. Reported again here, one token would draw two refusals at + * `objectstack validate` (this arm's beside the judge's), and at `registerFlow` + * the parse's would replace the judge's located one. + */ +function slotJudgeRefusedPaths(nodeType: string, authored: Readonly>): ReadonlySet { + const paths = new Set(); + for (const refusal of flowNodeValueTemplateRefusals(nodeType, authored)) paths.add(refusal.path); + for (const slot of flowNodeTextSlotSources(nodeType, authored)) { + if (textSlotTemplateRefusal(slot.source) !== undefined) paths.add(slot.path); + } + return paths; +} + +/** + * What {@link builtinValueJudged} reads beyond one issue, per node config: the + * keys at which the contract marks an issue {@link READ_AS_WRITTEN_PARAM}, and + * — computed on first ask — {@link slotJudgeRefusedPaths}. + */ +interface ValueArmScope { + readonly readAsWrittenKeys: ReadonlySet; + slotJudgeRefused(): ReadonlySet; +} + /** * [#21898] Is a builtin contract's issue at a PRESENT key a value the build can - * refuse — one the run would refuse for the same reason? Every carve-out is a - * place where the build does not know what the run will parse, or where - * another judge owns the finding: + * refuse — one the run would refuse for the same reason? + * + * (#23260) Every builtin executor parses its config AS AUTHORED — `http` after + * its credential channel's `signingSecret`, nothing else — since #19939 + * deleted the flow interpolator and every call site it had, so the run's + * contract sees what the build's sees, a `{token}` included: a value that + * carries one is judged for its type like any literal (`get_record` + * `limit: '{x}'` is refused at `limit`, and at every run it reached it was + * refused there too). Until then a value carrying a token was never judged + * here, because the interpolation decided what the run would parse. + * + * Every exemption is a place where the build does not know what the run will + * parse, or where another judge owns the finding: * * - key membership — an undeclared key (`unrecognized_keys`) and a tombstoned * one (a `retiredKey()`, {@link isRetiredKeyIssue}): both are the key @@ -393,27 +435,33 @@ interface ContractIssue { * - a `predicate` ledger slot, at or inside it, and a `value` one whose value * is an envelope ({@link atOrInsideJudgedLedgerSlot}); * - a run-resolved key ({@link RUN_RESOLVED_KEYS}); - * - a value carrying a `{token}` anywhere inside it: it is never refused for - * its pre-interpolation type, even where the executor parses the authored - * config and so refuses it at the run — unless the contract marks its - * issue {@link READ_AS_WRITTEN_PARAM} (#19939: a `notify` node's + * - (#23260) a key whose contract refusal is the literal's own — an issue + * marked {@link READ_AS_WRITTEN_PARAM} (#19939: a `notify` node's * `sourceObject`, `actorId` and `payload`, and an `http` node's `method`, - * `durable` and `timeoutMs` and a token written as its whole `headers` - * map, which the executor reads as written, never through the interpolator, so the run sees the token the build sees and - * the contract's refusal of it is the run's verdict). + * `durable` and `timeoutMs` and a token written as its whole `headers` map, + * each read as written): the marked issue is judged, and any other issue at + * that key is not, so the key is refused once, in the literal's words (its + * remedy names the value to write, and what the token would do read as + * written), where the contract's type issue beside it says less; + * - (#23260) a value slot or a text slot whose value its own judge refuses + * ({@link slotJudgeRefusedPaths}): that judge's located refusal is the + * slot's, reported by the doors that call it, so this arm adds none. */ function builtinValueJudged( nodeType: string, authored: Readonly>, issue: ContractIssue, + scope: ValueArmScope, ): boolean { if (issue.code === 'unrecognized_keys') return false; if (isRetiredKeyIssue(issue)) return false; if ((FLOW_REGION_SLOTS_BY_TYPE.get(nodeType) ?? []).some((slot) => slot.key === issue.path[0])) return false; if (atOrInsideJudgedLedgerSlot(nodeType, authored, issue.path)) return false; if ((RUN_RESOLVED_KEYS[nodeType] ?? []).includes(issue.path[0] as string)) return false; - if (issue.code === 'custom' && issue.params?.[READ_AS_WRITTEN_PARAM] === true) return true; - if (carriesInterpolationToken(authoredAt(authored, issue.path))) return false; + if (isReadAsWrittenIssue(issue)) return true; + const key = ledgerPathOf(issue.path); + if (scope.readAsWrittenKeys.has(key)) return false; + if (scope.slotJudgeRefused().has(key)) return false; return true; } @@ -650,8 +698,12 @@ function unrecognizedKeysOf(issue: { readonly code: string }): readonly string[] * key MEMBERSHIP is not judged by this bullet (an undeclared key is the * next one's, a tombstoned key nobody's here), nor a region slot, a * `predicate` ledger slot, a `value` ledger slot holding an envelope, a - * run-resolved key, or any value carrying a `{token}`: never refused for - * its pre-interpolation type. + * run-resolved key, a second issue at a key the contract refuses in a + * literal's own words, or a value or text slot whose value its own judge + * refuses. (#23260) A value carrying a `{token}` is judged like any + * literal: every builtin parses its config as authored, so the run's + * contract sees the token the build's sees (`get_record` `limit: '{x}'` + * is refused at `limit`). * - (#21982) A key the contract does not declare, on every builtin * {@link builtinNodeConfigKeysJudged} judges (every one, `try_catch` * included since #22343) → `node-config-refused-by-contract`, anchored at @@ -781,8 +833,17 @@ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowN if (!parsedAtRun && !keysJudged) return out; const result = contract.schema.safeParse(authored); if (result.success) return out; + const issues = result.error?.issues ?? []; + // [#23260] What the builtin value arm reads beyond one issue — see + // `builtinValueJudged`. The slot judges are asked once, and only when a + // present value reaches that question. + let slotJudgeRefused: ReadonlySet | undefined; + const scope: ValueArmScope = { + readAsWrittenKeys: new Set(issues.filter(isReadAsWrittenIssue).map((issue) => ledgerPathOf(issue.path))), + slotJudgeRefused: () => (slotJudgeRefused ??= slotJudgeRefusedPaths(nodeType, authored)), + }; const seen = new Set(); - for (const issue of result.error?.issues ?? []) { + for (const issue of issues) { if (keysJudged) { // An unknown key's issue sits on the object that holds it (the config // itself for a top-level key, so its `path` is empty): anchor one @@ -834,7 +895,7 @@ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowN const absent = absentAt(authored, issue.path); // [#21898] A present builtin value: refused where the build knows what the // run will parse — see `builtinValueJudged`. - if (!absent && !whole && !builtinValueJudged(nodeType, authored, issue)) continue; + if (!absent && !whole && !builtinValueJudged(nodeType, authored, issue, scope)) continue; const key = ledgerPathOf(issue.path); if (seen.has(key)) continue; seen.add(key); diff --git a/packages/spec/src/automation/flow-template-token.ts b/packages/spec/src/automation/flow-template-token.ts index 3d8aff187a9..ee9fe41a14c 100644 --- a/packages/spec/src/automation/flow-template-token.ts +++ b/packages/spec/src/automation/flow-template-token.ts @@ -35,12 +35,16 @@ * [#19939] The `params` key a contract issue carries when it refuses a `{…}` * token — or an envelope — at a position the executor reads AS WRITTEN (a * `notify` node's `sourceObject`, `actorId` and `payload`; an `http` node's - * `method`, `durable` and `timeoutMs`, and its `headers` map as a whole), - * never through an interpolator. The build doors' value arm (`flow-node-config-refusals.ts`, - * `builtinValueJudged`) leaves every other token-carrying value alone, since - * only the interpolation decides what the run will see there; at a position - * read as written the run sees exactly what the build sees, so the - * contract's refusal is the run's verdict and the build reports it. + * `method`, `durable` and `timeoutMs`, and its `headers` map as a whole) — a + * literal's own refusal of what it holds, naming the value to write. + * + * (#23260) Every builtin executor parses its config as authored, so the run + * sees what the build sees at every key, and the build doors' value arm + * (`flow-node-config-refusals.ts`, `builtinValueJudged`) judges a + * token-carrying value like any literal. What the marker tells it is which + * issue speaks for the key: the marked one is reported, and the contract's + * type issue at the same key (`timeoutMs: '{t}'` is also not a number) is + * not, so the literal is refused once, in its own words. */ export const READ_AS_WRITTEN_PARAM = 'readAsWritten'; From 111a82cb4c0b3043c9d081d9b024258f48eec5e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:17:12 +0000 Subject: [PATCH 2/3] wip(spec): pins, rationale and changeset for the retired token carve-out (#23260) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H --- .../23260-builtin-token-value-judged.md | 42 +++++ .../flow-builtin-node-config-values.test.ts | 163 +++++++++++++++--- packages/spec/src/automation/flow.zod.ts | 8 +- ...flow-builtin-node-config-values-refused.ts | 42 +++-- .../spec/src/migrations/registry.ts.template | 10 +- 5 files changed, 211 insertions(+), 54 deletions(-) create mode 100644 .changeset/23260-builtin-token-value-judged.md diff --git a/.changeset/23260-builtin-token-value-judged.md b/.changeset/23260-builtin-token-value-judged.md new file mode 100644 index 00000000000..dcd2f326fb8 --- /dev/null +++ b/.changeset/23260-builtin-token-value-judged.md @@ -0,0 +1,42 @@ +--- +'@objectstack/spec': minor +--- + +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. + +Clause-②: no (narrowing) + + + +**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.*`). + +**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. + +**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.`. + +**What does not change.** + +- A token at a plain string key (`outputVariable`, `flowName`, `objectName`) parses and registers as before: the contract takes a string there. +- 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. +- 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. +- An `http` `signingSecret` is still never judged at the build doors: the credential channel may supply what the run signs with. + +## FROM → TO + +| you wrote | write instead | what changes | +|:--|:--|:--| +| `get_record` `limit: '{page.size}'` | `limit: 25` | refused at `config.limit` at every build door, where it registered and failed every run | +| `loop` `maxIterations: '{cap}'` | `maxIterations: 50` | the same, at `config.maxIterations` | +| `update_record` `multi: '{bulk}'` | `multi: true` | the same, at `config.multi` | +| a screen field `min: '{m}'` | `min: 1` | the same, at `config.fields.N.min` | +| `try_catch` `retry: { maxRetries: '{n}' }` | `retry: { maxRetries: 3 }` | the same, at `config.retry.maxRetries` | +| `notify` `severity: '{level}'` | `severity: 'warning'` | the same, at `config.severity` | + +**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. + +**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. + +### The kit + +- **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. +- **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. diff --git a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts index 63d47b61b1c..542bb7e2956 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts @@ -12,16 +12,16 @@ * `outputVariable: 42`, a screen field's `min: '1'` — used to pass * `FlowSchema`, `objectstack validate` and `objectstack compile`, register, and * then fail every run that reached the node. The pins below hold the refusal at - * every door that parses a flow, and the controls hold what stays accepted: + * every door that parses a flow — (#23260) a `{token}` value in a typed slot + * included, judged for its type like any literal now that every builtin parses + * its config as authored — and the controls hold what stays accepted: * - * - a valid node of every builtin type; - * - a `{token}` value in a typed slot, in each spelling an author writes — - * never refused for its pre-interpolation type (except at a key its - * executor reads as written, a notify or http literal — #19939); + * - a valid node of every builtin type, and a literal of the right type where + * a token is refused; * - the places the build cannot know what the run parses, or another judge * owns the finding (key membership, a region slot, a `predicate` / `value` - * ledger slot, `http`'s run-resolved `signingSecret`, a legacy `loop` with - * no `body`). + * ledger slot, a value or text slot whose own judge refuses its token, + * `http`'s run-resolved `signingSecret`, a legacy `loop` with no `body`). */ import { describe, expect, it } from 'vitest'; @@ -30,6 +30,8 @@ import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; import { flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals'; +import { flowNodeTextSlotSources, textSlotTemplateRefusal } from './flow-text-slot-template'; +import { flowNodeValueTemplateRefusals } from './flow-value-slot-template'; import { FlowSchema } from './flow.zod'; const ENTRY_ID = 'flow-builtin-node-config-values-refused'; @@ -207,6 +209,133 @@ describe('one refusal per judged builtin, at each typed key', () => { }); }); +/** + * Every finding the three judges `registerFlow` and `objectstack validate` call + * on one node's config raise — this arm, the value-slot judge and the + * text-slot judge — each tagged with its judge and located at its path. + */ +function judgedAtTheDoors(type: string, config: Config): string[] { + return [ + ...flowNodeConfigRefusals(type, config).map(({ path }) => `contract:${path}`), + ...flowNodeValueTemplateRefusals(type, config).map(({ path }) => `value-slot:${path}`), + ...flowNodeTextSlotSources(type, config) + .filter(({ source }) => textSlotTemplateRefusal(source) !== undefined) + .map(({ path }) => `text-slot:${path}`), + ]; +} + +describe('[#23260] a value carrying a `{token}` is judged for its type, like any literal', () => { + // Every builtin executor parses its config as authored since #19939 deleted + // the flow interpolator, so the run's contract sees the token the build's + // sees, and refuses it there at every run that reaches the node. + + it('the probe: get_record limit {x} is refused at nodes.1.config.limit, in the contract\'s words', () => { + const config = { ...VALID.get_record, limit: '{x}' }; + const issues = issuesOf(flowWith('get_record', config)); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'custom', path: 'nodes.1.config.limit' }]); + expect(issues[0]!.message).toBe(flowNodeConfigRefusals('get_record', config)[0]!.message); + expect(issues[0]!.message).toContain(contractSentence('get_record', config, 'limit')); + expect(contractSentence('get_record', config, 'limit')).toMatch(/expected number, received string/); + expect(flowNodeConfigRefusals('get_record', config).map(({ code, params, path }) => ({ code, params, path }))).toEqual([ + { code: 'node-config-refused-by-contract', params: { nodeType: 'get_record', key: 'limit' }, path: 'limit' }, + ]); + }); + + it('defineStack refuses the probe in its ADR-0112 envelope, at flows.0.nodes.1.config.limit', () => { + const stack = { + manifest: { id: 'com.example.tokens', name: 'tokens', version: '1.0.0', type: 'app', namespace: 'tok' }, + objects: [{ name: 'tok_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }], + flows: [flowWith('get_record', { objectName: 'tok_account', limit: '{x}' }, 'tok_probe')], + }; + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stack as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the flow').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'flows.0.nodes.1.config.limit', code: 'custom' }, + ]); + }); + + it('CONTROL: a literal of the right type is unchanged', () => { + const config = { ...VALID.get_record, limit: 25 }; + expect(flowNodeConfigRefusals('get_record', config)).toEqual([]); + expect(issuesOf(flowWith('get_record', config))).toEqual([]); + }); + + it('in every spelling an author writes, at each typed key', () => { + const TOKENS: ReadonlyArray = [ + ['get_record', { limit: '{page.size}' }, 'limit'], + ['get_record', { limit: '{{ page.size }}' }, 'limit'], + ['screen', { fields: [{ name: 'qty', min: '${minimum}' }] }, 'fields[0].min'], + ['update_record', { multi: '{bulk}' }, 'multi'], + ['notify', { severity: '{level}' }, 'severity'], + ['loop', { maxIterations: '{cap}' }, 'maxIterations'], + ['try_catch', { retry: { maxRetries: '{tries}' } }, 'retry.maxRetries'], + ]; + for (const [type, edit, key] of TOKENS) { + const config = { ...VALID[type], ...edit }; + expect(flowNodeConfigRefusals(type, config).map(({ code, path }) => ({ code, path })), JSON.stringify(edit)) + .toEqual([{ code: 'node-config-refused-by-contract', path: key }]); + expect(issuesOf(flowWith(type, config)), JSON.stringify(edit)).toHaveLength(1); + } + }); + + it('one token at a value slot is reported once — by the value-slot judge, never beside it by this arm', () => { + // The contracts compose the value-slot judge, so each of these configs is + // refused by its contract at the slot (a collection's through its union's + // message); the arm exempts that refusal and the judge alone reports it. + const CASES: ReadonlyArray = [ + ['create_record', { ...VALID.create_record, fields: { title: '{record.name}' } }, 'fields.title'], + ['screen', { objectName: 'account', mode: 'edit', recordId: '{record.id}' }, 'recordId'], + ['screen', { fields: [{ name: 'qty', type: 'number', defaultValue: '{qty}' }] }, 'fields[0].defaultValue'], + ['loop', { ...VALID.loop, collection: '{rows}' }, 'collection'], + ['notify', { ...VALID.notify, recipients: '{owner}' }, 'recipients'], + ['http', { ...VALID.http, body: '{payload}' }, 'body'], + ]; + for (const [type, config, key] of CASES) { + expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, `${type} ${key}`).toBe(false); + expect(judgedAtTheDoors(type, config), `${type} ${key}`).toEqual([`value-slot:${key}`]); + } + }); + + it('one token at a text slot is reported once — by the text-slot judge, never beside it by this arm', () => { + const CASES: ReadonlyArray = [ + ['notify', { title: { dialect: 'template', source: 'Deal {deal.name} won' } }, 'title'], + ['notify', { title: { dialect: 'template', source: 'Hi {{ $User.Id }}' } }, 'title'], + ['notify', { actionUrl: '/crm_deal/{record.id}' }, 'actionUrl'], + ['http', { url: 'https://example.test/{id}' }, 'url'], + ['http', { headers: { 'X-Kind': '{kind}' } }, 'headers.X-Kind'], + ['screen', { description: 'Order {order.no}' }, 'description'], + ]; + for (const [type, edit, key] of CASES) { + const config = { ...VALID[type], ...edit }; + expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, `${type} ${key}`).toBe(false); + expect(judgedAtTheDoors(type, config), `${type} ${key}`).toEqual([`text-slot:${key}`]); + } + }); + + it('a literal the executor reads as written is refused once, in the literal\'s words, never its type\'s', () => { + // [#19939] The contract refuses `timeoutMs: '{t}'` twice — its type, and the + // literal's own refusal marked READ_AS_WRITTEN_PARAM; the key is refused once. + const CASES: ReadonlyArray = [ + [{ timeoutMs: '{t}' }, 'timeoutMs', '`timeoutMs` is the per-request timeout in milliseconds, a number, a literal the executor reads as written'], + [{ headers: '{h}' }, 'headers', '`headers` is a map of header name to text, read as written'], + ]; + for (const [edit, key, lead] of CASES) { + const config = { ...VALID.http, ...edit }; + const refusals = flowNodeConfigRefusals('http', config); + expect(refusals.map(({ path }) => path), key).toEqual([key]); + expect(refusals[0]!.message, key).toContain(lead); + expect(refusals[0]!.message, key).not.toMatch(/expected (number|record), received string/); + expect(judgedAtTheDoors('http', config), key).toEqual([`contract:${key}`]); + } + }); +}); + describe('what stays accepted (lit controls)', () => { it('CONTROL: a valid node of every builtin type parses, and the judge says nothing', () => { expect(Object.keys(VALID).sort()).toEqual([...getBuiltinNodeConfigContracts().keys()].sort()); @@ -217,26 +346,6 @@ describe('what stays accepted (lit controls)', () => { } }); - it('CONTROL: a token value in a typed slot is never refused for its pre-interpolation type', () => { - // [#19939] Not `http`: its executor interpolates nothing since then, so a - // token at its `timeoutMs`, `durable` or whole `headers` is read as written - // and refused at every door (`io-node-config.test.ts`, the http literals). - const TOKENS: ReadonlyArray = [ - ['get_record', { limit: '{page.size}' }], - ['screen', { fields: [{ name: 'qty', min: '${minimum}', max: '{maximum}' }] }], - ['update_record', { multi: '{bulk}' }], - ['loop', { maxIterations: '{cap}' }], - ['try_catch', { retry: { maxRetries: '{tries}' } }], - ]; - for (const [type, edit] of TOKENS) { - const config = { ...VALID[type], ...edit }; - // The contract itself refuses the string — only the judge holds back. - expect(getBuiltinNodeConfigContracts().get(type)!.schema.safeParse(config).success, JSON.stringify(edit)).toBe(false); - expect(flowNodeConfigRefusals(type, config), JSON.stringify(edit)).toEqual([]); - expect(issuesOf(flowWith(type, config)), JSON.stringify(edit)).toEqual([]); - } - }); - it('CONTROL: a token-free sibling does not shield a refused value (http judges each slot on its own)', () => { const config = { ...VALID.http, url: 'https://example.test/{path}', timeoutMs: '5000' }; expect(flowNodeConfigRefusals('http', config).map(({ path }) => path)).toEqual(['timeoutMs']); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 41a55ac0d61..4dee4a1dcc8 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1499,9 +1499,11 @@ export const FlowSchema = lazySchema(() => strictObject( // `collection`, `map` with no `collection`, a CRUD node with no // `objectName`, …). For a builtin (#21898) a present VALUE its contract // refuses is refused too (`create_record` `outputVariable: 42`, a screen - // field `min: '1'`), where the build can know what the run parses — never - // a value carrying a `{token}`, a region slot, a ledger predicate or - // value slot, or `http`'s run-resolved `signingSecret`. A KEY its + // field `min: '1'`), where the build can know what the run parses — a + // value carrying a `{token}` included since #23260 (`get_record` + // `limit: '{x}'`), never a region slot, a ledger predicate or value slot, + // a value or text slot whose own judge refuses its token, or `http`'s + // run-resolved `signingSecret`. A KEY its // contract does not declare is refused too (#21982), for every builtin // `builtinNodeConfigKeysJudged` names (every one, `try_catch` included // since #22343), a body-less legacy `loop` included. The one plugin node contract the spec declares, `approval` diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-builtin-node-config-values-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-builtin-node-config-values-refused.ts index 2d5c96a8a25..6f29de9f8f6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-builtin-node-config-values-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-builtin-node-config-values-refused.ts @@ -22,10 +22,12 @@ export const entry: SemanticMigration = { + 'written as the string 1, get_record limit as a string, update_record multi as a string), a value ' + 'outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, ' + 'try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule ' - + 'finding on present keys (a notify template beside an inline title). Never a value carrying a ' - + 'token in braces — except at a key its executor reads as written (a notify sourceObject, actorId or ' - + 'payload; an http method, durable, timeoutMs or whole headers map), where the contract refuses the ' - + 'token itself — an undeclared or retired key, a region slot, or an http signingSecret. ' + + 'finding on present keys (a notify template beside an inline title). A value carrying a token in ' + + 'braces is judged like any other (get_record limit written as a token, refused as the wrong type), ' + + 'and at a key its executor reads as written (a notify sourceObject, actorId or payload; an http ' + + 'method, durable, timeoutMs or whole headers map) the refusal is the literal\'s own. Never a value or ' + + 'text slot whose own judge refuses its token, an undeclared or retired key, a region slot, or an ' + + 'http signingSecret. ' + 'Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an ' + 'exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio ' + 'flow designer, and a flow row already sitting in sys_metadata', @@ -36,10 +38,10 @@ export const entry: SemanticMigration = { + '`durable: true`), one of the declared values (`severity: \'warning\'`, `mode: \'edit\'`), or a value ' + 'inside the declared range. A number or boolean slot takes a LITERAL only: every builtin executor ' + 'parses the config as authored, so a `{token}` template there (`limit: \'{page.size}\'`, ' - + '`maxIterations: \'{cap}\'`) passes the build doors and still fails every run — and at an `http` ' - + 'node\'s `durable` and `timeoutMs`, which `http` once interpolated before it parsed, so a sole-token ' - + 'template there resolved to the declared type, the token is now refused at the build doors as well, ' - + 'with the literal\'s remedy (`timeoutMs: 5000`, `durable: true`). For a rule ' + + '`maxIterations: \'{cap}\'`) is refused at the build doors as the wrong type, as every run refused ' + + 'it — and at an `http` node\'s `durable` and `timeoutMs`, which `http` once interpolated before it ' + + 'parsed, so a sole-token template there resolved to the declared type, with the literal\'s remedy ' + + '(`timeoutMs: 5000`, `durable: true`). For a rule ' + 'finding, follow the rule\'s own sentence (keep `template` or the inline `title` / `message`, not both)', reason: 'Every builtin executor (`service-automation` `builtin/`) parses its node\'s `config` against the ' @@ -52,13 +54,14 @@ export const entry: SemanticMigration = { + '`AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share ' + '(`flowNodeConfigRefusals`) now refuses such a value as `node-config-refused-by-contract`, anchored ' + 'at the key, in the contract\'s own words — the code the approval contract already uses. It judges ' - + 'only what the build can know the run will parse, and holds one more class back by ruling: a value ' - + 'carrying a `{token}` is never refused at the build doors for its pre-interpolation type — which is ' - + 'no promise it runs, since every builtin parses its config as authored and so still refuses a token ' - + 'in a number or boolean slot at its first run — save at a key its executor reads as written (a ' - + 'notify node\'s `sourceObject`, `actorId` and `payload`, an `http` node\'s `method`, `durable`, ' - + '`timeoutMs` and whole `headers` map), where the contract refuses the token itself and the build ' - + 'doors report that refusal. `http` once parsed after interpolating its whole config, so only ' + + 'only what the build can know the run will parse — a value carrying a `{token}` included: every ' + + 'builtin parses its config as authored, so the run\'s contract sees the token the build\'s sees, and ' + + 'a token in a number or boolean slot is refused as the wrong type (`get_record` `limit: \'{x}\'`). At ' + + 'a key its executor reads as written (a notify node\'s `sourceObject`, `actorId` and `payload`, an ' + + '`http` node\'s `method`, `durable`, `timeoutMs` and whole `headers` map) the contract refuses the ' + + 'token itself, and the build doors report that refusal, once, in the literal\'s words. A value or ' + + 'text slot whose own judge refuses its token is that judge\'s, which `registerFlow` and `objectstack ' + + 'validate` call. `http` once parsed after interpolating its whole config, so only ' + 'token-free values were judged there; its executor interpolates nothing now. Its `signingSecret` is ' + 'never judged, since the credential channel may supply it; a ' + '`loop` with no `body` is not parsed by its executor and is judged for nothing; the region slots of ' @@ -80,8 +83,9 @@ export const entry: SemanticMigration = { + 'stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and confirm ' + 'each flow REGISTERS: no `failed to register flow` warn for it — that warn line is the locator for ' + 'a row that exists only in `sys_metadata`. A node whose values its contract accepts parses and ' - + 'registers byte-identically to before. A `{token}` template parses and registers as before too — ' - + 'except at a key read as written (a notify or http literal, above), where it is refused with the ' - + 'literal\'s remedy — and runs only where the slot takes a string; in a number or boolean slot it fails ' - + 'at its first run exactly as it did, so write a literal there.', + + 'registers byte-identically to before. A `{token}` template at a plain string key (an ' + + '`outputVariable`, a `flowName`) parses and registers as before; in a number or boolean slot, or ' + + 'one with a declared set, it is refused at the key like any wrong-typed literal — at a key read as ' + + 'written (a notify or http literal, above), with the literal\'s remedy — where it used to register ' + + 'and fail at its first run, so write a literal there.', }; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index c3eb158a13b..5f195ccac38 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1928,11 +1928,11 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`node-config-refused-by-contract` code. Every builtin executor parses its config against that ' + 'contract before it acts, so a `create_record` `outputVariable: 42` or a screen field `min: \'1\'` ' + 'used to pass `objectstack validate` and `objectstack compile`, register, and fail every run that ' - + 'reached the node. The arm judges only what the build can know the run will parse: never a value ' - + 'carrying a `{token}`, whatever its slot\'s type (held back by ruling, not admitted: such a token in ' - + 'a number or boolean slot still fails at its first run, so those slots take a literal), save at a ' - + 'key its executor reads as written — a `notify` or `http` literal — whose contract refuses the token ' - + 'itself; on `http`, never the credential-held `signingSecret`; on a `loop`, only one with a ' + + 'reached the node. The arm judges only what the build can know the run will parse — a value ' + + 'carrying a `{token}` included, since every builtin parses its config as authored: a token in a number ' + + 'or boolean slot is refused as the wrong type (`limit: \'{x}\'`), and at a key its executor reads as ' + + 'written — a `notify` or `http` literal — in the literal\'s own words; never a value or text slot ' + + 'whose own judge refuses its token; on `http`, never the credential-held `signingSecret`; on a `loop`, only one with a ' + '`body`; on the region containers, never the region slots. Key membership is untouched. No key ' + 'is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the ' + 'value the author meant. Its D3 record is the semantic entry ' From b89e80b2e19fd7ccd429646bf852c6dc4c8b4258 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:31:30 +0000 Subject: [PATCH 3/3] test(service-automation): a token in a number slot is refused at registration (#23260) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H --- .../src/builtin/config-parse.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/services/service-automation/src/builtin/config-parse.test.ts b/packages/services/service-automation/src/builtin/config-parse.test.ts index 33d4c3c4869..232802ad7d0 100644 --- a/packages/services/service-automation/src/builtin/config-parse.test.ts +++ b/packages/services/service-automation/src/builtin/config-parse.test.ts @@ -210,6 +210,20 @@ describe('execute-time config parse (#4277)', () => { expect(enqueued[0]?.timeoutMs).toBe(1500); }); + it('[#23260] a token in a number slot is refused at registration, at config.limit, and at the run', async () => { + // Until #23260 the build doors never judged a value carrying a `{token}` + // for its type, so this node registered — and every builtin parses its + // config as authored, so every run that reached it was refused here. + const door = doorRefusal('get_record', { objectName: 'account', limit: '{x}' }); + expect(door).toContain('refused at `limit` by the get_record contract'); + expect(door).toContain('expected number, received string'); + const result = await runPatched(engineWith(), 'get_record', { objectName: 'account', limit: 10 }, { limit: '{x}' }); + expect(result.success).toBe(false); + expect(result.error).toContain('config.limit'); + // CONTROL: the literal of the right type registers. + expect(doorRefusal('get_record', { objectName: 'account', limit: 10 })).toBe(''); + }); + it('http still refuses a statically wrong-typed slot', async () => { expect(doorRefusal('http', { url: 'https://example.test', timeoutMs: 'soon' })).toContain('refused at `timeoutMs`'); const engine = engineWith();