Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/6038-production-predicate-fault-warning.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
---
'@object-ui/core': patch
'@object-ui/react': patch
'@object-ui/components': patch
---

A node-gate visibility predicate that FAULTS now says so in a production build, once per
distinct predicate source (objectui#6038, maintainer ruling 2026-08-25, option B: "the
silence is no longer an accepted property"). Observability only — no verdict moves.

`SchemaRenderer`'s visibility chain is fail-open: a predicate that cannot be evaluated
resolves to the same answer as one that said yes, so a gate that stops biting looks
exactly like a gate the author got right. The diagnostic that names it (objectui#5454 /
objectui#5687) sat behind a `__DEV__` short-circuit, because the only fault-detection
channel available was `throwOnError`, and on the CEL branch `evaluateCelCondition`
implements that by evaluating **twice** — too expensive to ship for every predicate of
every node.

**What production actually printed before, measured per dialect on the built evaluator**
— the card's premise held for one dialect of three, and the other two failed in opposite
directions:

| dialect | production console, before |
|---|---|
| bare string | **nothing** |
| `{ dialect: 'cel' }` envelope | one generic line, deduped per source |
| `${…}` template | one generic line **per evaluation**, never deduped |

So the dialect objectstack#11254 measured a live gate breaking on was the silent one,
while the template dialect was the console flood the ruling's rate-limit clause exists to
prevent.

**The fix reports the fault the evaluator already detected, at the same number of engine
calls.** `EvaluationOptions.onFault` is a new passback on `@object-ui/core`'s
`ExpressionEvaluator`: every fault site is already inside a `catch`, or already holds the
canonical engine's failure reason, so nothing is evaluated twice. It mirrors, one layer
up, the seam `FieldPredicateDiagnostic` already documents (`warn: false` plus a reason
passback), and supplying it transfers reporting to the caller so one fault stays one
line. Pinned: the CEL branch performs the same number of record reads with the passback
as without it, and strictly fewer than the `throwOnError` probe.

`SchemaRenderer` passes it in production and reports through the **same** reporter the dev
branch uses — same message, same severity, same dedupe `Set`, same key. Development and
production now print the identical line for the identical fault; the `__DEV__` gate no
longer decides *whether* a fault is reported, only *how* it is detected.

`page:tabs` item-level `visibleWhen` (`@object-ui/components`) is covered by the same
reporter and the same rate limit. It swallowed the identical fault under a different
helper, and it was the worse of the two: the node gate at least reported in development,
while a faulting item predicate was silent in *both* builds on a gate whose false verdict
removes an entire tab, header and panel.

**Rate limit:** deduped per (node type, gate key, predicate source) — never per render and
never per node instance. A two-hundred-row list of one broken predicate is one line; a
second distinct predicate source still gets its own line. Both halves are pinned, because
a test that asserts only "a warning was emitted" is equally green on an implementation
that emitted fifty, and one that asserts only "exactly one" is equally green on an
implementation that suppresses everything.

**Not changed by this card, deliberately:** the fail-open semantics themselves; the
objectui#5687 adapter-only `data.*` report, which stays development-only under its own
2026-08-22 ruling (that path is not a fault — the predicate evaluated perfectly, against
the wrong object); and the `/forms/:name` scope wiring of objectui#6262, which lands in
its own PR.

`reportUnresolvableVisibilityPredicate`, `formatUnresolvableVisibilityMessage`,
`UNRESOLVABLE_VISIBILITY_PREFIX` and `__resetVisibilityPredicateWarnings` are now exported
from `@object-ui/react` so every surface that evaluates a node `visibleWhen` shares one
reporter and one rate limit — a second copy would mean a second dedupe `Set`, and one
authored predicate would be entitled to one line per package instead of one line.
24 changes: 24 additions & 0 deletions content/docs/guide/metadata-diagnostics.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,30 @@ fires while its field stays visible is the classic symptom — open the
browser console and the broken predicate identifies itself (most often a
bare field name where `record.<field>` was meant).

The same is now true of a **component node's own gate** — `visibleWhen` on a
page component (and its `visible` / `visibleOn` / `visibility` / `hidden` /
`hiddenOn` siblings), plus a `page:tabs` item's `visibleWhen`. These used to
report in a development build only, so a gate that stopped biting in
production left nothing on the console at all. They now warn in **both**
builds, with the node type, the node id, the gate key, the predicate source
and the engine's reason:

```text
[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1")
visibleWhen: "nosuchroot.status == 'draft'"
Reason: Failed to evaluate expression "nosuchroot.status == 'draft'": nosuchroot is not defined
The node was treated as its safe default, which on this surface means the
gate did NOT bite - a predicate that cannot be evaluated reads on screen
exactly like one that said yes.
```

The line is **rate limited to one per distinct predicate source**, so a broken
predicate rendered down two hundred rows of a list is one line, not two
hundred — while a second, differently-broken predicate still gets its own.
The verdict is unchanged in every case: this is a diagnostic about a
predicate, not a change to what the gate decides. A node gate that fails open
renders exactly as it always did; the difference is that it now says so.

### 4. Governance overview page

`/apps/<app>/metadata/_diagnostics` — a single sortable table of every
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6038, census site 2 — a `page:tabs` item's `visibleWhen` that FAULTS
* is reported, through the same reporter and the same rate limit as the node
* gate in `SchemaRenderer`.
*
* ## Why this file exists at all
*
* The card is written about `evaluateVisibilityPredicate`'s `__DEV__`
* short-circuit, and the dispatch's census clause is explicit that the shape —
* "a predicate evaluation caught and swallowed" — is what has to be covered,
* not the symbol. `PageTabsRenderer.isItemVisible` is that shape under another
* name: it calls the same `evaluateCondition`, on the same canonical key
* (`visibleWhen`), with the same fail-open contract its own comment declares
* ("the same semantics SchemaRenderer applies to component-level
* `visibleWhen`").
*
* It was in fact the WORSE of the two. The node gate at least reported in a
* development build; an item-level `visibleWhen` that faulted here was silent
* in BOTH builds — and its false verdict removes an entire tab, header and
* panel, rather than one block. A tab that quietly stops disappearing (or
* quietly stops appearing) is the failure an author is least likely to notice,
* because a tab strip looks correct in every arrangement.
*
* ## One reporter, one rate limit — not one per package
*
* The report goes through `reportUnresolvableVisibilityPredicate`, exported
* from `@object-ui/react` for this card. A local copy would mean a second
* dedupe `Set`, and one authored predicate would then be entitled to one line
* per package instead of one line. The last case here is what pins that.
*
* ## Reverse verification (direction predicted BEFORE running)
*
* Dropping the `onFault` option from `isItemVisible` turns RED exactly the
* report cases and leaves every VERDICT case green — the tabs render
* identically either way, which is this card's observability-only constraint
* restated on this surface.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import React from 'react';
import {
SchemaRenderer,
UNRESOLVABLE_VISIBILITY_PREFIX,
__resetVisibilityPredicateWarnings,
} from '@object-ui/react';
import '../renderers';

const tabsSchema = (items: any[]) => ({ type: 'page:tabs', id: 'tabs', items });

/** Faults on every dialect this surface accepts (measured on the built evaluator). */
const FAULT_BARE = 'nosuchroot.x > 1';
const FAULT_BARE_2 = 'anotherbadroot.y == 3';

type WarnSpy = { mock: { calls: unknown[][] } };
const spyWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {});
const reports = (warn: WarnSpy): string[] =>
warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(UNRESOLVABLE_VISIBILITY_PREFIX));
const allWarnings = (warn: WarnSpy): string[] => warn.mock.calls.map((c) => String(c[0]));

beforeEach(() => {
__resetVisibilityPredicateWarnings();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

describe('objectui#6038 — a faulting `page:tabs` item predicate is reported', () => {
it('POSITIVE CONTROL: the spy observes a line carrying the prefix', () => {
// Without it, the `toHaveLength(0)` cases below are equally green on a
// capture that observes nothing.
const warn = spyWarn();
console.warn(`${UNRESOLVABLE_VISIBILITY_PREFIX} - synthetic control line`);
expect(reports(warn)).toHaveLength(1);
});

it('DEGENERATE CONTROL: unrelated console output does not satisfy the pin', () => {
const warn = spyWarn();
console.warn('[object-ui] an entirely unrelated warning');
expect(allWarnings(warn)).toHaveLength(1);
expect(reports(warn)).toHaveLength(0);
});

it('THE acceptance criterion: a faulting item `visibleWhen` warns, and the tab still renders', () => {
const warn = spyWarn();
const { getByText } = render(
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: [] },
{ label: 'Contracts', value: 'contracts', visibleWhen: FAULT_BARE, children: [] },
])}
/>,
);
// VERDICT UNCHANGED — fail-open, so the tab is still there. This card does
// not get to move that; it only gets to say so.
expect(getByText('Contracts')).toBeTruthy();

const lines = reports(warn);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('page:tabs');
expect(lines[0]).toContain('visibleWhen');
expect(lines[0]).toContain(FAULT_BARE);
expect(lines[0]).toContain('Reason:');
});

it('a HEALTHY item predicate stays silent, on both verdicts', () => {
// The half that makes the loud half mean something: a false predicate is a
// verdict, not a fault, and must print nothing.
const warn = spyWarn();
// THREE items, two of them surviving: `alwaysShowStrip` defaults to false,
// so a strip down to a single tab hides its header entirely and renders the
// panel bare — the assertion below would then be measuring that rule
// instead of this one. (Measured: this case first failed on exactly that.)
const { getByText, queryByText } = render(
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', visibleWhen: '1 == 1', children: [] },
{ label: 'Related', value: 'related', children: [] },
{ label: 'Contracts', value: 'contracts', visibleWhen: '1 == 2', children: [] },
])}
/>,
);
expect(getByText('Details')).toBeTruthy();
expect(getByText('Related')).toBeTruthy();
expect(queryByText('Contracts')).toBeNull();
expect(allWarnings(warn)).toHaveLength(0);
});

it('deduped per predicate SOURCE: eight tabs sharing one broken predicate produce ONE line', () => {
// The "not per call-site instance" half — eight distinct items, eight
// evaluations, one authored mistake.
const warn = spyWarn();
render(
<SchemaRenderer
schema={tabsSchema(
Array.from({ length: 8 }, (_, i) => ({
label: `Tab ${i}`,
value: `t${i}`,
visibleWhen: FAULT_BARE,
children: [],
})),
)}
/>,
);
expect(reports(warn)).toHaveLength(1);
expect(allWarnings(warn)).toHaveLength(1);
});

it('a SECOND distinct predicate source still warns — a dedupe that suppressed everything would look identical', () => {
const warn = spyWarn();
render(
<SchemaRenderer
schema={tabsSchema([
{ label: 'A', value: 'a', visibleWhen: FAULT_BARE, children: [] },
{ label: 'B', value: 'b', visibleWhen: FAULT_BARE_2, children: [] },
])}
/>,
);
const lines = reports(warn);
expect(lines).toHaveLength(2);
expect(lines.some((l) => l.includes(FAULT_BARE))).toBe(true);
expect(lines.some((l) => l.includes(FAULT_BARE_2))).toBe(true);
});

it('ONE rate limit across packages: the node gate and the tab gate share a dedupe `Set`', () => {
// The property the shared export exists for. The SAME predicate source on
// the SAME node type must not be entitled to a second line just because a
// second package evaluated it. Here the tab strip reports first; a node
// gate of type `page:tabs` carrying the same `visibleWhen` source then
// finds the entry already present.
//
// (A different node TYPE legitimately reports again — the key is
// (type, key, source), and two types are two places an author has to go
// and fix. This case holds the type fixed, which is what isolates the
// cross-package question from the key-shape question.)
const warn = spyWarn();
render(
<SchemaRenderer
schema={tabsSchema([{ label: 'A', value: 'a', visibleWhen: FAULT_BARE, children: [] }])}
/>,
);
expect(reports(warn)).toHaveLength(1);
cleanup();
render(<SchemaRenderer schema={{ type: 'page:tabs', id: 'other', visibleWhen: FAULT_BARE, items: [] } as any} />);
expect(reports(warn)).toHaveLength(1);
});
});
20 changes: 17 additions & 3 deletions packages/components/src/renderers/layout/containers.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,7 @@ import React from 'react';
import { ComponentRegistry, ExpressionEvaluator, evalRowPredicate, getRecordDisplayName, toPredicateRecord } from '@object-ui/core';
import type { ComponentInput } from '@object-ui/core';
import { actionRendersAt } from '@object-ui/types';
import { useRecordContext, useAction, useCapabilityGate, usePredicateScope, usePageVariables, useInlineEdit, useActionTextLocalizer } from '@object-ui/react';
import { useRecordContext, useAction, useCapabilityGate, usePredicateScope, usePageVariables, useInlineEdit, useActionTextLocalizer, reportUnresolvableVisibilityPredicate } from '@object-ui/react';
import { renderChildren, cn } from '../../lib/utils';
import { LazyIcon } from '../../lib/lazy-icon';
import { RelatedCountStore, useRelatedCountVersion } from '../../hooks/related-count-store';
Expand DownExpand Up@@ -456,8 +456,22 @@ const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
page: pageVariables,
});
// evaluateCondition is fail-open (unparseable predicate → visible) — the
// same semantics SchemaRenderer applies to component-level `visibleWhen`.
return evaluator.evaluateCondition(it.visibleWhen);
// same semantics SchemaRenderer applies to component-level `visibleWhen`,
// and objectui#6038 gives it the same VOICE. The verdict is untouched: a
// faulting predicate still resolves to `true` and the tab still renders.
//
// This site is in the census for the reason the card's census clause names
// — it swallows the identical fault under a different helper. It is worse
// than the node gate was, in fact: `SchemaRenderer` at least reported in
// development, while an item-level `visibleWhen` that faulted here was
// silent in BOTH builds, on a gate whose false verdict removes an entire
// tab (header and panel) rather than one block. Reported through the SAME
// reporter and the SAME dedupe `Set` as the node gate, so one authored
// predicate is one line no matter which surface evaluates it.
return evaluator.evaluateCondition(it.visibleWhen, {
onFault: (reason) =>
reportUnresolvableVisibilityPredicate('page:tabs', schema?.id, 'visibleWhen', it.visibleWhen, reason),
});
};
const visibleFlags = rawItems.map(isItemVisible);
// Keep the filtered array's identity stable while visibility is unchanged
Expand Down
Loading
Loading