Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27968-ledger-sibling-block-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
---

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

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered connector-action-input-value-slot-judged -->

**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.

- **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`.
- **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'`.
- **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`.
- **Unchanged.** Every `config`-rooted entry resolves byte-identically. The `FlowSchema` example comment no longer says `connectorConfig.input` is handed to the action as written.

**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' }`).
18 changes: 18 additions & 0 deletions .changeset/27968-registration-judges-connector-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-automation': minor
---

fix(service-automation)!: `registerFlow` refuses a malformed envelope and a `{…}` token in a `connector_action` node's `connectorConfig.input`

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered connector-action-input-value-slot-judged -->

**BREAKING** accept-set narrowing at `AutomationEngine.registerFlow`.

- **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.
- **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.
- **Unchanged.** Literals and valid CEL envelopes register and dispatch as before; an envelope nested inside a literal is data; `input` stays optional.

**The remedy.** Write a computed value as `{ dialect: 'cel', source: '…' }` and a constant as a literal; follow the CEL spelling each refusal names.
18 changes: 18 additions & 0 deletions .changeset/27968-validate-judges-connector-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/lint': minor
---

fix(lint)!: `objectstack validate` judges a `connector_action` node's `connectorConfig.input` as a value slot

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered connector-action-input-value-slot-judged -->

**BREAKING** accept-set narrowing at `validateStackExpressions` (`objectstack validate`), and so at the runtime authoring gate that runs the same pass on a flow write.

- **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.
- **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.
- **Unchanged.** Literals and valid CEL envelopes pass.

**The remedy.** Write a computed value as `{ dialect: 'cel', source: '…' }` and a constant as a literal; follow the CEL spelling each refusal names.
2 changes: 1 addition & 1 deletion content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const result = FlowSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **connectorId** | `string` | ✅ | Registered connector name |
| **actionId** | `string` | ✅ | Action key declared by the connector |
| **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`. |
| **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`. |

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

Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,10 @@ export const TaskCompletedSlackFlow = defineFlow({
actionId: 'chat.postMessage',
input: {
channel: 'C0WINS000',
text: '✅ Task done: {record.title}',
// Each input value is a literal or a CEL value envelope evaluated
// before dispatch (#27968); a `{…}` token in a string is refused.
// `title` is a required field, so it is read unguarded.
text: { dialect: 'cel', source: "'✅ Task done: ' + record.title" },
},
},
},
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/test/flow-credential-literal-doors.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
* query parameter, a nested connector input); DARK is the same flow with
* templates in those three positions — a `{{ var }}` hole at the http url and
* header (text slots, where a single-brace token is refused since #19939), a
* `{var}` token at the connector input — and must stay silent. Every
* CEL value envelope computing the connector input's `auth` map (a value slot
* since #27968, where a `{…}` token is refused) — and must stay silent. Every
* value is a probe sentinel, not a credential.
*
* Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`).
Expand Down Expand Up @@ -85,7 +86,11 @@ const stackOf = (literal: boolean) => ({
connectorConfig: {
connectorId: 'rest',
actionId: 'request',
input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } },
input: {
method: 'GET',
path: '/p',
auth: literal ? { clientSecret: SENTINELS[2] } : { dialect: 'cel', source: "{'clientSecret': api_token}" },
},
},
},
{ id: 'end', type: 'end', label: 'End' },
Expand Down
90 changes: 90 additions & 0 deletions packages/lint/src/validate-expressions.connector-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* **`objectstack validate` judges a `connector_action` node's
* `connectorConfig.input`** (#27968) — the build door's half of the slot
* `registerFlow` now judges too.
*
* The expression pass reads value slots only through the spec's expression
* ledger, whose paths were rooted in `node.config`; `connectorConfig` is a
* sibling block, so a malformed CEL envelope, a template-dialect envelope and a
* `{…}` token string there passed `validateStackExpressions` clean while the
* same three under a `subflow`'s `config.input` were refused. The ledger now
* roots the slot in the block, and this pass judges it like every other value
* slot, located at `connectorConfig.input.<key>`, at `error`. The runtime
* authoring gate runs the same pass, so a flow write is refused there as well.
*/

import { describe, expect, it } from 'vitest';
import { VALUE_ENVELOPE_REFUSAL, VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation';

import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { validateStackExpressions, type ExprIssue } from './validate-expressions.js';

type AnyRec = Record<string, unknown>;

/** start → `node` → end, the record an input variable. */
function flowWith(node: AnyRec): AnyRec {
return {
name: 'f',
label: 'F',
type: 'autolaunched',
variables: [{ name: 'rec', type: 'object', isInput: true }],
nodes: [
{ id: 'start', type: 'start', config: {} },
{ id: 'call', label: 'Call', ...node },
{ id: 'done', type: 'end', config: {} },
],
edges: [{ id: 'e1', source: 'start', target: 'call' }, { id: 'e2', source: 'call', target: 'done' }],
};
}

const connectorNode = (input: AnyRec): AnyRec => ({
type: 'connector_action',
connectorConfig: { connectorId: 'vendor', actionId: 'lookup', input },
});
const subflowNode = (input: AnyRec): AnyRec => ({ type: 'subflow', config: { flowName: 'child', input } });

const errorsOf = (flow: AnyRec): ExprIssue[] =>
validateStackExpressions({ flows: [flow] }).filter((i) => (i.severity ?? 'error') === 'error');

/** The three shapes the #27327 thread measured passing the build door under `connectorConfig.input`. */
const SHAPES = [
['a malformed CEL envelope', { dialect: 'cel', source: 'rec.' }, VALUE_ENVELOPE_REFUSAL],
['a template-dialect envelope', { dialect: 'template', source: '{rec.id}' }, VALUE_ENVELOPE_REFUSAL],
['a bare `{…}` token string', '{rec.id}', VALUE_SLOT_TEMPLATE_REFUSAL],
] as const;

describe('objectstack validate refuses the three shapes at `connectorConfig.input.<key>`', () => {
it.each(SHAPES)('%s', (_shape, value, lead) => {
const found = errorsOf(flowWith(connectorNode({ customerId: value, region: 'eu' })));
expect(found.map((i) => i.where)).toEqual([
"flow 'f' · node 'call' (connector_action) connector_action input value at connectorConfig.input.customerId",
]);
expect(found[0]!.message.startsWith(lead)).toBe(true);
// The same shape under a `subflow`'s input map: the same refusal, at its own position.
const control = errorsOf(flowWith(subflowNode({ customerId: value })));
expect(control.map((i) => i.where)).toEqual([
"flow 'f' · node 'call' (subflow) subflow input value at config.input.customerId",
]);
// The connector's refusal is the subflow's, minus the callee sentence a
// child flow's input value adds: the action was handed the text, not nothing.
expect(control[0]!.message.startsWith(found[0]!.message)).toBe(true);
});

it('a valid envelope and literals pass', () => {
expect(errorsOf(flowWith(connectorNode({
customerId: { dialect: 'cel', source: 'rec.id' },
region: 'eu',
body: { filter: { dialect: 'cel', source: 'rec.id' } },
})))).toEqual([]);
expect(errorsOf(flowWith({ type: 'connector_action', connectorConfig: { connectorId: 'vendor', actionId: 'lookup' } }))).toEqual([]);
});

it('the runtime authoring gate refuses a flow write carrying one, through the same pass', () => {
const result = runRuntimeAuthoringRules({ type: 'flow', item: flowWith(connectorNode({ customerId: '{rec.id}' })) });
const refused = result.errors.filter((f) => f.where.includes('connectorConfig.input.customerId'));
expect(refused.map((f) => f.rule)).toEqual(['expression-invalid']);
expect(refused[0]!.message.startsWith(VALUE_SLOT_TEMPLATE_REFUSAL)).toBe(true);
});
});
11 changes: 7 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2769,8 +2769,11 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
severity: 'error',
});
}
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
// [#27968] Handed the node, the ledger reaches a slot rooted in a
// sibling block too — a `connector_action` node's
// `connectorConfig.input` — and names the block it is in.
for (const found of resolveFlowNodeExpressions(nodeType, cfg, node)) {
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at ${found.block}.${found.path}`;
// [#15137] `value` slots are checkable too, by their own rule — see
// `checkDeclaredValue`. Not shadowing-checked: the shadow diagnostic is
// about a bare identifier resolving to a variable instead of a field,
Expand Down Expand Up @@ -2828,9 +2831,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
// (`flowNodeValueTemplateRefusals`), walking the ledger's `value` slots
// and the two legacy `assignment` shapes, so build and registration
// give one verdict, and each refusal names the token's CEL spelling.
for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg)) {
for (const templateRefusal of flowNodeValueTemplateRefusals(nodeType, cfg, node)) {
issues.push({
where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at config.${templateRefusal.path}`,
where: `${at} · node '${node.id}' (${nodeType}) ${templateRefusal.label} at ${templateRefusal.block}.${templateRefusal.path}`,
message: templateRefusal.message,
source: templateRefusal.source,
severity: 'error',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ const SENTINELS = ['door-header-sentinel', 'door-url-sentinel', 'door-input-sent
/**
* One flow, three served positions. `literal: false` is the same flow with templates:
* a `{{ var }}` hole at the http `url` and header — text slots, where a single-brace
* token is refused at this door since #19939 — and a `{var}` token at the connector input.
* token is refused at this door since #19939 — and, at the connector input, a CEL value
* envelope computing the `auth` map: the input is a value slot since #27968, where a
* `{…}` token is refused.
*/
const credentialFlow = (literal: boolean) => ({
name: literal ? 'door_cred_lit' : 'door_cred_dark',
Expand Down Expand Up @@ -55,7 +57,11 @@ const credentialFlow = (literal: boolean) => ({
connectorConfig: {
connectorId: 'rest',
actionId: 'request',
input: { method: 'GET', path: '/p', auth: { clientSecret: literal ? SENTINELS[2] : '{api_token}' } },
input: {
method: 'GET',
path: '/p',
auth: literal ? { clientSecret: SENTINELS[2] } : { dialect: 'cel', source: "{'clientSecret': api_token}" },
},
},
},
{ id: 'end', type: 'end', label: 'End' },
Expand Down
Loading
Loading