11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { describe , it , expect } from 'vitest' ;
4- import { TimeRelativeTriggerSchema , LoopConfigSchema , FlowSchema } from '@objectstack/spec/automation' ;
4+ import { TimeRelativeTriggerSchema , LoopConfigSchema , ParallelConfigSchema , FlowSchema } from '@objectstack/spec/automation' ;
55import { AUTHORING_RULES } from './authoring-rules.js' ;
66import {
77 lintFlowPatterns ,
@@ -51,6 +51,35 @@ const flow = (condition: unknown, triggerType = 'record-after-update') => ({
5151 } ] ,
5252} ) ;
5353
54+ /**
55+ * The keys a container schema REFUSED on a fixture — `[]` when the shape spells
56+ * only declared keys, whatever the schema thinks of their VALUES (#5700).
57+ *
58+ * The criterion is deliberately key-level, and the same one
59+ * `validate-security-posture.test.ts` writes down for its reachability guard: a
60+ * rejected KEY and a rejected VALUE are different facts. A key the schema does
61+ * not declare is not an authoring surface at all — a fixture spelling one
62+ * describes a container no author can save, which is exactly the defect #5700
63+ * catalogued. A rejected value is a fixture choice these rules are often
64+ * entitled to make.
65+ *
66+ * So the container audits below use this, NOT flat `safeParse` success: most of
67+ * this file's fixtures are hand-written raw literals that omit the `label`
68+ * `FlowNodeSchema` requires on every node, and demanding full-parse green would
69+ * either delete that coverage or drag in a file-wide re-write #5700 explicitly
70+ * defers. Where a fixture IS fully authorable, it is pinned with full green
71+ * instead (see the `LoopConfigSchema` pins).
72+ */
73+ function refusedKeys ( result : {
74+ success : boolean ;
75+ error ?: { issues : readonly { code : string ; path : readonly PropertyKey [ ] ; message : string } [ ] } ;
76+ } ) : string [ ] {
77+ if ( result . success ) return [ ] ;
78+ return ( result . error ?. issues ?? [ ] )
79+ . filter ( ( i ) => i . code === 'unrecognized_keys' )
80+ . map ( ( i ) => `${ i . path . join ( '.' ) || '(root)' } : ${ i . message } ` ) ;
81+ }
82+
5483describe ( 'lintFlowPatterns — time-relative anti-pattern (#1874)' , ( ) => {
5584 it ( 'flags record-change date-EQUALITY against a time function' , ( ) => {
5685 const fnds = lintFlowPatterns ( flow ( 'end_date == daysFromNow(60)' ) ) ;
@@ -620,21 +649,24 @@ describe('lintFlowPatterns — user-less runAs unscoped (#1888 / ADR-0049 / ADR-
620649 // container's config physically contains its body, and the walk visits the
621650 // body in its own right, so a rule that pushed per hit would double-report.
622651 it ( 'reports ONCE for a flow whose regions hold several data nodes' , ( ) => {
652+ const fanConfig = {
653+ branches : [
654+ { name : 'writes' , nodes : [ { id : 'w1' , type : 'update_record' , label : 'Write' , config : { objectName : 'a' } } ] , edges : [ ] } ,
655+ { name : 'deletes' , nodes : [ { id : 'w2' , type : 'delete_record' , label : 'Delete' , config : { objectName : 'b' } } ] , edges : [ ] } ,
656+ ] ,
657+ } ;
658+ // #5700 container audit — the `parallel` twin of the `loop` pin above. This
659+ // block already declares `name`/`nodes`/`edges` and labels its branch nodes,
660+ // so it clears the strongest bar available: full `safeParse` green.
661+ expect ( ParallelConfigSchema . safeParse ( fanConfig ) . success ) . toBe ( true ) ;
662+
623663 const fnds = lintFlowPatterns ( {
624664 flows : [ {
625665 name : 'nightly_sweep' ,
626666 type : 'schedule' ,
627667 nodes : [
628668 { id : 'start' , type : 'start' , config : { triggerType : 'schedule' , cron : '0 8 * * *' } } ,
629- {
630- id : 'fan' , type : 'parallel' ,
631- config : {
632- branches : [
633- { name : 'writes' , nodes : [ { id : 'w1' , type : 'update_record' , label : 'Write' , config : { objectName : 'a' } } ] , edges : [ ] } ,
634- { name : 'deletes' , nodes : [ { id : 'w2' , type : 'delete_record' , label : 'Delete' , config : { objectName : 'b' } } ] , edges : [ ] } ,
635- ] ,
636- } ,
637- } ,
669+ { id : 'fan' , type : 'parallel' , config : fanConfig } ,
638670 ] ,
639671 edges : [ { id : 'e1' , source : 'start' , target : 'fan' } ] ,
640672 } ] ,
@@ -1090,6 +1122,17 @@ describe('flow-inert-node-condition (#4414)', () => {
10901122 * future flattening of the walk fails here instead of going quiet again.
10911123 */
10921124
1125+ /**
1126+ * The `loop` container `loopBodyFlow` builds, factored out (#5700) so the pin
1127+ * below reads the REAL config object every case in this family descends into,
1128+ * rather than a retyped copy that can drift away from it silently.
1129+ */
1130+ const loopLeadsConfig = ( body : { nodes : unknown [ ] ; edges : unknown [ ] } ) => ( {
1131+ collection : '{vars.leads}' ,
1132+ iteratorVariable : 'lead' ,
1133+ body,
1134+ } ) ;
1135+
10931136/** A scheduled sweep: `loop` over leads, with `body` holding the per-item graph. */
10941137function loopBodyFlow ( body : { nodes : unknown [ ] ; edges : unknown [ ] } ) {
10951138 return {
@@ -1100,7 +1143,7 @@ function loopBodyFlow(body: { nodes: unknown[]; edges: unknown[] }) {
11001143 { id : 'start' , type : 'start' , config : { triggerType : 'schedule' , schedule : 'cron:0 9 * * *' } } ,
11011144 {
11021145 id : 'loop_leads' , type : 'loop' , label : 'Loop Leads' ,
1103- config : { collection : '{vars.leads}' , itemVar : 'lead' , body } ,
1146+ config : loopLeadsConfig ( body ) ,
11041147 } ,
11051148 { id : 'end' , type : 'end' } ,
11061149 ] ,
@@ -1112,6 +1155,71 @@ function loopBodyFlow(body: { nodes: unknown[]; edges: unknown[] }) {
11121155 } ;
11131156}
11141157
1158+ /**
1159+ * #5700 — the container every case below nests its per-item graph inside must be
1160+ * a shape the schema ACCEPTS, or those cases prove their rule against metadata
1161+ * no author can write. That is the #4966 trap (the `TimeRelativeTriggerSchema`
1162+ * pin near the top of this file) one container down, and it was not hypothetical
1163+ * here: this helper bound the item with `itemVar` until #5700. `LoopConfigSchema`
1164+ * is a `strictObject` whose declared key is `iteratorVariable`, so `itemVar` is
1165+ * reported as an `unrecognized_key` rather than dropped (#4001) — while region
1166+ * collection reads only `config.body`, so every assertion downstream kept passing
1167+ * and nothing went red.
1168+ *
1169+ * The near-miss direction is already pinned once, in the #5633 block above
1170+ * (`itemVar` really is refused, so this family's pins have teeth); it is not
1171+ * repeated here.
1172+ *
1173+ * Full `safeParse` green rather than the key-level `refusedKeys` bar, because
1174+ * what the rules built on this helper judge is a VALUE verdict about a node that
1175+ * must really be REACHABLE inside a really-authorable container — so the body
1176+ * has to parse too, `label` and all.
1177+ *
1178+ * What is pinned is the container SHELL plus one representative body: the body
1179+ * is this helper's parameter, and the bodies the call sites pass are raw
1180+ * literals that omit the node `label` `FlowNodeSchema` requires. Bringing this
1181+ * whole file to full-flow-parse green is the separate, much broader change
1182+ * #5700 defers by name.
1183+ */
1184+ describe ( '#5700 — the shared loop container is a shape an author can actually write' , ( ) => {
1185+ it ( 'pins loopBodyFlow’s `loop` config against LoopConfigSchema' , ( ) => {
1186+ const parsed = LoopConfigSchema . safeParse ( loopLeadsConfig ( {
1187+ nodes : [ { id : 'enroll' , type : 'create_record' , label : 'Enroll' , config : { objectName : 'campaign_member' } } ] ,
1188+ edges : [ ] ,
1189+ } ) ) ;
1190+ expect ( parsed . success ) . toBe ( true ) ;
1191+ } ) ;
1192+
1193+ /**
1194+ * Why the two nested cases below carry their OWN pins instead of leaning on
1195+ * this one. `FlowRegionSchema.nodes` is an array of `FlowNodeSchema`, whose
1196+ * `config` is declared `z.record(z.string(), z.unknown())` — an OPEN record.
1197+ * So a nested container's config is accepted wholesale by the enclosing parse:
1198+ * this shell pin stays GREEN while an inner `loop` spells `itemVar`. Measured,
1199+ * not assumed — the assertion below is that blindness, stated as a fact, so a
1200+ * future reader does not mistake one pin at the top for coverage of the tree.
1201+ */
1202+ it ( 'does NOT see into a nested container’s config — the inner pins are load-bearing' , ( ) => {
1203+ const shellWithBadInnerLoop = LoopConfigSchema . safeParse ( loopLeadsConfig ( {
1204+ nodes : [ {
1205+ id : 'loop_touchpoints' , type : 'loop' , label : 'Loop Touchpoints' ,
1206+ config : {
1207+ collection : '{lead.touchpoints}' , itemVar : 'tp' ,
1208+ body : { nodes : [ { id : 'reset' , type : 'update_record' , label : 'Reset' , config : { } } ] , edges : [ ] } ,
1209+ } ,
1210+ } ] ,
1211+ edges : [ ] ,
1212+ } ) ) ;
1213+ expect ( shellWithBadInnerLoop . success ) . toBe ( true ) ;
1214+
1215+ // …whereas the very same inner config, parsed as the container it is, is refused.
1216+ expect ( refusedKeys ( LoopConfigSchema . safeParse ( {
1217+ collection : '{lead.touchpoints}' , itemVar : 'tp' ,
1218+ body : { nodes : [ { id : 'reset' , type : 'update_record' , label : 'Reset' , config : { } } ] , edges : [ ] } ,
1219+ } ) ) . join ( ' ' ) ) . toMatch ( / U n r e c o g n i z e d k e y \( s \) o n t h i s l o o p c o n t a i n e r c o n f i g : ` i t e m V a r ` / ) ;
1220+ } ) ;
1221+ } ) ;
1222+
11151223describe ( '#5383 — flow-inert-node-condition descends into a loop body' , ( ) => {
11161224 // The shipped shape, reduced: a per-item gate inside a sweep, whose predicate
11171225 // was written on the node instead of its out-edges.
@@ -1155,18 +1263,29 @@ describe('#5383 — flow-inert-node-condition descends into a loop body', () =>
11551263 expect ( fnds [ 0 ] . where ) . not . toContain ( 'loop' ) ;
11561264 } ) ;
11571265
1266+ /**
1267+ * The INNER container, named so the pin below reads the object the fixture
1268+ * really descends into. The planted defect is the decision's inert
1269+ * `config.condition` — everything else about this shape is authorable, and
1270+ * has to be, or the case proves the descent against a `loop` the schema
1271+ * refuses (#5700; the enclosing shell pin cannot see in here — `FlowNodeSchema`
1272+ * declares `config` as an open record).
1273+ */
1274+ const nestedTouchpointsLoop = {
1275+ collection : '{lead.touchpoints}' , iteratorVariable : 'tp' ,
1276+ body : {
1277+ nodes : [ { id : 'check_recent' , type : 'decision' , label : 'Check Recent' , config : { condition : 'tp.age_days < 7' } } ] ,
1278+ edges : [ ] ,
1279+ } ,
1280+ } ;
1281+
1282+ it ( 'pins that inner loop container against LoopConfigSchema (#5700)' , ( ) => {
1283+ expect ( LoopConfigSchema . safeParse ( nestedTouchpointsLoop ) . success ) . toBe ( true ) ;
1284+ } ) ;
1285+
11581286 it ( 'descends a loop nested inside a loop — same depth semantics as the engine' , ( ) => {
11591287 const fnds = lintFlowPatterns ( loopBodyFlow ( {
1160- nodes : [ {
1161- id : 'loop_touchpoints' , type : 'loop' ,
1162- config : {
1163- collection : '{lead.touchpoints}' , itemVar : 'tp' ,
1164- body : {
1165- nodes : [ { id : 'check_recent' , type : 'decision' , config : { condition : 'tp.age_days < 7' } } ] ,
1166- edges : [ ] ,
1167- } ,
1168- } ,
1169- } ] ,
1288+ nodes : [ { id : 'loop_touchpoints' , type : 'loop' , label : 'Loop Touchpoints' , config : nestedTouchpointsLoop } ] ,
11701289 edges : [ ] ,
11711290 } ) ) . filter ( ( f ) => f . rule === FLOW_INERT_NODE_CONDITION ) ;
11721291 expect ( fnds ) . toHaveLength ( 1 ) ;
@@ -1177,21 +1296,25 @@ describe('#5383 — flow-inert-node-condition descends into a loop body', () =>
11771296 } ) ;
11781297
11791298 it ( 'descends a parallel branch too — the scope names the branch index' , ( ) => {
1299+ const fanConfig = {
1300+ branches : [
1301+ { name : 'owner' , nodes : [ { id : 'gate' , type : 'decision' , config : { condition : 'a == b' } } ] , edges : [ ] } ,
1302+ { name : 'watchers' , nodes : [ { id : 'ping' , type : 'notify' , config : { title : 'Hi {record.name}' } } ] , edges : [ ] } ,
1303+ ] ,
1304+ } ;
1305+ // #5700 container audit, key-level bar (see `refusedKeys`): every key here is
1306+ // one `ParallelConfigSchema` declares. The branch nodes omit the `label`
1307+ // `FlowNodeSchema` requires, which is the file-wide convention #5700 defers —
1308+ // a rejected VALUE, not a key that is no authoring surface at all.
1309+ expect ( refusedKeys ( ParallelConfigSchema . safeParse ( fanConfig ) ) ) . toEqual ( [ ] ) ;
1310+
11801311 const fnds = lintFlowPatterns ( {
11811312 flows : [ {
11821313 name : 'fan_out' ,
11831314 runAs : 'system' ,
11841315 nodes : [
11851316 { id : 'start' , type : 'start' , config : { triggerType : 'schedule' , schedule : 'cron:0 9 * * *' } } ,
1186- {
1187- id : 'fan' , type : 'parallel' ,
1188- config : {
1189- branches : [
1190- { name : 'owner' , nodes : [ { id : 'gate' , type : 'decision' , config : { condition : 'a == b' } } ] , edges : [ ] } ,
1191- { name : 'watchers' , nodes : [ { id : 'ping' , type : 'notify' , config : { title : 'Hi {record.name}' } } ] , edges : [ ] } ,
1192- ] ,
1193- } ,
1194- } ,
1317+ { id : 'fan' , type : 'parallel' , config : fanConfig } ,
11951318 ] ,
11961319 edges : [ { id : 'e1' , source : 'start' , target : 'fan' } ] ,
11971320 } ] ,
@@ -1281,21 +1404,25 @@ describe('#5383 — the branch-routing family reads the region’s own edges', (
12811404 { id : 'g2' , source : 'gate' , target : 'y' , isDefault : true } ,
12821405 ] ,
12831406 } ) ;
1407+ const fanConfig = {
1408+ branches : [
1409+ { name : 'a' , ...branch ( 'lead.score > 50' ) } ,
1410+ { name : 'b' , ...branch ( 'lead.score > 90' ) } ,
1411+ ] ,
1412+ } ;
1413+ // #5700 container audit, key-level bar (see `refusedKeys`). Covers the branch
1414+ // EDGES too: `condition` and `isDefault` are the keys this case turns on, and
1415+ // both are declared — so what it pins is real edge vocabulary, not a shape
1416+ // the schema would refuse.
1417+ expect ( refusedKeys ( ParallelConfigSchema . safeParse ( fanConfig ) ) ) . toEqual ( [ ] ) ;
1418+
12841419 const fnds = lintFlowPatterns ( {
12851420 flows : [ {
12861421 name : 'twin_regions' ,
12871422 runAs : 'system' ,
12881423 nodes : [
12891424 { id : 'start' , type : 'start' , config : { triggerType : 'schedule' , schedule : 'cron:0 9 * * *' } } ,
1290- {
1291- id : 'fan' , type : 'parallel' ,
1292- config : {
1293- branches : [
1294- { name : 'a' , ...branch ( 'lead.score > 50' ) } ,
1295- { name : 'b' , ...branch ( 'lead.score > 90' ) } ,
1296- ] ,
1297- } ,
1298- } ,
1425+ { id : 'fan' , type : 'parallel' , config : fanConfig } ,
12991426 ] ,
13001427 edges : [ { id : 'e1' , source : 'start' , target : 'fan' } ] ,
13011428 } ] ,
@@ -1510,19 +1637,33 @@ describe('lintFlowPatterns — unbounded bulk write (#5482)', () => {
15101637 expect ( fnds [ 0 ] . message ) . toContain ( "every row of 'campaign_member' is deleted" ) ;
15111638 } ) ;
15121639
1513- it ( 'flags an update_record two regions deep' , ( ) => {
1514- const fnds = lintFlowPatterns ( loopBodyFlow ( {
1640+ /**
1641+ * The INNER container for the two-regions-deep case, named so the pin below
1642+ * reads the object the fixture descends into (#5700). The planted defect is
1643+ * the body node's unfiltered `multi: true`; the container around it has to
1644+ * be authorable or the case proves this rule against a `loop` the schema
1645+ * refuses — and the enclosing shell pin cannot see in here, because
1646+ * `FlowNodeSchema` declares `config` as an open record.
1647+ */
1648+ const nestedTouchpointsResetLoop = {
1649+ collection : '{lead.touchpoints}' , iteratorVariable : 'tp' ,
1650+ body : {
15151651 nodes : [ {
1516- id : 'loop_touchpoints' , type : 'loop' , label : 'Loop Touchpoints' ,
1517- config : {
1518- collection : '{lead.touchpoints}' , itemVar : 'tp' ,
1519- body : {
1520- nodes : [ { id : 'reset' , type : 'update_record' , config : { objectName : 'touchpoint' , fields : { done : false } , multi : true } } ] ,
1521- edges : [ ] ,
1522- } ,
1523- } ,
1652+ id : 'reset' , type : 'update_record' , label : 'Reset Touchpoints' ,
1653+ config : { objectName : 'touchpoint' , fields : { done : false } , multi : true } ,
15241654 } ] ,
15251655 edges : [ ] ,
1656+ } ,
1657+ } ;
1658+
1659+ it ( 'pins that inner loop container against LoopConfigSchema (#5700)' , ( ) => {
1660+ expect ( LoopConfigSchema . safeParse ( nestedTouchpointsResetLoop ) . success ) . toBe ( true ) ;
1661+ } ) ;
1662+
1663+ it ( 'flags an update_record two regions deep' , ( ) => {
1664+ const fnds = lintFlowPatterns ( loopBodyFlow ( {
1665+ nodes : [ { id : 'loop_touchpoints' , type : 'loop' , label : 'Loop Touchpoints' , config : nestedTouchpointsResetLoop } ] ,
1666+ edges : [ ] ,
15261667 } ) ) ;
15271668 expect ( fnds ) . toHaveLength ( 1 ) ;
15281669 expect ( fnds [ 0 ] . rule ) . toBe ( FLOW_MULTI_WRITE_UNFILTERED ) ;
0 commit comments