@@ -10,6 +10,7 @@ import {
1010 FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID ,
1111 FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
1212 FLOW_TRIGGER_UNROUTABLE ,
13+ FLOW_SCHEDULE_ORGANIZATION_MISSING ,
1314} from './validate-flow-trigger-readiness.js' ;
1415
1516function recordFlow ( overrides : Record < string , unknown > = { } ) {
@@ -35,6 +36,20 @@ function recordFlow(overrides: Record<string, unknown> = {}) {
3536
3637const candidateObject = { name : 'app_candidate' , label : 'Candidate' , fields : { } } ;
3738
39+ /**
40+ * [#16659] Every time-triggered fixture in this file that is ABOUT some other
41+ * rule now declares an acting organization.
42+ *
43+ * `flow-schedule-organization-missing` fires on any `schedule` /
44+ * `time_relative` flow without one, so a fixture that omits it would carry a
45+ * second finding and stop isolating the rule it exists to pin — the tests below
46+ * assert exact finding LISTS, which is what makes them worth having. ⛔ This is
47+ * not a relaxation: the fixtures that are about the missing key omit it
48+ * deliberately, in the `acting organization (#16659)` block and in the severity
49+ * table.
50+ */
51+ const FIXTURE_ORG = 'org_2mtx1w9d0k4bqf7v' ;
52+
3853describe ( 'validateFlowTriggerReadiness' , ( ) => {
3954 it ( 'passes a correctly wired, explicitly active record flow' , ( ) => {
4055 const findings = validateFlowTriggerReadiness ( {
@@ -118,7 +133,11 @@ describe('validateFlowTriggerReadiness', () => {
118133 name : 'digest' ,
119134 type : 'schedule' ,
120135 nodes : [
121- { id : 'start' , type : 'start' , config : { schedule : { type : 'interval' , intervalMs : 60000 } } } ,
136+ {
137+ id : 'start' ,
138+ type : 'start' ,
139+ config : { schedule : { type : 'interval' , intervalMs : 60000 } , organization : FIXTURE_ORG } ,
140+ } ,
122141 { id : 'end' , type : 'end' } ,
123142 ] ,
124143 edges : [ { id : 'e1' , source : 'start' , target : 'end' } ] ,
@@ -141,6 +160,7 @@ describe('validateFlowTriggerReadiness', () => {
141160 type : 'start' ,
142161 config : {
143162 timeRelative : { object : 'contracts' , dateField : 'end_date' , offsetDays : [ 60 , 30 , 7 ] } ,
163+ organization : FIXTURE_ORG ,
144164 } ,
145165 } ,
146166 { id : 'end' , type : 'end' } ,
@@ -166,6 +186,7 @@ describe('validateFlowTriggerReadiness', () => {
166186 type : 'start' ,
167187 config : {
168188 timeRelative : { object : 'contract' , dateField : 'end_date' , withinDays : 60 } ,
189+ organization : FIXTURE_ORG ,
169190 } ,
170191 } ,
171192 { id : 'end' , type : 'end' } ,
@@ -207,7 +228,7 @@ describe('validateFlowTriggerReadiness', () => {
207228 status : 'active' ,
208229 runAs : 'system' ,
209230 nodes : [
210- { id : 'start' , type : 'start' , config : { timeRelative } } ,
231+ { id : 'start' , type : 'start' , config : { timeRelative, organization : FIXTURE_ORG } } ,
211232 { id : 'end' , type : 'end' } ,
212233 ] ,
213234 edges : [ { id : 'e1' , source : 'start' , target : 'end' } ] ,
@@ -991,11 +1012,17 @@ describe('validateFlowTriggerReadiness', () => {
9911012 // ── #5762 — the family's severity map ────────────────────────────────────
9921013 //
9931014 // The rules in this file were reviewed as ONE family and split on a single
994- // question: is this stack enough to know the flow is dead? Three rules answer
995- // yes and gate; two hedge and advise. The split is the contract, so it is
996- // pinned as a map rather than as five scattered `severity` lines — a later
997- // rule added to this file has to decide which side it is on, and a later edit
998- // that quietly demotes one of the three has to come past this test.
1015+ // question: is this stack enough to know the flow is dead? Four rules answer
1016+ // yes and gate; three hedge and advise. The split is the contract, so it is
1017+ // pinned as a map rather than as scattered `severity` lines — a later rule
1018+ // added to this file has to decide which side it is on, and a later edit that
1019+ // quietly demotes one of the gating ones has to come past this test.
1020+ //
1021+ // [#16659] `flow-schedule-organization-missing` is the one entry whose side is
1022+ // NOT decided by that question: it answers YES and still advises, because an
1023+ // `error` gates `objectstack build` and the repo's own shipped example apps
1024+ // carry time-triggered flows with no authorable repair. Its docblock records
1025+ // that, and this table is where a later promotion has to come past.
9991026 //
10001027 // Every entry is provoked through a real stack, so an id whose criterion stops
10011028 // firing fails here instead of passing vacuously (the empty-verdict trap: an
@@ -1113,6 +1140,25 @@ describe('validateFlowTriggerReadiness', () => {
11131140 flows : [ recordFlow ( ) ] ,
11141141 } ,
11151142 ] ,
1143+ [
1144+ // [#16659] `warning`, and its own docblock records why the corpus, not
1145+ // the verdict, is what holds it there.
1146+ FLOW_SCHEDULE_ORGANIZATION_MISSING ,
1147+ 'warning' ,
1148+ {
1149+ objects : [ { name : 'task' , label : 'Task' , fields : { } } ] ,
1150+ flows : [
1151+ {
1152+ name : 'orgless_digest' ,
1153+ type : 'schedule' ,
1154+ status : 'active' ,
1155+ nodes : [
1156+ { id : 'start' , type : 'start' , config : { schedule : { type : 'cron' , expression : '0 1 * * *' } } } ,
1157+ ] ,
1158+ } ,
1159+ ] ,
1160+ } ,
1161+ ] ,
11161162 ] ;
11171163
11181164 for ( const [ rule , severity , stack ] of provoke ) {
@@ -1159,7 +1205,15 @@ describe('validateFlowTriggerReadiness', () => {
11591205 {
11601206 id : 'start' ,
11611207 type : 'start' ,
1162- config : { timeRelative : { object : 'task' , dateField : 'due_at' , withinDays : 30 } } ,
1208+ // [#16659] The clean fixture gained `organization`: after this
1209+ // card a CORRECT time-triggered flow declares the organization
1210+ // it runs as, so a fixture without one is no longer clean —
1211+ // the trigger would refuse to bind it. ⛔ Not a relaxation of
1212+ // the floor this case guards; the floor moved.
1213+ config : {
1214+ timeRelative : { object : 'task' , dateField : 'due_at' , withinDays : 30 } ,
1215+ organization : 'org_2mtx1w9d0k4bqf7v' ,
1216+ } ,
11631217 } ,
11641218 ] ,
11651219 } ,
@@ -1169,6 +1223,118 @@ describe('validateFlowTriggerReadiness', () => {
11691223 } ) ;
11701224 } ) ;
11711225
1226+ // ─── the acting organization (#16659) ───────────────────────────────────
1227+ //
1228+ // `engine.ts`'s trigger-kind resolver states the invariant: it is shared with
1229+ // `defineStack`'s trigger-capability refusal and with this file, "so the
1230+ // runtime cannot drift from what authoring accepted". A key the two triggers
1231+ // REFUSE to bind without, and that authoring never mentions, is that drift —
1232+ // an author's first signal was a production stderr line at boot.
1233+ describe ( 'acting organization (#16659)' , ( ) => {
1234+ const taskObject = { name : 'task' , label : 'Task' , fields : { } } ;
1235+
1236+ function timeTriggered ( config : Record < string , unknown > , overrides : Record < string , unknown > = { } ) {
1237+ return {
1238+ objects : [ taskObject ] ,
1239+ flows : [
1240+ {
1241+ name : 'digest' ,
1242+ type : 'schedule' ,
1243+ status : 'active' ,
1244+ nodes : [ { id : 'start' , type : 'start' , config } ] ,
1245+ ...overrides ,
1246+ } ,
1247+ ] ,
1248+ } ;
1249+ }
1250+
1251+ const orgFindings = ( stack : Record < string , unknown > ) =>
1252+ validateFlowTriggerReadiness ( stack ) . filter ( ( f ) => f . rule === FLOW_SCHEDULE_ORGANIZATION_MISSING ) ;
1253+
1254+ it ( 'fires on a `schedule` flow that declares none' , ( ) => {
1255+ const findings = orgFindings ( timeTriggered ( { schedule : { type : 'cron' , expression : '0 1 * * *' } } ) ) ;
1256+ expect ( findings ) . toHaveLength ( 1 ) ;
1257+ expect ( findings [ 0 ] . path ) . toBe ( 'flows[0].nodes[0].config.organization' ) ;
1258+ expect ( findings [ 0 ] . message , 'the sentence is the trigger\'s own, so the two cannot drift' ) . toContain (
1259+ 'declares no acting organization' ,
1260+ ) ;
1261+ expect ( findings [ 0 ] . message , 'the refusal names the flow — the ruling requires that' ) . toContain ( 'digest' ) ;
1262+ } ) ;
1263+
1264+ it ( 'fires on a `time_relative` sweep that declares none, and says WHICH kind' , ( ) => {
1265+ const findings = orgFindings (
1266+ timeTriggered ( { timeRelative : { object : 'task' , dateField : 'due_at' , withinDays : 7 } } ) ,
1267+ ) ;
1268+ expect ( findings ) . toHaveLength ( 1 ) ;
1269+ expect (
1270+ findings [ 0 ] . message ,
1271+ 'a sweep must be named as one — the two kinds take the same refusal for different reasons' ,
1272+ ) . toContain ( 'time-relative flow' ) ;
1273+ } ) ;
1274+
1275+ it ( 'names the near-miss spelling the open `config` record accepted and ignored' , ( ) => {
1276+ const findings = orgFindings (
1277+ timeTriggered ( { schedule : { type : 'cron' , expression : '0 1 * * *' } , organizationId : 'org_x' } ) ,
1278+ ) ;
1279+ expect ( findings ) . toHaveLength ( 1 ) ;
1280+ expect (
1281+ findings [ 0 ] . message ,
1282+ 'an author who wrote `organizationId` is told about THEIR spelling, not about "nothing"' ,
1283+ ) . toContain ( 'organizationId' ) ;
1284+ } ) ;
1285+
1286+ it ( 'judges a present-but-unusable value exactly as the trigger does' , ( ) => {
1287+ // ⛔ Not a separate opinion: both read `resolveScheduleOrganization`, so a
1288+ // flow admitted by one and refused by the other is the silent hole again.
1289+ for ( const bad of [ '' , 123 , { id : 'org_x' } , null ] ) {
1290+ expect (
1291+ orgFindings ( timeTriggered ( { schedule : '0 1 * * *' , organization : bad } ) ) ,
1292+ `organization: ${ JSON . stringify ( bad ) } must be judged undeclared` ,
1293+ ) . toHaveLength ( 1 ) ;
1294+ }
1295+ } ) ;
1296+
1297+ it ( 'is silent once the flow declares one' , ( ) => {
1298+ expect (
1299+ orgFindings ( timeTriggered ( { schedule : { type : 'cron' , expression : '0 1 * * *' } , organization : 'org_a' } ) ) ,
1300+ ) . toEqual ( [ ] ) ;
1301+ } ) ;
1302+
1303+ it ( 'never fires on a record_change, api, or manual flow' , ( ) => {
1304+ // Those bindings carry no organization BY CONSTRUCTION — they are fired by
1305+ // a caller who already holds one, and lifting a declared one onto them
1306+ // would let a flow overrule the tenant of the write that triggered it. A
1307+ // rule that asked them for the key would be asking for a defect.
1308+ expect ( orgFindings ( { objects : [ candidateObject ] , flows : [ recordFlow ( { status : 'active' } ) ] } ) ) . toEqual ( [ ] ) ;
1309+ expect (
1310+ orgFindings ( {
1311+ objects : [ taskObject ] ,
1312+ flows : [
1313+ {
1314+ name : 'by_api' ,
1315+ type : 'api' ,
1316+ status : 'active' ,
1317+ nodes : [ { id : 'start' , type : 'start' , config : { } } ] ,
1318+ } ,
1319+ ] ,
1320+ } ) ,
1321+ ) . toEqual ( [ ] ) ;
1322+ expect (
1323+ orgFindings ( {
1324+ objects : [ taskObject ] ,
1325+ flows : [
1326+ {
1327+ name : 'by_hand' ,
1328+ type : 'autolaunched' ,
1329+ status : 'active' ,
1330+ nodes : [ { id : 'start' , type : 'start' , config : { } } ] ,
1331+ } ,
1332+ ] ,
1333+ } ) ,
1334+ ) . toEqual ( [ ] ) ;
1335+ } ) ;
1336+ } ) ;
1337+
11721338 it ( 'handles map-keyed flows/objects and stacks with no flows' , ( ) => {
11731339 expect ( validateFlowTriggerReadiness ( { } ) ) . toEqual ( [ ] ) ;
11741340 const findings = validateFlowTriggerReadiness ( {
0 commit comments