Skip to content

Commit 3cc45cc

Browse files
fix(spec,service-automation,lint)!: registration and validate judge a connector_action's connectorConfig.input value slot (#28150)
domain:spec#1 · os-tesla · session_01L9qibwb6hBqszPTYJobitm <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao8zjUCX5w2bY9jeptPaF4yU)_ Fixes #27968 ## 维护者速读 - 改了什么:流程里"调用连接器"节点的输入值,现在在注册流程和 `objectstack validate` 时就会被检查,和子流程的输入用同一条规则。 - 为什么改:之前写错的计算表达式、旧的 `{…}` 模板写法能顺利保存和上线,要等流程真正跑到这一步才失败,或者把 `{record.title}` 这样的原文直接发给外部系统。 - 风险与回滚:已经存在、带这三种写法的流程会被拒绝注册(启动时打一条 warn 并跳过该流程,其他流程照常);仓库内只有示例应用的一处 Slack 文案命中,已改写。回滚只需 revert 本 PR,不涉及数据迁移。 - 先看:`packages/spec/src/automation/flow-node-expression-paths.ts` 新增的 `connector_action` 条目和"兄弟区块"根,以及两个新测试文件。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/builtin/connector-input-registration.test.ts` 和 `pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.connector-input.test.ts`,三种写法都被拒绝,合法表达式照常注册并运行。 - 需要你做的:无。席内契约复审 PASS(评论 6525595405),由席位落地;skills 下的过时说明已立卡 #28162 交 skills 车道。 ## What changes - **The ledger gains a sibling-block root** (`packages/spec/src/automation/flow-node-expression-paths.ts`). A `FlowNodeExpressionPath` may now name `block`, the node property its `path` is rooted in. It defaults to `config`, so every existing entry keeps its meaning. `FLOW_NODE_SIBLING_EXPRESSION_BLOCKS` lists the addressable blocks (today only `connectorConfig` → `connector_action`). `resolveFlowNodeExpressions` and `resolveFlowNodeValueSlots` take the node's sibling blocks as an optional third argument, and each result carries its `block`. The new entry is `connector_action` · `connectorConfig` · `input.*` · `value`. - **One judge, no second list.** `flowNodeValueTemplateRefusals` (in `flow-value-slot-template.ts`) reads the same entry and reports `block`. A sibling-block slot takes the generic value-slot remedy, without the callee sentence a child flow's input adds. `flow-node-config-refusals.ts` skips block-rooted entries, because its walk addresses only `config`. - **Both doors pass the node.** In `service-automation`, `flow-registration-checks.ts` locates a refusal at `connectorConfig.input.<key>`. In `lint`, `validate-expressions.ts` does the same, so `objectstack validate` and the runtime authoring gate judge the map too. No service-automation-only judge was added (triage 6487582764). - **Declared in the schema.** `FlowNodeSchema.connectorConfig.input` values carry `.meta({ xExpression: 'value' })` and stay `z.unknown()`, so `FlowSchema.parse` is unchanged. The describe text now says what a value is. - **The ratchet reads a third channel.** `getFlowNodeSiblingBlockJsonSchemas()` hands the sibling-block JSON Schema to `config-expression-ledger.test.ts`. A marker on a block the ledger cannot address fails the ratchet. - **Declared rider.** The stale doc comment in the `FlowSchema` example (formerly `flow.zod.ts:950–951`, "handed to the action as written: no template is read there") now reads "Each `connectorConfig.input` value is a literal, handed to the action as written, or a CEL value envelope evaluated in the run's scope before dispatch". - **Other changes.** - The `engine.ts` `evaluateValueEnvelope` docblock is updated (comment only). - The ADR-0087 D3 entry is `18.connector-action-input-value-slot-judged`. - There are three changesets, one per package. - The regenerated artefacts are `api-surface/automation.json`, `export-origins/automation.json` and `content/docs/references/automation/flow.mdx`. **File-surface amendments (producer side, named per the order):** - `examples/app-showcase/src/automation/flows/index.ts`: `TaskCompletedSlackFlow`'s `text: '✅ Task done: {record.title}'` is now `{ dialect: 'cel', source: "'✅ Task done: ' + record.title" }`. The new judge refuses the old spelling. It never interpolated: Slack received the braces as written. - `packages/metadata-protocol/src/runtime-authoring-gate.flow-credential-literal.test.ts` and `packages/cli/test/flow-credential-literal-doors.e2e.test.ts`: the DARK variant's connector input `'{api_token}'` is now a CEL envelope, `{ dialect: 'cel', source: "{'clientSecret': api_token}" }`. Its intent is unchanged: a non-literal credential the advisory does not flag. ## Why The executor half (#27980) evaluates every top-level CEL envelope in `connectorConfig.input`. The build doors judge value slots only through the ledger, and the ledger could address nothing outside `node.config`. Under `connectorConfig.input`, `registerFlow` and `objectstack validate` accepted three shapes that they refuse under a `subflow`'s `config.input`: a malformed envelope, a template envelope and a `{…}` token. That gave one slot two reject sets, and a malformed envelope was first met by a run. Triage 6516097905 ruled that the ledger gains the sibling block and its consumers read it with no second list. Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: follow-up #28162 ## Verification Final head `6a06a420` (merged with `origin/main` `91a423ef`). The suites and the gate sweep ran on `0a88f925` (`origin/main` `6b125a7a`). After the last merge, the closure was rebuilt and the new test files, the ledger ratchet, `residual-top-level-flow-boot.test.ts` (from #28048), `check:generated` and full dogfood were re-run. - **Rejection tests.** - `service-automation` `connector-input-registration.test.ts` has 6 tests. Its three cells are a malformed CEL envelope (`{ dialect: 'cel', source: 'rec.' }` → `VALUE_ENVELOPE_REFUSAL`), a template envelope (→ `VALUE_ENVELOPE_REFUSAL`) and `'{rec.id}'` (→ `VALUE_SLOT_TEMPLATE_REFUSAL`). Each asserts `node 'call' (connector_action) connector_action input value at connectorConfig.input.customerId: <refusal>`, checks the literal key beside it is not named, and compares against a `subflow` `config.input` control that gives the same refusal. The file also shows that a refused flow cannot run (the handler is never called), that a valid envelope plus literals registers and the run evaluates it, and that a node with no `input` registers. - `lint` `validate-expressions.connector-input.test.ts` has 5 tests: the same three shapes at `connectorConfig.input.<key>`, a valid envelope passing, and the runtime authoring gate refusing with rule `expression-invalid`. - **Ablation** (`scripts/ablation-replace.mjs`, each restored to HEAD): every one of the four consumer call sites going back to its two-argument form turns red. - registration `resolveFlowNodeExpressions` → 2 envelope cells red; - registration `flowNodeValueTemplateRefusals` → token cell and not-registered test red; - lint resolve → 2 envelope cells red; - lint template → token cell and runtime-gate test red. - **Suites.** - `service-automation`: 226 files / 2865 tests green. - `lint`: 179 files / 7649 tests green. - `spec`: full suite 756 files / 22516 tests green. - `metadata-protocol` credential-literal gate: 3/3. - After the merge: `connector-input-schema.test.ts` (from #27995), `connector-openapi` `input-schema-dispatch.test.ts`, the ledger ratchet and both new files are green. - `tsc --noEmit` is clean for spec, service-automation and lint. - **Full dogfood** (owed by the narrowing arm, under the verify lock, closure built): 358 files / 2883 tests green, 20 skipped, on `6a06a420`. An earlier run on `0a88f925` had 1 red (`api-action-door-forward`, the main defect #28082 fixed by #28095), which is why main was merged again. - **Gates.** `dispatch-gates --commands` derived 112 commands, each run `</dev/null` with its exit recorded. `--ran` reconcile: 112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN. Three gates first exited 3 (prerequisite): `check-plugin-teardown-shape --self-test` (shallow clone; the pinned fixture commit was fetched), `check:skill-examples` and `check:dual-build-cjs-loads` (unbuilt packages). All three exit 0 after the prerequisite. `check-platform-checklist --changed-from origin/main`: OK. eslint over the changed files (`--no-inline-config`): clean. `check:generated`: all 14 up to date. - `packages/cli/test/flow-credential-literal-doors.e2e.test.ts` is integration tier and is declared to CI; it was not run locally. ## Acceptance notes - **In-tree survey of `connectorConfig.input`.** There are 6 authored connector inputs outside tests: 4 in `examples/app-showcase` flows, 1 in `ui/apps/console/src/preview-samples.ts:248` and 1 in `content/docs/automation/connectors.mdx`. The new judge refuses exactly 1: the showcase `post_to_slack` text, rewritten above. Two test fixtures (the DARK credential variants) were refused and rewritten. The conversion fixture `packages/spec/src/conversions/registry.ts:1381/1425` (`flow-node-connector-config-lift`, `retiredFromLoadPath`) still holds `'Task done: {record.title}'`. It is a before/after transform fixture that is never registered, so it is left as it is. - **Not narrowed.** `FlowSchema.parse` is unchanged: the map stays `z.unknown()`, as every value slot in an open `config` does. `input` stays optional. - **Existing rows.** A stored flow carrying one of the three shapes is skipped at boot with the existing `failed to register flow` warn, and the flows beside it still register. ADR-0087 D3 entry `connector-action-input-value-slot-judged` carries the prescription. No D2 conversion exists, because no token has a lossless CEL spelling and a malformed envelope carries no source to correct. - **Noted, not changed.** - `conversions/refused-shapes.ts` detector `flow-value-slot-template-dialect-refused` still walks `config` only. Its "listed exactly when the door refuses" property no longer covers a connector token. A follow-up can extend the detector, which reads the same ledger. - `ui` `flow-value-envelope.ts` `isValueEnvelopeSlot` ignores `block`. Its one false-positive path, `config.input` on `connector_action`, is refused by the parse already. `ui/` is untouched. - `lint` also applies its unbound-root check to connector input envelopes, as it does for every value slot. - **Contract review** is owed (narrowing) and is run by the triage seat. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint over the changed files passes (the whole-tree `pnpm lint` runs in CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibzGExkj8nxREmV7FjX --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f13ee8 commit 3cc45cc

22 files changed

Lines changed: 782 additions & 61 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: the flow-node expression ledger roots a path in a node's sibling block, and declares a `connector_action` node's `connectorConfig.input` a value slot
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered connector-action-input-value-slot-judged -->
11+
12+
**BREAKING** accept-set narrowing of `connectorConfig.input` on `connector_action` flow nodes, served at `AutomationEngine.registerFlow` and `objectstack validate` (see the `@objectstack/service-automation` and `@objectstack/lint` entries). This package carries the ledger both doors read.
13+
14+
- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` could address a node's `config` only, so the one value map the executor reads from a sibling block — `connectorConfig.input`, evaluated before dispatch since the executor half landed — was declared nowhere and judged by no door. An entry may now name `block: 'connectorConfig'` (absent means `config`); `FLOW_NODE_SIBLING_EXPRESSION_BLOCKS` lists the blocks a path can be rooted in with the node type that reads each, and `FlowNodeExpressionBlock` / `FlowNodeSiblingExpressionBlock` / `FlowNodeSiblingBlocks` type them. The new entry is `connector_action` · `connectorConfig` · `input.*` · `value`.
15+
- **The resolvers.** `resolveFlowNodeExpressions`, `resolveFlowNodeValueSlots` and `flowNodeValueTemplateRefusals` take an optional third argument, the node's sibling blocks (pass the node itself), and each result names the `block` its `path` is inside. A caller that passes none is answered for `config` only, exactly as before; every `config`-rooted result reads `block: 'config'`.
16+
- **The declaration.** `FlowNodeSchema.connectorConfig.input`'s map value carries `.meta({ xExpression: 'value' })`, and `getFlowNodeSiblingBlockJsonSchemas()` hands the node's blocks other than `config` to the reconciliation ratchet as its third channel. The value stays `z.unknown()`: `FlowSchema.parse` does not judge the map, as it judges no value slot of a node's open `config`.
17+
- **Unchanged.** Every `config`-rooted entry resolves byte-identically. The `FlowSchema` example comment no longer says `connectorConfig.input` is handed to the action as written.
18+
19+
**The remedy.** Write a computed input value as a CEL value envelope, `{ dialect: 'cel', source: 'record.id' }`, and a constant as the literal it is; a string that carried a `{…}` token takes the CEL spelling its refusal names (`'{rec.id}'` → `{ dialect: 'cel', source: 'rec.id' }`).
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: `registerFlow` refuses a malformed envelope and a `{…}` token in a `connector_action` node's `connectorConfig.input`
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered connector-action-input-value-slot-judged -->
11+
12+
**BREAKING** accept-set narrowing at `AutomationEngine.registerFlow`.
13+
14+
- **Before.** The executor evaluates `connectorConfig.input` as a value-slot map, but registration judged value slots through a ledger that could address `config` only. A malformed CEL envelope (`{ dialect: 'cel', source: 'rec.' }`), a template envelope (`{ dialect: 'template', … }`) and a `{…}` token string (`'{rec.id}'`) all registered there, while the same three under a `subflow`'s `config.input` were refused. The malformed envelope failed the step at run time; the token string was handed to the action as its literal text.
15+
- **Now.** The value-envelope and value-slot template checks read the ledger's `connectorConfig` entry, so all three are refused at registration, each located at `connectorConfig.input.<key>` with the same refusal a `subflow` input value draws. A flow row in `sys_metadata` carrying one is skipped at boot with a `warn` naming it, its trigger not armed.
16+
- **Unchanged.** Literals and valid CEL envelopes register and dispatch as before; an envelope nested inside a literal is data; `input` stays optional.
17+
18+
**The remedy.** Write a computed value as `{ dialect: 'cel', source: '…' }` and a constant as a literal; follow the CEL spelling each refusal names.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: `objectstack validate` judges a `connector_action` node's `connectorConfig.input` as a value slot
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered connector-action-input-value-slot-judged -->
11+
12+
**BREAKING** accept-set narrowing at `validateStackExpressions` (`objectstack validate`), and so at the runtime authoring gate that runs the same pass on a flow write.
13+
14+
- **Before.** A malformed CEL envelope, a template envelope and a `{…}` token string in `connectorConfig.input` passed clean; the same three under a `subflow`'s `config.input` were `error`s.
15+
- **Now.** Each is an `error` at `node 'ID' (connector_action) connector_action input value at connectorConfig.input.KEY`, with the message a `subflow` input value draws. A valid envelope is also held to the value slots' other checks, the unbound-root judgment included, as every value slot is.
16+
- **Unchanged.** Literals and valid CEL envelopes pass.
17+
18+
**The remedy.** Write a computed value as `{ dialect: 'cel', source: '…' }` and a constant as a literal; follow the CEL spelling each refusal names.

‎content/docs/references/automation/flow.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ const result = FlowSchema.parse(data);
150150
| :--- | :--- | :--- | :--- |
151151
| **connectorId** | `string` | ✅ | Registered connector name |
152152
| **actionId** | `string` | ✅ | Action key declared by the connector |
153-
| **input** | `Record<string, any>` | optional | Mapped inputs for the action. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: the connector authenticates through its own `auth.credentialRef`. |
153+
| **input** | `Record<string, any>` | optional | Mapped inputs for the action. Each value is a literal, handed to the action as written, or a CEL value envelope `{ dialect: 'cel', source }` evaluated in the run's scope before dispatch; a `{…}` template token in a string is refused when the flow is registered or validated. The flow definition, this map included, is served to every member who can read flows, so never put a credential here: the connector authenticates through its own `auth.credentialRef`. |
154154

155155
### Nested Shape: `FlowNode.inputSchema[string]`
156156

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,10 @@ export const TaskCompletedSlackFlow = defineFlow({
349349
actionId: 'chat.postMessage',
350350
input: {
351351
channel: 'C0WINS000',
352-
text: '✅ Task done: {record.title}',
352+
// Each input value is a literal or a CEL value envelope evaluated
353+
// before dispatch (#27968); a `{…}` token in a string is refused.
354+
// `title` is a required field, so it is read unguarded.
355+
text: { dialect: 'cel', source: "'✅ Task done: ' + record.title" },
353356
},
354357
},
355358
},

‎packages/cli/test/flow-credential-literal-doors.e2e.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
* query parameter, a nested connector input); DARK is the same flow with
1616
* templates in those three positions — a `{{ var }}` hole at the http url and
1717
* header (text slots, where a single-brace token is refused since #19939), a
18-
* `{var}` token at the connector input — and must stay silent. Every
18+
* CEL value envelope computing the connector input's `auth` map (a value slot
19+
* since #27968, where a `{…}` token is refused) — and must stay silent. Every
1920
* value is a probe sentinel, not a credential.
2021
*
2122
* Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`).
@@ -85,7 +86,11 @@ const stackOf = (literal: boolean) => ({
8586
connectorConfig: {
8687
connectorId: 'rest',
8788
actionId: 'request',
88-
input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } },
89+
input: {
90+
method: 'GET',
91+
path: '/p',
92+
auth: literal ? { clientSecret: SENTINELS[2] } : { dialect: 'cel', source: "{'clientSecret': api_token}" },
93+
},
8994
},
9095
},
9196
{ id: 'end', type: 'end', label: 'End' },
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* **`objectstack validate` judges a `connector_action` node's
5+
* `connectorConfig.input`** (#27968) — the build door's half of the slot
6+
* `registerFlow` now judges too.
7+
*
8+
* The expression pass reads value slots only through the spec's expression
9+
* ledger, whose paths were rooted in `node.config`; `connectorConfig` is a
10+
* sibling block, so a malformed CEL envelope, a template-dialect envelope and a
11+
* `{…}` token string there passed `validateStackExpressions` clean while the
12+
* same three under a `subflow`'s `config.input` were refused. The ledger now
13+
* roots the slot in the block, and this pass judges it like every other value
14+
* slot, located at `connectorConfig.input.<key>`, at `error`. The runtime
15+
* authoring gate runs the same pass, so a flow write is refused there as well.
16+
*/
17+
18+
import { describe, expect, it } from 'vitest';
19+
import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation';
20+
21+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
22+
import { validateStackExpressions, type ExprIssue } from './validate-expressions.js';
23+
24+
type AnyRec = Record<string, unknown>;
25+
26+
/** start → `node` → end, the record an input variable. */
27+
function flowWith(node: AnyRec): AnyRec {
28+
return {
29+
name: 'f',
30+
label: 'F',
31+
type: 'autolaunched',
32+
variables: [{ name: 'rec', type: 'object', isInput: true }],
33+
nodes: [
34+
{ id: 'start', type: 'start', config: {} },
35+
{ id: 'call', label: 'Call', ...node },
36+
{ id: 'done', type: 'end', config: {} },
37+
],
38+
edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'done' }],
39+
};
40+
}
41+
42+
const connectorNode = (input: AnyRec): AnyRec => ({
43+
type: 'connector_action',
44+
connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input },
45+
});
46+
const subflowNode = (input: AnyRec): AnyRec => ({ type: 'subflow', config: { flowName: 'child', input } });
47+
48+
const errorsOf = (flow: AnyRec): ExprIssue[] =>
49+
validateStackExpressions({ flows: [flow] }).filter((i) => (i.severity ?? 'error') === 'error');
50+
51+
/** The three shapes the #27327 thread measured passing the build door under `connectorConfig.input`. */
52+
const SHAPES = [
53+
['a malformed CEL envelope', { dialect: 'cel', source: 'rec.' }, VALUE_ENVELOPE_REFUSAL],
54+
['a template-dialect envelope', { dialect: 'template', source: '{rec.id}' }, VALUE_ENVELOPE_REFUSAL],
55+
['a bare `{…}` token string', '{rec.id}', VALUE_SLOT_TEMPLATE_REFUSAL],
56+
] as const;
57+
58+
describe('objectstack validate refuses the three shapes at `connectorConfig.input.<key>`', () => {
59+
it.each(SHAPES)('%s', (_shape, value, lead) => {
60+
const found = errorsOf(flowWith(connectorNode({ customerId: value, region: 'eu' })));
61+
expect(found.map((i) => i.where)).toEqual([
62+
"flow 'f' · node 'call' (connector_action) connector_action input value at connectorConfig.input.customerId",
63+
]);
64+
expect(found[0]!.message.startsWith(lead)).toBe(true);
65+
// The same shape under a `subflow`'s input map: the same refusal, at its own position.
66+
const control = errorsOf(flowWith(subflowNode({ customerId: value })));
67+
expect(control.map((i) => i.where)).toEqual([
68+
"flow 'f' · node 'call' (subflow) subflow input value at config.input.customerId",
69+
]);
70+
// The connector's refusal is the subflow's, minus the callee sentence a
71+
// child flow's input value adds: the action was handed the text, not nothing.
72+
expect(control[0]!.message.startsWith(found[0]!.message)).toBe(true);
73+
});
74+
75+
it('a valid envelope and literals pass', () => {
76+
expect(errorsOf(flowWith(connectorNode({
77+
customerId: { dialect: 'cel', source: 'rec.id' },
78+
region: 'eu',
79+
body: { filter: { dialect: 'cel', source: 'rec.id' } },
80+
})))).toEqual([]);
81+
expect(errorsOf(flowWith({ type: 'connector_action', connectorConfig: { connectorId: 'vendor', actionId: 'lookup' } }))).toEqual([]);
82+
});
83+
84+
it('the runtime authoring gate refuses a flow write carrying one, through the same pass', () => {
85+
const result = runRuntimeAuthoringRules({ type: 'flow', item: flowWith(connectorNode({ customerId: '{rec.id}' })) });
86+
const refused = result.errors.filter((f) => f.where.includes('connectorConfig.input.customerId'));
87+
expect(refused.map((f) => f.rule)).toEqual(['expression-invalid']);
88+
expect(refused[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true);
89+
});
90+
});

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2769,8 +2769,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
27692769
severity: 'error',
27702770
});
27712771
}
2772-
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
2773-
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
2772+
// [#27968] Handed the node, the ledger reaches a slot rooted in a
2773+
// sibling block too — a `connector_action` node's
2774+
// `connectorConfig.input` — and names the block it is in.
2775+
for (const found of resolveFlowNodeExpressions(nodeType, cfg, node)) {
2776+
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at ${found.block}.${found.path}`;
27742777
// [#15137] `value` slots are checkable too, by their own rule — see
27752778
// `checkDeclaredValue`. Not shadowing-checked: the shadow diagnostic is
27762779
// about a bare identifier resolving to a variable instead of a field,
@@ -2828,9 +2831,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
28282831
// (`flowNodeValueTemplateRefusals`), walking the ledger's `value` slots
28292832
// and the two legacy `assignment` shapes, so build and registration
28302833
// give one verdict, and each refusal names the token's CEL spelling.
2831-
for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg)) {
2834+
for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg, node)) {
28322835
issues.push({
2833-
where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at config.${templateRefusal.path}`,
2836+
where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at ${templateRefusal.block}.${templateRefusal.path}`,
28342837
message: templateRefusal.message,
28352838
source: templateRefusal.source,
28362839
severity: 'error',

‎packages/metadata-protocol/src/runtime-authoring-gate.flow-credential-literal.test.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ const SENTINELS = ['door-header-sentinel', 'door-url-sentinel', 'door-input-sent
2424
/**
2525
* One flow, three served positions. `literal: false` is the same flow with templates:
2626
* a `{{ var }}` hole at the http `url` and header — text slots, where a single-brace
27-
* token is refused at this door since #19939 — and a `{var}` token at the connector input.
27+
* token is refused at this door since #19939 — and, at the connector input, a CEL value
28+
* envelope computing the `auth` map: the input is a value slot since #27968, where a
29+
* `{…}` token is refused.
2830
*/
2931
const credentialFlow = (literal: boolean) => ({
3032
name: literal ? 'door_cred_lit' : 'door_cred_dark',
@@ -55,7 +57,11 @@ const credentialFlow = (literal: boolean) => ({
5557
connectorConfig: {
5658
connectorId: 'rest',
5759
actionId: 'request',
58-
input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } },
60+
input: {
61+
method: 'GET',
62+
path: '/p',
63+
auth: literal ? { clientSecret: SENTINELS[2] } : { dialect: 'cel', source: "{'clientSecret': api_token}" },
64+
},
5965
},
6066
},
6167
{ id: 'end', type: 'end', label: 'End' },

0 commit comments

Comments
 (0)