@@ -11,7 +11,22 @@ import {
1111 FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE ,
1212 FLOW_TRIGGER_UNROUTABLE ,
1313 FLOW_API_TRIGGER_SECRET_MISSING ,
14+ validateFlowApiTriggerSecret ,
1415} from './validate-flow-trigger-readiness.js' ;
16+ import { AUTHORING_COMMANDS , AUTHORING_RULES , runAuthoringRules } from './authoring-rules.js' ;
17+ import { runRuntimeAuthoringRules } from './runtime-gate.js' ;
18+
19+ /**
20+ * [#20553] The flow-trigger family as the CLI table runs it: two registry
21+ * entries over one file — `validateFlowTriggerReadiness` and, split out because
22+ * it is CLI-only until #20611, `validateFlowApiTriggerSecret`. Cases that are
23+ * about the WHOLE family's verdict on a stack (the severity map, the clean-stack
24+ * floor, the api-secret cases' exhaustive assertions) judge through this.
25+ */
26+ const cliFlowFamily = ( stack : Record < string , unknown > ) => [
27+ ...validateFlowTriggerReadiness ( stack ) ,
28+ ...validateFlowApiTriggerSecret ( stack ) ,
29+ ] ;
1530
1631function recordFlow ( overrides : Record < string , unknown > = { } ) {
1732 return {
@@ -1035,7 +1050,7 @@ describe('validateFlowTriggerReadiness', () => {
10351050 } ;
10361051 }
10371052 const judge = ( flow : Record < string , unknown > ) =>
1038- validateFlowTriggerReadiness ( { objects : [ candidateObject ] , flows : [ flow ] } ) ;
1053+ cliFlowFamily ( { objects : [ candidateObject ] , flows : [ flow ] } ) ;
10391054
10401055 it ( 'fails a secretless `type: api` flow, naming the flow and the key' , ( ) => {
10411056 const findings = judge ( apiFlow ( { hookId : 'intake' } ) ) ;
@@ -1144,6 +1159,66 @@ describe('validateFlowTriggerReadiness', () => {
11441159 it ( 'the id is the published slug' , ( ) => {
11451160 expect ( FLOW_API_TRIGGER_SECRET_MISSING ) . toBe ( 'flow-api-trigger-secret-missing' ) ;
11461161 } ) ;
1162+
1163+ // [#20611] The id is CLI-only until the runtime publish gate judges the
1164+ // carried-forward body: a `/meta` save is gated BEFORE `saveMetaItem`
1165+ // restores the `config.secret` the flow read path withholds (#20552), so a
1166+ // signed flow's GET → edit → PUT would reach the gate secretless. Each side
1167+ // of the wall is pinned, and the gate pin carries its own positive control.
1168+ describe ( 'one rule id on ONE side of the runtime wall — CLI-only until #20611' , ( ) => {
1169+ const secretless = apiFlow ( { hookId : 'intake' } ) ;
1170+ const stack = { objects : [ candidateObject ] , flows : [ secretless ] } ;
1171+
1172+ it ( 'the split is whole: validateFlowTriggerReadiness alone no longer emits the id' , ( ) => {
1173+ expect ( validateFlowTriggerReadiness ( stack ) . map ( ( f ) => f . rule ) ) . not . toContain (
1174+ FLOW_API_TRIGGER_SECRET_MISSING ,
1175+ ) ;
1176+ expect ( validateFlowApiTriggerSecret ( stack ) . map ( ( f ) => f . rule ) ) . toEqual ( [ FLOW_API_TRIGGER_SECRET_MISSING ] ) ;
1177+ } ) ;
1178+
1179+ it ( 'its registry entry gates all three commands on the cli surface only, with a reason' , ( ) => {
1180+ const entry = AUTHORING_RULES . find ( ( r ) => r . name === 'validateFlowApiTriggerSecret' ) ;
1181+ expect ( entry ) . toBeDefined ( ) ;
1182+ expect ( entry ! . tier ) . toBe ( 'gating' ) ;
1183+ expect ( [ ...entry ! . commands ] . sort ( ) ) . toEqual ( [ ...AUTHORING_COMMANDS ] . sort ( ) ) ;
1184+ expect ( entry ! . surfaces ) . toEqual ( [ 'cli' ] ) ;
1185+ expect ( entry ! . runtimeTypes ) . toBeUndefined ( ) ;
1186+ expect ( ( entry ! . surfaceReason ?? '' ) . trim ( ) . length ) . toBeGreaterThan ( 40 ) ;
1187+ // …while the family's shared entry still crosses the wall.
1188+ expect ( AUTHORING_RULES . find ( ( r ) => r . name === 'validateFlowTriggerReadiness' ) ! . surfaces ) . toContain (
1189+ 'runtime-publish' ,
1190+ ) ;
1191+ } ) ;
1192+
1193+ it . each ( [ ...AUTHORING_COMMANDS ] ) ( 'os %s still refuses a secretless api flow through the table' , ( command ) => {
1194+ const hits = runAuthoringRules ( command , { normalized : stack , parsed : stack } ) . filter (
1195+ ( f ) => f . rule === FLOW_API_TRIGGER_SECRET_MISSING ,
1196+ ) ;
1197+ expect ( hits . map ( ( f ) => [ f . severity , f . path ] ) ) . toEqual ( [ [ 'error' , 'flows[0].nodes[0].config.secret' ] ] ) ;
1198+ } ) ;
1199+
1200+ it ( 'the runtime publish gate does not emit it — and still runs the rest of the family' , ( ) => {
1201+ const gated = runRuntimeAuthoringRules ( { type : 'flow' , item : secretless } ) ;
1202+ expect ( gated . rulesRun ) . toContain ( 'validateFlowTriggerReadiness' ) ;
1203+ expect ( gated . rulesRun ) . not . toContain ( 'validateFlowApiTriggerSecret' ) ;
1204+ expect ( [ ...gated . errors , ...gated . advisories ] . map ( ( f ) => f . rule ) ) . not . toContain (
1205+ FLOW_API_TRIGGER_SECRET_MISSING ,
1206+ ) ;
1207+ // Positive control: the same door still refuses a flow the family
1208+ // proves dead, so the absence above is the wall, not a gate that ran
1209+ // nothing.
1210+ const dead = runRuntimeAuthoringRules ( {
1211+ type : 'flow' ,
1212+ item : {
1213+ name : 'declared_dead' ,
1214+ type : 'record_change' ,
1215+ status : 'active' ,
1216+ nodes : [ { id : 'start' , type : 'start' , config : { objectName : 'app_candidate' , triggerType : 'onCreate' } } ] ,
1217+ } ,
1218+ } ) ;
1219+ expect ( dead . errors . map ( ( f ) => f . rule ) ) . toContain ( FLOW_TRIGGER_UNROUTABLE ) ;
1220+ } ) ;
1221+ } ) ;
11471222 } ) ;
11481223
11491224 // ── #5762 — the family's severity map ────────────────────────────────────
@@ -1297,7 +1372,7 @@ describe('validateFlowTriggerReadiness', () => {
12971372
12981373 for ( const [ rule , severity , stack ] of provoke ) {
12991374 it ( `${ rule } is ${ severity } ` , ( ) => {
1300- const matching = validateFlowTriggerReadiness ( stack ) . filter ( ( f ) => f . rule === rule ) ;
1375+ const matching = cliFlowFamily ( stack ) . filter ( ( f ) => f . rule === rule ) ;
13011376 // Non-vacuous first: the fixture really does provoke this id.
13021377 expect ( matching . length , `${ rule } was not provoked by its own fixture` ) . toBeGreaterThan ( 0 ) ;
13031378 for ( const f of matching ) expect ( f . severity ) . toBe ( severity ) ;
@@ -1310,7 +1385,7 @@ describe('validateFlowTriggerReadiness', () => {
13101385 // makes a gate read as a bug — and the cross-package sentence is exactly
13111386 // what earns `flow-trigger-unknown-object` its warning.
13121387 for ( const [ rule , severity , stack ] of provoke ) {
1313- for ( const f of validateFlowTriggerReadiness ( stack ) . filter ( ( x ) => x . rule === rule ) ) {
1388+ for ( const f of cliFlowFamily ( stack ) . filter ( ( x ) => x . rule === rule ) ) {
13141389 if ( severity === 'warning' && rule === FLOW_TRIGGER_UNKNOWN_OBJECT ) {
13151390 expect ( f . hint , rule ) . toMatch ( / a n o t h e r i n s t a l l e d p a c k a g e / ) ;
13161391 }
@@ -1327,7 +1402,7 @@ describe('validateFlowTriggerReadiness', () => {
13271402 // correct flow fail. Without this, "everything is an error" would pass
13281403 // every assertion above.
13291404 expect (
1330- validateFlowTriggerReadiness ( {
1405+ cliFlowFamily ( {
13311406 objects : [ candidateObject , { name : 'task' , label : 'Task' , fields : { } } ] ,
13321407 flows : [
13331408 recordFlow ( { status : 'active' } ) ,
0 commit comments