@@ -255,6 +255,148 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => {
255255 } ) ;
256256} ) ;
257257
258+ // ─────────────────────────────────────────────────────────────────────
259+ // #7815 — WHICH LAYER a site is on, when the caller does not say.
260+ //
261+ // The rule above is correct for the layer it is told. What it was told at the
262+ // runtime publish gate was the `'runtime'` default for EVERY view, including
263+ // schema-bound metadata forms — so a correctly `data.`-rooted form drew the
264+ // advisory telling its author to write `record.`. These cases pin the
265+ // derivation itself; `runtime-gate.test.ts` pins it at the door it was wrong at.
266+ // ─────────────────────────────────────────────────────────────────────
267+
268+ describe ( 'the layer a site declares for itself (#7815)' , ( ) => {
269+ /**
270+ * A schema-bound metadata form, with the data source on the CONTAINER (the
271+ * `self` rung of the `formViewSites` ladder).
272+ */
273+ const metaForm = ( predicate : string ) => ( {
274+ views : [ {
275+ name : 'field_editor' ,
276+ data : { provider : 'schema' , schemaId : 'field' } ,
277+ sections : [ { fields : [ { field : 'notes' , visibleWhen : predicate } ] } ] ,
278+ } ] ,
279+ } ) ;
280+
281+ /** The same predicate on a plain runtime view — the negative control. */
282+ const runtimeForm = ( predicate : string ) => ( {
283+ views : [ { name : 'task_form' , sections : [ { fields : [ { field : 'notes' , visibleWhen : predicate } ] } ] } ] ,
284+ } ) ;
285+
286+ it ( 'a `data.`-rooted predicate on a schema-bound form is CORRECT — no advisory' , ( ) => {
287+ // The finding this card is about. `data` IS the root that surface binds.
288+ expect ( validateVisibilityPredicates ( metaForm ( "data.type == 'grid'" ) ) ) . toEqual ( [ ] ) ;
289+ } ) ;
290+
291+ it ( 'the same predicate on a plain runtime view still draws it — the rule is live' , ( ) => {
292+ // The negative control that keeps the case above from being a walk that
293+ // simply went blind: one character of difference in the fixture (the
294+ // `data:` source), opposite verdicts.
295+ const findings = validateVisibilityPredicates ( runtimeForm ( "data.type == 'grid'" ) ) ;
296+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ VISIBILITY_ROOT_MISLAYERED ] ) ;
297+ expect ( findings [ 0 ] . severity ) . toBe ( 'warning' ) ;
298+ } ) ;
299+
300+ it ( 'a `record.`-rooted predicate on a schema-bound form draws it the OTHER way' , ( ) => {
301+ // ADR-0089 D3 is bidirectional and this direction was unreachable at the
302+ // runtime gate: told `'runtime'`, the rule forbids `data.` and says nothing
303+ // about `record.`, so a form predicate that never matches published silent.
304+ // No new behaviour — this is the metadata-layer arm the rule already had.
305+ const findings = validateVisibilityPredicates ( metaForm ( "record.type == 'grid'" ) ) ;
306+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ VISIBILITY_ROOT_MISLAYERED ] ) ;
307+ expect ( findings [ 0 ] . severity ) . toBe ( 'warning' ) ;
308+ expect ( findings [ 0 ] . message ) . toContain ( 'record.' ) ;
309+ expect ( findings [ 0 ] . hint ) . toContain ( 'data' ) ;
310+ } ) ;
311+
312+ it ( 'derives per SITE, not per stack — one entry can carry both kinds' , ( ) => {
313+ // `formViews.<key>` sub-containers each declare their own `data`, so a
314+ // stack-level layer would be wrong for one of these two no matter which
315+ // value it took.
316+ const stack = {
317+ views : [ {
318+ name : 'mixed' ,
319+ object : 'account' ,
320+ formViews : {
321+ meta : {
322+ data : { provider : 'schema' , schemaId : 'field' } ,
323+ sections : [ { fields : [ { field : 'a' , visibleWhen : "data.type == 'grid'" } ] } ] ,
324+ } ,
325+ live : {
326+ sections : [ { fields : [ { field : 'b' , visibleWhen : "data.type == 'grid'" } ] } ] ,
327+ } ,
328+ } ,
329+ } ] ,
330+ } ;
331+ const findings = validateVisibilityPredicates ( stack ) ;
332+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ VISIBILITY_ROOT_MISLAYERED ] ) ;
333+ expect ( findings [ 0 ] . path ) . toBe ( 'views[0].formViews.live.sections[0].fields[0]' ) ;
334+ } ) ;
335+
336+ it ( '`opts.layer` still governs every site that declares no data source' , ( ) => {
337+ // The file-aware caller's contract is unchanged: a `*.form.ts` whose form
338+ // carries no `data: { provider: 'schema' }` is still only reachable through
339+ // the option, and a page component always is.
340+ expect ( validateVisibilityPredicates ( runtimeForm ( "data.type == 'grid'" ) , { layer : 'metadata' } ) )
341+ . toEqual ( [ ] ) ;
342+ expect (
343+ validateVisibilityPredicates ( runtimeForm ( "record.type == 'grid'" ) , { layer : 'metadata' } )
344+ . map ( ( f ) => f . rule ) ,
345+ ) . toEqual ( [ VISIBILITY_ROOT_MISLAYERED ] ) ;
346+
347+ const page = ( predicate : string ) => ( {
348+ pages : [ { name : 'p' , regions : [ { components : [ { type : 'element:text' , visibleWhen : predicate } ] } ] } ] ,
349+ } ) ;
350+ expect ( validateVisibilityPredicates ( page ( "data.x == 'y'" ) ) . map ( ( f ) => f . rule ) )
351+ . toEqual ( [ VISIBILITY_ROOT_MISLAYERED ] ) ;
352+ expect ( validateVisibilityPredicates ( page ( "data.x == 'y'" ) , { layer : 'metadata' } ) ) . toEqual ( [ ] ) ;
353+ } ) ;
354+
355+ it ( 'an unresolvable `schemaId` is still a schema-bound SURFACE' , ( ) => {
356+ // The layer follows the data SOURCE, not whether the id resolves — the same
357+ // boundary `literalRhs` draws off the same `schemaIdOf` call, so the two
358+ // cannot disagree about which surface they are on.
359+ expect ( validateVisibilityPredicates ( {
360+ views : [ {
361+ name : 'f' ,
362+ data : { provider : 'schema' , schemaId : 'no_such_schema' } ,
363+ sections : [ { fields : [ { field : 'x' , visibleWhen : "data.a == 'b'" } ] } ] ,
364+ } ] ,
365+ } ) ) . toEqual ( [ ] ) ;
366+ } ) ;
367+
368+ it ( 'a non-schema provider is NOT a metadata form' , ( ) => {
369+ // `schemaIdOf` reads `provider === 'schema'` only; an ObjectQL-backed data
370+ // source is a runtime surface and keeps the runtime direction.
371+ const findings = validateVisibilityPredicates ( {
372+ views : [ {
373+ name : 'f' ,
374+ data : { provider : 'object' , object : 'account' } ,
375+ sections : [ { fields : [ { field : 'x' , visibleWhen : "data.a == 'b'" } ] } ] ,
376+ } ] ,
377+ } ) ;
378+ expect ( findings . map ( ( f ) => f . rule ) ) . toEqual ( [ VISIBILITY_ROOT_MISLAYERED ] ) ;
379+ } ) ;
380+
381+ it ( 'moves NO finding across the error/advisory boundary' , ( ) => {
382+ // The acceptance guarantee, asserted rather than argued: the derivation may
383+ // only ever change which ADVISORIES an author hears. Every fixture here is
384+ // schema-bound — the set the derivation moves — and every `error` on it is
385+ // the same id, at the same path, that the `'runtime'` reading produced.
386+ const errorsOf = ( predicate : string ) =>
387+ validateVisibilityPredicates ( metaForm ( predicate ) )
388+ . filter ( ( f ) => f . severity === 'error' )
389+ . map ( ( f ) => f . rule )
390+ . sort ( ) ;
391+
392+ expect ( errorsOf ( "data.type == 'grid'" ) ) . toEqual ( [ ] ) ;
393+ expect ( errorsOf ( "record.type == 'grid'" ) ) . toEqual ( [ ] ) ;
394+ expect ( errorsOf ( 'status == active' ) ) . toEqual ( [ VISIBILITY_BARE_IDENTIFIER ] ) ;
395+ expect ( errorsOf ( 'active == data.type' ) ) . toEqual ( [ VISIBILITY_BARE_IDENTIFIER ] ) ;
396+ expect ( errorsOf ( "country === 'USA'" ) ) . toEqual ( [ VISIBILITY_PREDICATE_SYNTAX ] ) ;
397+ } ) ;
398+ } ) ;
399+
258400// ─────────────────────────────────────────────────────────────────────
259401// `visibility-bare-identifier` — #6128 (the build-time half of #5149's
260402// 2026-08-06 ruling; the runtime warn-once half landed as objectui#3541).
@@ -475,8 +617,15 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => {
475617 it ( 'proves the scanner still sees — the stand-down is per IDENTIFIER' , ( ) => {
476618 // Every one of these is the same schema-bound form, so a walk that had
477619 // gone blind would report nothing here either.
620+ //
621+ // #7815: this pin used to read `record.status`, which is what the rule
622+ // said here while the caller's `'runtime'` default decided the layer for a
623+ // form that binds no `record` at all. The refusal is unchanged — same id,
624+ // same `error`, same one finding; only the ROOT it prescribes moved to the
625+ // one this surface actually binds. (That the pin had to change is the
626+ // measurement: an assertion was holding the wrong prescription in place.)
478627 expect ( bareFindings ( metaForm ( 'status == active' ) ) . map ( ( f ) => f . hint ) )
479- . toEqual ( [ expect . stringContaining ( '`record .status`' ) ] ) ;
628+ . toEqual ( [ expect . stringContaining ( '`data .status`' ) ] ) ;
480629 expect ( bareFindings ( metaForm ( 'active == data.type' ) ) ) . toHaveLength ( 1 ) ;
481630 expect ( bareFindings ( metaForm ( 'data.type == active && active' ) ) ) . toHaveLength ( 1 ) ;
482631 // A macro body produces no replacement finding, so nothing stands down.
0 commit comments