@@ -2210,6 +2210,128 @@ describe('runtime authoring gate on OBJECT writes — the option visibleWhen ver
22102210 } ) ;
22112211} ) ;
22122212
2213+ /**
2214+ * [#22157] The object save door refuses a field option's `visibleWhen` that
2215+ * reads `parent`, as `os build` does.
2216+ *
2217+ * The server's option check (`evaluateOptionVisibility` in ObjectQL) binds
2218+ * `record`, `previous` and the acting user, never `parent`. Measured before
2219+ * this change: on a detail with exactly one `master_detail` (where the
2220+ * field-rule slots DO bind `parent`), an option whose `visibleWhen` read
2221+ * `parent.status` saved through this door with a 200 and landed `active`, and
2222+ * every write that then picked the option faulted and was admitted unchecked.
2223+ *
2224+ * The refusal is in `@objectstack/lint` (the option pass's root verdict); no
2225+ * code here moves. Pinned through the REAL `saveMetaItem`, the header stored
2226+ * in the registry so the write is judged against the universe it lands in:
2227+ *
2228+ * (a) the measured body is refused on an active save — a 422
2229+ * `INVALID_METADATA` carrying the build's located finding — and nothing
2230+ * lands;
2231+ * (b) the same body still saves as a DRAFT (drafts are never gated);
2232+ * (c) control: an option reading `record` saves, and the row lands;
2233+ * (d) the door's issue and the build's finding are the same finding.
2234+ *
2235+ * ⚠️ As in the blocks above: this package reaches `@objectstack/lint` through
2236+ * its built `dist/`, so an edit to the rule is invisible here until
2237+ * `pnpm --filter @objectstack/lint build` has run.
2238+ */
2239+ describe ( 'runtime authoring gate on OBJECT writes — an option visibleWhen reading parent (#22157)' , ( ) => {
2240+ const header = {
2241+ name : 'fx_header' ,
2242+ label : 'Header' ,
2243+ sharingModel : 'private' ,
2244+ fields : { name : { type : 'text' , label : 'Name' } , status : { type : 'text' , label : 'Status' } } ,
2245+ } ;
2246+ const line = ( visibleWhen : unknown ) => ( {
2247+ name : 'fx_line' ,
2248+ label : 'Line' ,
2249+ sharingModel : 'private' ,
2250+ fields : {
2251+ hdr : { type : 'master_detail' , label : 'Header' , reference : 'fx_header' } ,
2252+ x : { type : 'text' , label : 'X' } ,
2253+ tier : {
2254+ type : 'select' ,
2255+ label : 'Tier' ,
2256+ options : [ { label : 'Standard' , value : 'standard' } , { label : 'Gold' , value : 'gold' , visibleWhen } ] ,
2257+ } ,
2258+ } ,
2259+ } ) ;
2260+ /** The card's measured body. */
2261+ const PARENT = "parent.status == 'closed'" ;
2262+ /** Where the build locates it — the option the author edits. */
2263+ const WHERE = "object 'fx_line' · field 'tier' option 'gold' visibleWhen" ;
2264+
2265+ /** A protocol whose live registry holds the header — the stored universe. */
2266+ const hostWithHeader = ( ) => {
2267+ const { engine, rows } = makeStubEngine ( ) ;
2268+ engine . registry . listItems = ( type : string ) => ( type === 'object' ? [ header ] : [ ] ) ;
2269+ const protocol = new ObjectStackProtocolImplementation ( engine , ( ) => new Map ( ) , 'env_test' ) as any ;
2270+ return { protocol, rows } ;
2271+ } ;
2272+ const lineRows = ( rows : Map < string , Row > ) =>
2273+ Array . from ( rows . values ( ) ) . filter ( ( r ) => r . type === 'object' && r . name === 'fx_line' ) ;
2274+ const saveLine = ( protocol : any , item : unknown , extra : Record < string , unknown > = { } ) =>
2275+ protocol . saveMetaItem ( { type : 'object' , name : 'fx_line' , item, ...extra } ) ;
2276+ const buildFindings = ( obj : unknown ) => {
2277+ const stack = { objects : [ header , obj ] } ;
2278+ return runAuthoringRules ( 'build' , { normalized : stack , parsed : stack } )
2279+ . filter ( ( f ) => f . rule === EXPRESSION_INVALID ) ;
2280+ } ;
2281+
2282+ it ( '(a) REFUSES an active save with a 422 carrying the build\'s located finding, and nothing lands' , async ( ) => {
2283+ const { protocol, rows } = hostWithHeader ( ) ;
2284+
2285+ const err = await saveLine ( protocol , line ( PARENT ) ) . catch ( ( e : any ) => e ) ;
2286+
2287+ expect ( err , 'the save resolved — the door still accepts the option predicate' ) . toBeInstanceOf ( Error ) ;
2288+ expect ( { code : err . code , status : err . status } ) . toEqual ( { code : 'INVALID_METADATA' , status : 422 } ) ;
2289+ expect ( err . rulesRun ) . toContain ( 'validateStackExpressions' ) ;
2290+ const issues = err . issues . filter ( ( i : any ) => i . rule === EXPRESSION_INVALID ) ;
2291+ expect ( issues , `issues: ${ JSON . stringify ( err . issues ) } ` ) . toHaveLength ( 1 ) ;
2292+ expect ( issues [ 0 ] . path ) . toBe ( WHERE ) ;
2293+ expect ( issues [ 0 ] . severity ) . toBe ( 'error' ) ;
2294+ // The named subject: the option, the field and the root.
2295+ expect ( issues [ 0 ] . message ) . toContain ( "option 'gold' on field 'tier' reads `parent`" ) ;
2296+ expect ( lineRows ( rows ) ) . toEqual ( [ ] ) ;
2297+ } ) ;
2298+
2299+ it ( '(b) the same body still saves as a DRAFT — drafts are never gated' , async ( ) => {
2300+ const { protocol, rows } = hostWithHeader ( ) ;
2301+
2302+ await expect ( saveLine ( protocol , line ( PARENT ) , { mode : 'draft' } ) ) . resolves . toMatchObject ( { success : true } ) ;
2303+
2304+ expect ( lineRows ( rows ) . map ( ( r ) => r . state ) ) . toEqual ( [ 'draft' ] ) ;
2305+ } ) ;
2306+
2307+ it ( '(c) control: an option reading `record` saves, and the row lands' , async ( ) => {
2308+ const { protocol, rows } = hostWithHeader ( ) ;
2309+
2310+ const result = await saveLine ( protocol , line ( "record.x == 'a'" ) ) ;
2311+
2312+ expect ( result . success ) . toBe ( true ) ;
2313+ expect ( lineRows ( rows ) . map ( ( r ) => r . state ) ) . toEqual ( [ 'active' ] ) ;
2314+ } ) ;
2315+
2316+ it ( '(d) the door and `os build` give the SAME finding' , async ( ) => {
2317+ const { protocol } = hostWithHeader ( ) ;
2318+ const err = await saveLine ( protocol , line ( PARENT ) ) . catch ( ( e : any ) => e ) ;
2319+ const atDoor = ( err . issues ?? [ ] ) . filter ( ( i : any ) => i . rule === EXPRESSION_INVALID ) ;
2320+
2321+ const atBuild = buildFindings ( line ( PARENT ) ) ;
2322+
2323+ // Non-vacuous on both sides.
2324+ expect ( atBuild ) . toHaveLength ( 1 ) ;
2325+ expect ( atBuild [ 0 ] ! . severity ) . toBe ( 'error' ) ;
2326+ expect ( atDoor ) . toHaveLength ( 1 ) ;
2327+ for ( const key of [ 'rule' , 'where' , 'path' , 'message' , 'hint' ] as const ) {
2328+ expect ( atDoor [ 0 ] [ key ] , `door and build disagree on '${ key } '` ) . toBe ( atBuild [ 0 ] ! [ key ] ) ;
2329+ }
2330+ // And the control is clean at the build too, not just at the door.
2331+ expect ( buildFindings ( line ( "record.x == 'a'" ) ) ) . toEqual ( [ ] ) ;
2332+ } ) ;
2333+ } ) ;
2334+
22132335/**
22142336 * [#22118] The object save door judges a stored sibling's finding against the
22152337 * STORED universe — the registry's objects WITH the written object's stored
0 commit comments