From 2c3f9f5095946a3ba3925696a57948796b0414af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:13:19 +0000 Subject: [PATCH] fix(spec): the stored-shape listing hands the value-slot judge the node, so a connector_action's connectorConfig.input token is listed The flow value-slot detector judged a node's `config` alone, so the slot the ledger roots in a connector_action node's `connectorConfig.input` was never read: `os migrate meta --stored` named nothing for a stored row that `registerFlow` refuses at boot. Both flow detectors now share one walk that hands the judge the whole node, as registerFlow and objectstack validate do, and split its refusals by block: `config` under flow-value-slot-template-dialect-refused, `connectorConfig` under connector-action-input-value-slot-judged, the D3 entry that refuses it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibz8wBXN5R4YRtm5e5r --- .../28163-stored-connector-input-listed.md | 12 +++ .../src/conversions/refused-shapes.test.ts | 90 ++++++++++++++++++ .../spec/src/conversions/refused-shapes.ts | 93 ++++++++++++++----- 3 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 .changeset/28163-stored-connector-input-listed.md diff --git a/.changeset/28163-stored-connector-input-listed.md b/.changeset/28163-stored-connector-input-listed.md new file mode 100644 index 00000000000..f9d363c6e8c --- /dev/null +++ b/.changeset/28163-stored-connector-input-listed.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta --stored` lists a stored `connector_action` node whose `connectorConfig.input` carries a `{…}` token, which boot refuses + +Clause-②: no +Metadata-protocol: none + +- **Before.** The stored-shape listing judged a flow node's value slots with the node's `config` alone, so it never read the value slot rooted in a `connector_action` node's `connectorConfig.input`. A stored flow with `input: { text: '{record.name}' }` was refused by `registerFlow` at boot (skipped with a `warn`, its trigger not armed), yet the preview listed nothing for it. +- **Now.** The listing hands the value-slot judge the whole node, as `registerFlow` and `objectstack validate` do, so it lists such a value as a TODO under the `connector-action-input-value-slot-judged` entry, by flow, node and path (`connectorConfig.input.text`), with the value's CEL spelling. A loose `config.input` the conversions lift onto `connectorConfig` is listed at its lifted path. Nothing is rewritten. +- **Unchanged.** A `config` value slot is listed under `flow-value-slot-template-dialect-refused`, as before. A CEL value envelope or a literal in `connectorConfig.input` is not listed. diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index 743e977ca2f..eafcd9766b5 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; +import { FLOW_NODE_SIBLING_EXPRESSION_BLOCKS } from '../automation/flow-node-expression-paths.js'; import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js'; import { ObjectSchema } from '../data/object.zod.js'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js'; @@ -66,6 +67,7 @@ describe('the refused-shape list — registered against the migration chain', () 'cbp-master-relation-tiers-retired', 'view-data-api-provider-retired', 'flow-value-slot-template-dialect-refused', + 'connector-action-input-value-slot-judged', 'object-external-binding-managed-datasource-refused', 'visibility-predicate-has-argument-refused', 'object-duplicate-action-name-refused', @@ -364,6 +366,94 @@ describe('#23269 — a stored flow whose CRUD filter operand is a {…} token is }); }); +// ── #28163 ──────────────────────────────────────────────────────────────── + +const connectorNode = (id: string, input: unknown) => ({ + id, + type: 'connector_action', + label: id, + connectorConfig: { connectorId: 'helpdesk', actionId: 'open_ticket', input }, +}); + +describe('#28163 — a stored connector_action whose connectorConfig.input carries a {…} token is listed by flow and node', () => { + it('one TODO under the connector input entry, at connectorConfig.input.; the row is not rewritten', () => { + const row = flowOf('fd_connector', [connectorNode('ticket', { text: '{record.name}', queue: 'support' })]); + const before = JSON.stringify(row); + const { item, todos } = storedTodos('flow', row); + expect(JSON.stringify(item)).toBe(before); + expect(todos).toHaveLength(1); + const todo = todos[0]!; + expect(todo.conversionId).toBe('connector-action-input-value-slot-judged'); + expect(todo.path).toBe('flows[0].nodes[1].connectorConfig.input.text'); + expect(todo.from).toBe(JSON.stringify('{record.name}')); + expect(todo.reason).toContain( + "Flow `fd_connector`, node 'ticket' (connector_action), connector_action input value at connectorConfig.input.text.", + ); + expect(todo.reason).toContain('registerFlow refuses the flow: it is not registered at boot and never runs'); + expect(todo.reason).toContain("{ dialect: 'cel', source: 'record.name' }"); + }); + + it('reaches a value nested in the input map, and a node inside a structured region', () => { + const flow = flowOf('fd_connector_loop', [{ + id: 'each', type: 'loop', label: 'Each', + config: { + collection: { dialect: 'cel', source: 'items' }, + body: { nodes: [connectorNode('inner', { tags: ['ok', '{item.tag}'] })], edges: [] }, + }, + }]); + const todos: ConversionTodoNotice[] = []; + applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) }); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + ['connector-action-input-value-slot-judged', 'flows[0].nodes[1].config.body.nodes[0].connectorConfig.input.tags[1]'], + ]); + }); + + it('judges the row the conversions produced: a loose config.input the chain lifts is listed at connectorConfig', () => { + const row = flowOf('fd_connector_loose', [{ + id: 'loose', type: 'connector_action', label: 'Loose', + config: { connectorId: 'helpdesk', actionId: 'open_ticket', input: { text: '{record.name}' } }, + }]); + const { todos } = storedTodos('flow', row); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + ['connector-action-input-value-slot-judged', 'flows[0].nodes[1].connectorConfig.input.text'], + ]); + }); + + it.each([ + ['a CEL envelope', { text: { dialect: 'cel', source: 'record.name' } }], + ['a literal', { text: 'Ticket', queue: 'support' }], + ['no input', undefined], + ])('a connector_action already on protocol (%s) is not listed', (_label, input) => { + const flow = flowOf('fd_connector_ok', [connectorNode('ticket', input)]); + const todos: ConversionTodoNotice[] = []; + applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) }); + expect(todos).toEqual([]); + }); + + it('agrees with the refusing door: each site is one the value-slot judge refuses when handed the node, and no other', () => { + const nodes: Array> = [ + connectorNode('a', { text: '{record.name}', id: { dialect: 'cel', source: 'record.id' } }), + crudNode('b', 'get_record', { id: '{record.id}' }), + connectorNode('c', { note: 'plain' }), + ]; + const { todos } = todosOf({ flows: [flowOf('fd_connector_mixed', nodes)] }); + // The door's own call — `registerFlow` and `objectstack validate` hand the node. + const judged = nodes.flatMap((n, i) => + flowNodeValueTemplateRefusals(n.type, n.config, n).map((r) => `flows[0].nodes[${i + 1}].${r.block}.${r.path}`)); + // Report order is detector order, so the two are compared as sets. + expect(todos.map((t) => t.path).sort()).toEqual([...judged].sort()); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + ['flow-value-slot-template-dialect-refused', 'flows[0].nodes[2].config.filter.id'], + ['connector-action-input-value-slot-judged', 'flows[0].nodes[1].connectorConfig.input.text'], + ]); + }); + + it('the two flow detectors cover every block the ledger roots a value slot in', () => { + // A new sibling block needs a detector here (and its D3 entry) before its refusals can be listed. + expect(['config', ...Object.keys(FLOW_NODE_SIBLING_EXPRESSION_BLOCKS)]).toEqual(['config', 'connectorConfig']); + }); +}); + // ── #23317 ──────────────────────────────────────────────────────────────── describe('#23317 — a stored page whose component binds the retired API source in its `data` prop is listed by page and component', () => { diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index 5835e5a30de..88279418c28 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -38,7 +38,9 @@ * component-props gate parses at the CLI doors and at the runtime save * door — and the value-slot judge * `AutomationEngine.registerFlow` and `objectstack validate` run - * ({@link flowNodeValueTemplateRefusals}), and the `external` binding judge + * ({@link flowNodeValueTemplateRefusals}), handed the whole node as those + * doors hand it, so a sibling block's slot is judged too, and the + * `external` binding judge * the object refinement and `defineStack` share * ({@link externalBindingDatasourceRefusal}), and the one-name-per-action * judge of the object refinement ({@link duplicateActionNames}). A site is listed here exactly @@ -54,7 +56,10 @@ * The TODO notices it produces are the published shape. */ -import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js'; +import { + flowNodeValueTemplateRefusals, + type FlowNodeValueTemplateRefusal, +} from '../automation/flow-value-slot-template.js'; import { controlledByParentMasterRefusal, isControlledByParentMasterField, @@ -391,30 +396,75 @@ const viewDataApiProvider: RefusedStoredShape = { // ── #23269: a flow value slot carrying a `{…}` token, a CRUD filter operand among them ── +/** + * Hand every value-slot refusal of every node of every stored flow to `visit`, + * with the flow's listed name and the node's own path. The judge is handed the + * node itself (#28163), as `registerFlow` and `objectstack validate` hand it, + * so a slot the ledger roots in a sibling block — a `connector_action` node's + * `connectorConfig.input` — is judged here exactly when those doors judge it. + * Each refusal names its block, and the two detectors below split the blocks + * by the D3 entry that refuses each: `config` and `connectorConfig`, which + * `refused-shapes.test.ts` holds equal to the blocks the ledger declares, so a + * new sibling block cannot go unlisted. + */ +function forEachFlowValueSlotRefusal( + stack: Dict, + visit: (flowName: string, node: Dict & { type: string }, path: string, refusal: FlowNodeValueTemplateRefusal) => void, +): void { + const flows = stack.flows; + if (!Array.isArray(flows)) return; + flows.forEach((flow, fi) => { + if (!isDict(flow) || !Array.isArray(flow.nodes)) return; + const flowName = typeof flow.name === 'string' ? flow.name : `flows[${fi}]`; + mapFlowNodeList(flow.nodes, `flows[${fi}].nodes`, (node, path) => { + if (typeof node.type !== 'string') return node; + const typed = node as Dict & { type: string }; + for (const refusal of flowNodeValueTemplateRefusals(typed.type, node.config, node)) { + visit(flowName, typed, path, refusal); + } + return node; + }); + }); +} + const flowValueSlotTemplateToken: RefusedStoredShape = { id: 'flow-value-slot-template-dialect-refused', toMajor: 18, surface: 'flows[].nodes[].config — a value slot (a get_record, update_record or delete_record filter operand among them)', detect(stack, report) { - const flows = stack.flows; - if (!Array.isArray(flows)) return; - flows.forEach((flow, fi) => { - if (!isDict(flow) || !Array.isArray(flow.nodes)) return; - const flowName = typeof flow.name === 'string' ? flow.name : `flows[${fi}]`; - mapFlowNodeList(flow.nodes, `flows[${fi}].nodes`, (node, path) => { - if (typeof node.type !== 'string') return node; - for (const refusal of flowNodeValueTemplateRefusals(node.type, node.config)) { - report({ - path: `${path}.config.${refusal.path}`, - from: JSON.stringify(refusal.source), - reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at ` - + `config.${refusal.path}. No lossless rewrite: the template resolved a token CEL reads ` - + 'differently, so the upgrade does not convert it. Left as stored, registerFlow refuses the flow: ' - + 'it is not registered at boot and never runs. ' - + refusal.message, - }); - } - return node; + forEachFlowValueSlotRefusal(stack, (flowName, node, path, refusal) => { + if (refusal.block !== 'config') return; + report({ + path: `${path}.config.${refusal.path}`, + from: JSON.stringify(refusal.source), + reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at ` + + `config.${refusal.path}. No lossless rewrite: the template resolved a token CEL reads ` + + 'differently, so the upgrade does not convert it. Left as stored, registerFlow refuses the flow: ' + + 'it is not registered at boot and never runs. ' + + refusal.message, + }); + }); + }, +}; + +// ── #27968: a connector_action node's connectorConfig.input value carrying a `{…}` token ── + +const connectorActionInputTemplateToken: RefusedStoredShape = { + id: 'connector-action-input-value-slot-judged', + toMajor: 18, + surface: 'flows[].nodes[].connectorConfig.input — a value of a connector_action node\'s input map', + detect(stack, report) { + forEachFlowValueSlotRefusal(stack, (flowName, node, path, refusal) => { + if (refusal.block !== 'connectorConfig') return; + const at = `${refusal.block}.${refusal.path}`; + report({ + path: `${path}.${at}`, + from: JSON.stringify(refusal.source), + reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at ${at}. ` + + 'No lossless rewrite: the action was handed the token\'s text as written, and no token has a ' + + 'lossless CEL spelling, so the upgrade does not convert it. Left as stored, registerFlow refuses the ' + + 'flow: it is not registered at boot and never runs. ' + + refusal.message, }); }); }, @@ -513,6 +563,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [ controlledByParentWithoutOneMaster, viewDataApiProvider, flowValueSlotTemplateToken, + connectorActionInputTemplateToken, externalBindingOnManagedDatasource, visibilityHasArgument, objectDuplicateActionName,