@@ -18,9 +18,16 @@ import {
1818// builtin's config could run. These are the contracts that do.
1919import {
2020 GetRecordConfigSchema ,
21+ CreateRecordConfigSchema ,
2122 UpdateRecordConfigSchema ,
2223 DeleteRecordConfigSchema ,
2324} from './builtin-node-config.zod' ;
25+ // #5500 — the `loop` container contract (ADR-0031). A `loop` with no
26+ // `config.body` is the LEGACY flat-graph shape: `loop-node.ts` reads
27+ // `config.collection` as a bare VARIABLE NAME, binds `$loopItems`/`$loopIndex`
28+ // and falls through without iterating — so no `iteratorVariable` is ever set
29+ // and a `{item.…}` token downstream references nothing.
30+ import { LoopConfigSchema } from './control-flow.zod' ;
2431
2532describe ( 'FlowNodeAction' , ( ) => {
2633 it ( 'should accept all node action types' , ( ) => {
@@ -114,8 +121,12 @@ describe('FlowNodeSchema', () => {
114121 id : 'node_3' ,
115122 type : 'create_record' ,
116123 label : 'Create Account' ,
124+ // #5500 — was `object:`, the retired spelling the ADR-0087 D2 conversion
125+ // `flow-node-crud-object-alias` rewrites at load (live through this major,
126+ // retires at 18). A fixture is a teaching surface, so it spells the
127+ // canonical key the executor actually reads.
117128 config : {
118- object : 'account' ,
129+ objectName : 'account' ,
119130 fields : {
120131 name : '{input.companyName}' ,
121132 status : 'active' ,
@@ -124,6 +135,13 @@ describe('FlowNodeSchema', () => {
124135 } ;
125136
126137 expect ( ( ) => FlowNodeSchema . parse ( node ) ) . not . toThrow ( ) ;
138+
139+ // #5500 — `FlowNodeSchema.config` is deliberately open (ADR-0018), so the
140+ // parse above stays green on a config no executor could run. Pin the config
141+ // against the contract `create_record` actually parses: this is a VALUE
142+ // verdict (the executor refuses the node without `objectName`), so full
143+ // safeParse green is the right bar, and the alias spelling turns it red.
144+ expect ( CreateRecordConfigSchema . safeParse ( node . config ) . success ) . toBe ( true ) ;
127145 } ) ;
128146
129147 it ( 'should accept all node types' , ( ) => {
@@ -481,8 +499,11 @@ describe('FlowSchema', () => {
481499 id : 'create_contact' ,
482500 type : 'create_record' ,
483501 label : 'Create Contact' ,
502+ // #5500 — `object:` → `objectName:` (see `should accept node with
503+ // config`). The `{firstName}` &c. tokens are declared INPUT
504+ // variables, which the engine binds by name, so they resolve.
484505 config : {
485- object : 'contact' ,
506+ objectName : 'contact' ,
486507 fields : {
487508 first_name : '{firstName}' ,
488509 last_name : '{lastName}' ,
@@ -494,9 +515,21 @@ describe('FlowSchema', () => {
494515 id : 'assign_output' ,
495516 type : 'assignment' ,
496517 label : 'Set Output' ,
518+ // #5500 — was `{ variable: 'contactId', value: '{create_contact.id}' }`,
519+ // which set NEITHER. `logic-nodes.ts` normalizes three assignment
520+ // shapes and its last branch is "no `assignments` wrapper → the
521+ // top-level config keys ARE the variable names", so that config
522+ // declared two variables literally named `variable` and `value`,
523+ // and `contactId` — declared `isOutput: true` right above — was
524+ // never written. Measured: with the old shape the run ends with
525+ // `variable='contactId'`, `value='<the new id>'`, `contactId=undefined`.
526+ //
527+ // The VALUE token was fine and is kept verbatim: the engine binds
528+ // every node's `result.output` under `<nodeId>.<key>` and the
529+ // template resolver reads that flat key, so `{create_contact.id}`
530+ // resolves to the created row's id (`create_record` outputs `id`).
497531 config : {
498- variable : 'contactId' ,
499- value : '{create_contact.id}' ,
532+ assignments : { contactId : '{create_contact.id}' } ,
500533 } ,
501534 } ,
502535 { id : 'end' , type : 'end' , label : 'End' } ,
@@ -509,6 +542,23 @@ describe('FlowSchema', () => {
509542 } ;
510543
511544 expect ( ( ) => FlowSchema . parse ( screenFlow ) ) . not . toThrow ( ) ;
545+
546+ // #5500 — pin the create node against the contract its executor parses.
547+ const cfgOf = ( id : string ) => screenFlow . nodes . find ( n => n . id === id ) ?. config ;
548+ expect ( CreateRecordConfigSchema . safeParse ( cfgOf ( 'create_contact' ) ) . success ) . toBe ( true ) ;
549+
550+ // #5500 — pin the assignment's SHAPE, which no spec schema governs (the
551+ // executor reads `config` directly, so `FlowSchema.parse` can never catch
552+ // this). The writes must live under `assignments`, and every name written
553+ // must be a variable this flow declares — reverting to the bare
554+ // `{ variable, value }` shape leaves `assignments` undefined and turns
555+ // both assertions red.
556+ const assignCfg = cfgOf ( 'assign_output' ) as { assignments ?: Record < string , unknown > } ;
557+ expect ( Object . keys ( assignCfg ?. assignments ?? { } ) ) . toEqual ( [ 'contactId' ] ) ;
558+ const declared = new Set ( ( screenFlow . variables ?? [ ] ) . map ( v => v . name ) ) ;
559+ for ( const written of Object . keys ( assignCfg ?. assignments ?? { } ) ) {
560+ expect ( declared . has ( written ) ) . toBe ( true ) ;
561+ }
512562 } ) ;
513563
514564 it ( 'should accept scheduled flow' , ( ) => {
@@ -523,55 +573,120 @@ describe('FlowSchema', () => {
523573 id : 'get_old_records' ,
524574 type : 'get_record' ,
525575 label : 'Find Old Records' ,
576+ // #5500 — three defects in two keys:
577+ // • `object:` → `objectName:` (the ADR-0087 D2 alias, as above).
578+ // • `filter` was the STRING `'created_at < DAYS_AGO(90)'`. The
579+ // contract declares `z.record(z.string(), z.unknown())`, so a
580+ // string fails safeParse outright ("expected record, received
581+ // string"), and `DAYS_AGO()` is a function no layer implements.
582+ // The date window is spelled in the dialect that OWNS a filter
583+ // value position: `{90_days_ago}` is a spec date macro
584+ // (`DATE_MACRO_PARAM_RE`), and `interpolateFilter` hands a known
585+ // filter token through VERBATIM for the query engine's
586+ // `resolveFilterTokens` to expand (#3810 ownership transfer) —
587+ // `date-macros.zod.ts` names "flow node filters" as a consumer.
588+ // • `limit` was absent. The executor branches on it: `limit > 1`
589+ // runs `find` and outputs a `records` LIST; otherwise `findOne`
590+ // and a single `record`. A cleanup sweep wants the list, and the
591+ // loop below needs an array, so the limit is what makes the
592+ // downstream `collection` an array at all.
526593 config : {
527- object : 'log_entry' ,
528- filter : 'created_at < DAYS_AGO(90)' ,
594+ objectName : 'log_entry' ,
595+ filter : { created_at : { $lt : '{90_days_ago}' } } ,
596+ limit : 200 ,
597+ outputVariable : 'oldRecords' ,
529598 } ,
530599 } ,
531600 {
532601 id : 'loop_records' ,
533602 type : 'loop' ,
534603 label : 'For Each Record' ,
604+ // #5500 — was a LEGACY flat-graph loop: no `config.body`, so
605+ // `loop-node.ts` took its back-compat branch, which reads
606+ // `config.collection` as a bare VARIABLE NAME (not a template),
607+ // found no variable literally named `{get_old_records.records}`,
608+ // bound nothing and returned success. The `loop → delete → loop`
609+ // back-edge was ordinary graph traversal, and `{item.id}` in the
610+ // delete node below referenced a variable no one ever set.
611+ // Measured on the old shape: `$loopItems` unset, `item` undefined.
612+ //
613+ // This is now the ADR-0031 structured container: the per-item steps
614+ // live in `config.body` (a single-entry/single-exit region run in
615+ // the enclosing scope) and `iteratorVariable` is what binds `item`.
535616 config : {
536- collection : '{get_old_records.records}' ,
537- } ,
538- } ,
539- {
540- id : 'delete_record' ,
541- type : 'delete_record' ,
542- label : 'Delete Record' ,
543- // #4924 — this was the worst of the three shapes: `recordId` was the
544- // node's ONLY key, no executor reads it, and a `delete_record` whose
545- // single "constraint" is unread is a match-everything delete (#3810)
546- // wearing a key that reads like a constraint. The executor locates rows
547- // through `filter` and refuses the node without `objectName`.
548- // The per-item token is the loop's `iteratorVariable` (default `item`),
549- // NOT `{<node id>.item}` — node outputs are never bound under a node id.
550- config : {
551- objectName : 'log_entry' ,
552- filter : { id : '{item.id}' } ,
617+ collection : '{oldRecords}' ,
618+ iteratorVariable : 'item' ,
619+ maxIterations : 200 ,
620+ body : {
621+ nodes : [
622+ {
623+ id : 'delete_record' ,
624+ type : 'delete_record' ,
625+ label : 'Delete Record' ,
626+ // #4924 — this was the worst of the three shapes: `recordId` was the
627+ // node's ONLY key, no executor reads it, and a `delete_record` whose
628+ // single "constraint" is unread is a match-everything delete (#3810)
629+ // wearing a key that reads like a constraint. The executor locates rows
630+ // through `filter` and refuses the node without `objectName`.
631+ // The per-item token is the loop's `iteratorVariable` (default `item`),
632+ // NOT `{<node id>.item}` — and #5500 moved the node INSIDE the loop
633+ // body, which is what makes `item` actually bound per iteration.
634+ config : {
635+ objectName : 'log_entry' ,
636+ filter : { id : '{item.id}' } ,
637+ } ,
638+ } ,
639+ ] ,
640+ edges : [ ] ,
641+ } ,
553642 } ,
554643 } ,
555644 { id : 'end' , type : 'end' , label : 'End' } ,
556645 ] ,
557646 edges : [
558647 { id : 'e1' , source : 'start' , target : 'get_old_records' } ,
559648 { id : 'e2' , source : 'get_old_records' , target : 'loop_records' } ,
560- { id : 'e3' , source : 'loop_records' , target : 'delete_record' } ,
561- { id : 'e4' , source : 'delete_record' , target : 'loop_records' } ,
562- { id : 'e5' , source : 'loop_records' , target : 'end' , label : 'Done' } ,
649+ // #5500 — the `loop → delete → loop` back-edge pair is gone: the
650+ // delete node now lives in `config.body`, so the loop's ordinary
651+ // out-edge is simply the after-loop continuation (ADR-0031).
652+ { id : 'e3' , source : 'loop_records' , target : 'end' , label : 'Done' } ,
563653 ] ,
564654 runAs : 'system' ,
565655 } ;
566656
567657 expect ( ( ) => FlowSchema . parse ( scheduledFlow ) ) . not . toThrow ( ) ;
568658
569659 // #4924 — the delete node addresses rows the only way the executor does.
570- // (The `get_old_records` / `loop_records` pair upstream still carries
571- // shapes of its own — a string `filter`, the `object` alias and a
572- // `{<node id>.…}` output reference — tracked separately, see the PR.)
573- const deleteConfig = scheduledFlow . nodes . find ( n => n . id === 'delete_record' ) ?. config ;
660+ // #5500 — it is now reached through the loop's body region.
661+ const loopConfig = scheduledFlow . nodes . find ( n => n . id === 'loop_records' ) ?. config ;
662+ const parsedLoop = LoopConfigSchema . safeParse ( loopConfig ) ;
663+ expect ( parsedLoop . success ) . toBe ( true ) ;
664+ const deleteConfig = parsedLoop . success
665+ ? parsedLoop . data . body ?. nodes . find ( n => n . id === 'delete_record' ) ?. config
666+ : undefined ;
574667 expect ( DeleteRecordConfigSchema . safeParse ( deleteConfig ) . success ) . toBe ( true ) ;
668+
669+ // #5500 — pin the loop as a STRUCTURED container. `body` is what separates
670+ // it from the legacy flat-graph shape that iterated nothing, and
671+ // `iteratorVariable` is the only thing that binds the `{item.…}` token the
672+ // body's filter reads. Dropping `body` puts the fixture back on the
673+ // legacy branch and turns both of these red.
674+ expect ( parsedLoop . success && parsedLoop . data . body ) . toBeDefined ( ) ;
675+ expect ( parsedLoop . success && parsedLoop . data . iteratorVariable ) . toBe ( 'item' ) ;
676+ // …and no main-graph edge targets a body node any more.
677+ const bodyNodeIds = new Set (
678+ parsedLoop . success ? ( parsedLoop . data . body ?. nodes ?? [ ] ) . map ( n => n . id ) : [ ] ,
679+ ) ;
680+ expect ( scheduledFlow . edges . filter ( e => bodyNodeIds . has ( e . target ) ) ) . toHaveLength ( 0 ) ;
681+
682+ // #5500 — the upstream read must produce an ARRAY for the loop to iterate:
683+ // `limit > 1` is what selects the `find`/`records` branch over
684+ // `findOne`/`record`, so it is a contract detail, not a tuning knob.
685+ const getConfig = scheduledFlow . nodes . find ( n => n . id === 'get_old_records' ) ?. config ;
686+ const parsedGet = GetRecordConfigSchema . safeParse ( getConfig ) ;
687+ expect ( parsedGet . success ) . toBe ( true ) ;
688+ expect ( parsedGet . success && ( parsedGet . data . limit ?? 0 ) > 1 ) . toBe ( true ) ;
689+ expect ( parsedGet . success && parsedGet . data . outputVariable ) . toBe ( 'oldRecords' ) ;
575690 } ) ;
576691
577692 it ( 'should accept API flow with webhook' , ( ) => {
@@ -975,11 +1090,29 @@ describe('BPMN — Default Sequence Flow (isDefault)', () => {
9751090 source : 'decision_1' ,
9761091 target : 'branch_a' ,
9771092 type : 'conditional' ,
978- condition : '{amount} > 1000' ,
1093+ // #5500 — was `'{amount} > 1000'`. An edge condition is BARE CEL
1094+ // (ADR-0032 §1a); `{…}` template braces parse as a CEL map literal, and
1095+ // `AutomationEngine.registerFlow` parse-validates every predicate at
1096+ // registration, so the braced form is a HARD registration failure:
1097+ // "Flow '…' has 1 invalid expression (ADR-0032 §1a). Predicates … must
1098+ // not wrap references in `{…}` template braces". This is the #1491 trap.
1099+ condition : 'amount > 1000' ,
9791100 label : 'High Value' ,
9801101 } ) ;
9811102 expect ( result . type ) . toBe ( 'conditional' ) ;
9821103 expect ( result . isDefault ) . toBe ( false ) ;
1104+ // #5500 — only an explicit assertion keeps this fixture from teaching the
1105+ // braced form back in. Braces are correct in TEMPLATE slots
1106+ // (`loop.collection`) and wrong here — the distinction is the whole point.
1107+ //
1108+ // Read `.source`, NOT the condition itself: `ExpressionInputSchema`
1109+ // normalizes a bare-string predicate into the canonical
1110+ // `{ dialect: 'cel', source }` envelope, so `expect(result.condition)
1111+ // .not.toContain('{')` asserts against an OBJECT and passes no matter what
1112+ // the predicate says. That phantom was written here first and caught by
1113+ // reverse-verification (the braced spelling stayed green) — hence this note.
1114+ expect ( result . condition ?. dialect ) . toBe ( 'cel' ) ;
1115+ expect ( result . condition ?. source ) . not . toContain ( '{' ) ;
9831116 } ) ;
9841117
9851118 it ( 'should validate a decision with default and conditional branches' , ( ) => {
@@ -997,8 +1130,11 @@ describe('BPMN — Default Sequence Flow (isDefault)', () => {
9971130 ] ,
9981131 edges : [
9991132 { id : 'e1' , source : 'start' , target : 'check_priority' } ,
1000- { id : 'e2' , source : 'check_priority' , target : 'high_path' , type : 'conditional' , condition : '{priority} == "high"' } ,
1001- { id : 'e3' , source : 'check_priority' , target : 'medium_path' , type : 'conditional' , condition : '{priority} == "medium"' } ,
1133+ // #5500 — bare CEL, not `{…}` template braces (ADR-0032 §1a; see
1134+ // 'should accept conditional edge type'). Registering the braced form
1135+ // threw at `registerFlow`, so this whole fixture was un-runnable.
1136+ { id : 'e2' , source : 'check_priority' , target : 'high_path' , type : 'conditional' , condition : 'priority == "high"' } ,
1137+ { id : 'e3' , source : 'check_priority' , target : 'medium_path' , type : 'conditional' , condition : 'priority == "medium"' } ,
10021138 { id : 'e4' , source : 'check_priority' , target : 'default_path' , isDefault : true , label : 'Default' } ,
10031139 { id : 'e5' , source : 'high_path' , target : 'end' } ,
10041140 { id : 'e6' , source : 'medium_path' , target : 'end' } ,
@@ -1012,6 +1148,17 @@ describe('BPMN — Default Sequence Flow (isDefault)', () => {
10121148 const defaultEdge = result . data . edges . find ( e => e . isDefault ) ;
10131149 expect ( defaultEdge ) . toBeDefined ( ) ;
10141150 expect ( defaultEdge ! . target ) . toBe ( 'default_path' ) ;
1151+ // #5500 — every guarded branch is bare CEL. `FlowSchema.parse` accepts any
1152+ // string here, so without this the fixture could teach the #1491 braced
1153+ // form back in while staying green. Assert on the normalized
1154+ // `condition.source` (see 'should accept conditional edge type') — the
1155+ // parsed `condition` is an Expression ENVELOPE, and `toContain` against
1156+ // the envelope object pins nothing.
1157+ const guardedEdges = result . data . edges . filter ( e => e . condition ) ;
1158+ expect ( guardedEdges ) . toHaveLength ( 2 ) ;
1159+ for ( const guarded of guardedEdges ) {
1160+ expect ( guarded . condition ?. source ) . not . toContain ( '{' ) ;
1161+ }
10151162 }
10161163 } ) ;
10171164} ) ;
0 commit comments