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
40 changes: 40 additions & 0 deletions .changeset/9856-client-mount-condition-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/app-shell': minor
---

Client-evaluated `ConditionBuilder` mounts declare the scope roots their own host binds
(objectui#9856) — the declared cost of objectui#9645, now paid.

objectui#9645 made a `scope="record"` mount advertise `RECORD_CONDITION_ROOTS` rather
than the CEL engine's whole default list, and named this follow-up on its own face: the
narrowed set is the one EVERY host of a record-scoped condition binds, which is right
for a mount whose host the component cannot see and wrong for a mount whose host binds
more. An action's `visible` / `disabled` is evaluated in the BROWSER, where the shell's
own expression scope publishes the identity roots and the deployment feature flags, so
those mounts had been advertising two roots while their evaluator answered six — with
the inspector's own section hint still promising predicates "over the record / user /
ctx".

**What changes for an author.** Editing an action's **Visible when** / **Disabled when**
— through the curated inspector or through the generic metadata form — the raw CEL
editor again suggests `user`, `current_user`, `os`, `ctx` and `features`, and its worked
example teaches the `user` clause again. Nothing an author could already type stops
working anywhere: the accept set is untouched, so this moves SUGGESTIONS only.

**One root is withdrawn at those same mounts, deliberately.** `previous` is no longer
offered where a browser evaluates the predicate. No client host binds it — the row
arrives alone and the ambient scope publishes no `previous` — so suggesting it there
built a predicate that could only fault. Server-evaluated mounts (a hook `condition`, an
object validation rule's guard) keep it and keep their narrowing exactly as
objectui#9645 left it.

**Which tier evaluates which metadata type is not re-decided here.** It is read from
`CONDITION_HOST_BY_METADATA_TYPE` (objectui#9953), the ruled table, through a new
`conditionRootsForMetadataType` — the third derivation off it, beside the lint scope and
the subject vocabulary. Mounts whose tier that table does not measure (a page block's
`visibleWhen`, a flow node's entry condition) declare nothing and are unchanged byte for
byte.

The advertised list is never retyped: its pin rebuilds it from the two producers that
decide it and lints every member at the `record` scope, so it reddens both when a host
binding moves and when a root is advertised that the engine would refuse.
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The generic condition mount advertises the roots ITS OWN metadata type's
* evaluator binds — objectui#9856, the polymorphic mount the curated
* declaration could not be copied onto.
*
* ## The defect these cases reproduce
*
* `SchemaForm` routes every predicate-named field to one widget, so one mount
* serves every metadata type. objectui#8167 gave that mount the host's LINT
* scope and objectui#9953 gave it the host's SUBJECT vocabulary; the third
* question — what the raw editor's autocomplete may OFFER — still had no
* channel. So an `action` edited through the generic form inherited the
* `scope="record"` default `RECORD_CONDITION_ROOTS`, the set every host of a
* record-scoped condition binds, while its own host is the browser, where
* `buildExpressionScope` binds more. The same loss as the curated mounts, one
* route over.
*
* ## Why this is a DERIVATION and not a third literal
*
* The two curated action mounts can name the list because they serve one tier.
* This mount serves every tier, and the two arms disagree: the same value would
* be right for an action and would hand a hook's author roots its server host
* never binds — re-opening the trap objectui#9645 closed. So the answer comes
* from `CONDITION_HOST_BY_METADATA_TYPE`, the one place the client/server
* verdict is ruled, through `conditionRootsForMetadataType`.
*
* ## Read through the WIRING, not from the helper
*
* The two rendered cases feed the derivation's own answer to the real widget
* and read the real suggestion menu. Asserting on `conditionRootsForMetadataType`
* alone would leave the JSX free to drop the prop with every case still green —
* the forwarding is half of what is under test.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// Module-scope import of the CEL engine, per AGENTS.md's flaky-test rule: the
// scope introspection runs behind a dynamic `import('@objectstack/formula')`
// inside `celAuthoring`, and a cold first load has been measured near a
// `waitFor`'s whole budget. The specifier must match `loadFormula`'s exactly —
// ESM caches by resolved specifier.
import '@objectstack/formula';

// `ConditionBuilder` calls `useObjectFields` unconditionally (objectui#4697),
// so an unmocked client would let a mount-time fetch escape to the real network.
const state = vi.hoisted(() => ({
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) },
}));
vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
return { ...mod, useMetadataClient: () => state.metadataClient };
});

import { SchemaForm } from './SchemaForm';
import type { WidgetContext } from './widgets';
import {
CONDITION_HOST_BY_METADATA_TYPE,
conditionRootsForMetadataType,
} from './conditionScope';
import { CLIENT_CONDITION_ROOTS } from './inspectors/ConditionBuilder';

afterEach(cleanup);

/** A schema whose one field is routed to the condition widget BY NAME. */
const SCHEMA = {
type: 'object',
properties: { condition: { type: 'string', title: 'Run only when' } },
} as never;

function Harness({ context }: { context: WidgetContext }) {
const [value, setValue] = React.useState<Record<string, unknown>>({ condition: '' });
return (
<SchemaForm
schema={SCHEMA}
value={value}
onChange={(next) => setValue(next as Record<string, unknown>)}
widgetContext={context}
/>
);
}

/** The LABEL of each open suggestion — the first span; the second is its kind tag. */
function offeredLabels(): string[] {
return screen
.queryAllByRole('option')
.map((o) => (o.querySelector('span')?.textContent ?? '').trim());
}

/**
* Mount the generic form with `roots`, warm the suggestion machinery, and read
* what it offers for `prefix`.
*
* ⚠️ The warm-up is load-bearing: the identifier catalog arrives
* asynchronously, and a menu that has not opened YET offers nothing — which
* would satisfy the "is not offered" case below no matter what the widget
* forwards. `record` is offered on both arms, so completing it begs no question.
*/
async function offeredAtGenericMount(
roots: string[] | undefined,
prefix: string,
): Promise<string[]> {
// One mount per reading: a case that takes two readings would otherwise leave
// two forms on screen and every `byRole` query would match both.
cleanup();
const user = userEvent.setup();
render(<Harness context={{ conditionScope: 'record', conditionRoots: roots }} />);
const group = await screen.findByRole('group', { name: /Run only when/ });
fireEvent.click(within(group).getByText('Expression'));
const box = within(group)
.getAllByRole('combobox')
.find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement;
await user.click(box);
await user.type(box, 'rec');
expect(await screen.findByRole('option', { name: /record/ }, { timeout: 4000 })).toBeTruthy();
await user.clear(box);
await user.type(box, prefix);
return offeredLabels();
}

/* ── The two arms, both fed the derivation's own answer ────────────────── */

describe('the generic condition mount, editing a CLIENT-evaluated type (objectui#9856)', () => {
it('offers the roots only a browser host binds', async () => {
// THE GATE. `conditionRootsForMetadataType('action')` is what
// `ResourceEditPage` hands this widget for that type, so this case fails if
// the derivation stops answering, if the widget stops forwarding, or if the
// list stops carrying what the browser evaluator binds.
const offered = await offeredAtGenericMount(conditionRootsForMetadataType('action'), 'o');
expect(offered).toContain('os');
});

it('does NOT offer them for a server-evaluated type — the narrowing stays put', async () => {
// The must-not-widen half, and the reason this member could not have been
// one value for the whole mount. `undefined` is what the derivation hands
// back for `hook`, and it leaves `ConditionBuilder`'s record-scoped default
// exactly where objectui#9645 put it.
const offered = await offeredAtGenericMount(conditionRootsForMetadataType('hook'), 'o');
expect(offered).not.toContain('os');
// Non-vacuity: this arm still completes SOMETHING, so the case above is not
// passing against a menu that simply never opened.
expect(await offeredAtGenericMount(conditionRootsForMetadataType('hook'), 'rec'))
.toContain('record');
});
});

/* ── The seam between the table and the list ───────────────────────────── */

describe('conditionRootsForMetadataType — derived from the ruled host table (objectui#9856)', () => {
it('answers with the advertised list itself for every client-evaluated type', () => {
// Identity, and over the TABLE rather than over one type: a `client` row
// added tomorrow is covered the day it lands.
const clientTypes = Object.entries(CONDITION_HOST_BY_METADATA_TYPE)
.filter(([, host]) => host === 'client')
.map(([type]) => type);
expect(clientTypes.length).toBeGreaterThan(0);
for (const type of clientTypes) {
expect(conditionRootsForMetadataType(type)).toBe(CLIENT_CONDITION_ROOTS);
}
});

it('declares nothing for every type that is not client-evaluated', () => {
// Including the unmeasured ones, which is the arm that keeps a tier nobody
// has put to an evaluator exactly as it was rather than widened on a guess.
for (const [type, host] of Object.entries(CONDITION_HOST_BY_METADATA_TYPE)) {
if (host === 'client') continue;
expect(conditionRootsForMetadataType(type)).toBeUndefined();
}
expect(conditionRootsForMetadataType('flow')).toBeUndefined();
expect(conditionRootsForMetadataType('a-type-this-build-never-heard-of')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
} from './widgets.js';
import {
conditionScopeForMetadataType,
conditionRootsForMetadataType,
conditionSubjectsForMetadataType,
} from './conditionScope.js';
import { mapLoaded, usePickerLoad } from './loadState.js';
Expand Down Expand Up @@ -983,6 +984,13 @@ function MetadataResourceEditPageImpl({
// same move as the line above, applied to the fact that line cannot
// carry.
conditionSubjects: conditionSubjectsForMetadataType(type),
// objectui#9856 — the same move once more, on the question the two lines
// above cannot answer between them: what the raw editor's autocomplete
// may OFFER. The builder's own default narrows a record-scoped mount to
// what every host binds, so the `action` tier — evaluated in the browser
// — is the one that has to declare that it binds more. Derived per type
// here for the reason the scope is: this page edits every metadata type.
conditionRoots: conditionRootsForMetadataType(type),
objectNames: objectsState,
objectFields: mapLoaded(objectCatalogState, (catalog) => catalog.fields),
objectActions: mapLoaded(objectCatalogState, (catalog) => catalog.actions),
Expand Down
32 changes: 31 additions & 1 deletion packages/app-shell/src/views/metadata-admin/conditionScope.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { RECORD_CONDITION_SUBJECTS } from './inspectors/ConditionBuilder.js';
import {
CLIENT_CONDITION_ROOTS,
RECORD_CONDITION_SUBJECTS,
} from './inspectors/ConditionBuilder.js';

/**
* Which lint scope a SCHEMA-DRIVEN condition editor claims, decided by the
Expand Down Expand Up @@ -272,3 +275,30 @@ export function conditionSubjectsForMetadataType(
? RECORD_CONDITION_SUBJECTS
: undefined;
}

/**
* The scope roots a condition editor at a host editing `type` may OFFER, or
* `undefined` to declare no narrowing (objectui#9856).
*
* The third derivation off {@link CONDITION_HOST_BY_METADATA_TYPE}, and the one
* that reads it the other way round from
* {@link conditionSubjectsForMetadataType}. That one narrows the SERVER tier,
* because the row builder's subject dropdown offers `user.*` by default and a
* server host binds no `user`. This one widens the CLIENT tier, because
* `ConditionBuilder` narrows a `scope="record"` mount to
* `RECORD_CONDITION_ROOTS` by default and a browser host binds more than that.
* Same table, opposite defaults, one ruling — which is why neither can be
* derived from `scope === 'record'`, and why the answer has to come from the
* host that knows which metadata type is on screen.
*
* `undefined` is a decision here too, and it covers both remaining arms:
*
* - a `server` tier keeps the builder's own record-scoped narrowing, byte for
* byte what objectui#9645 landed;
* - an UNMEASURED tier keeps whatever it had, because a root list is a reading
* taken at an evaluator and there is none to hand back for a tier nobody has
* put to one.
*/
export function conditionRootsForMetadataType(type: string): string[] | undefined {
return conditionHostForMetadataType(type) === 'client' ? CLIENT_CONDITION_ROOTS : undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
import { useObjectOptions } from '../previews/useObjectOptions.js';
import { useObjectFields } from '../previews/useObjectFields.js';
import { useMetaOptions } from '../previews/useMetaOptions.js';
import { ConditionBuilder } from './ConditionBuilder.js';
import { ConditionBuilder, CLIENT_CONDITION_ROOTS } from './ConditionBuilder.js';
import { expressionSource, writeExpressionSource } from './expression-envelope.js';
import { IconPickerWidget } from '../widgets.js';

Expand Down Expand Up @@ -898,8 +898,20 @@ export function ActionDefaultInspector({
and wrong here. It also ends a disagreement inside this very
control — the row builder was already emitting `record.<field>`
while its own raw editor accepted the retired bare spelling. */}
<ConditionBuilder label="Visible when" value={expressionSource(draft.visible)} onCommit={(v) => onPatch({ visible: writeExpressionSource(draft.visible, v) })} objectName={objectName} disabled={readOnly} scope="record" onBlockingIssuesChange={(n) => reportCel('visible', n)} />
<ConditionBuilder label="Disabled when" value={expressionSource(draft.disabled)} onCommit={(v) => onPatch({ disabled: writeExpressionSource(draft.disabled, v) })} objectName={objectName} disabled={readOnly} scope="record" onBlockingIssuesChange={(n) => reportCel('disabled', n)} />
{/* `roots` is the second declaration this pair owes, and it answers a
different question than `scope` does (objectui#9856). `scope` says
how the CEL is LINTED; `roots` says what the HOST binds, and
objectui#9645 could not derive the second from the first — so a
`scope="record"` mount that declares nothing inherits
`RECORD_CONDITION_ROOTS`, the set every host of a record-scoped
condition binds. These two are evaluated in the BROWSER, where
`buildExpressionScope` binds more than that, and
`CONDITION_HOST_BY_METADATA_TYPE` rules the `action` tier `client`
from a reading taken at that evaluator. Declared here rather than
defaulted, for the reason `RECORD_CONDITION_ROOTS` gives: the
component cannot see which host is on the other end. */}
<ConditionBuilder label="Visible when" value={expressionSource(draft.visible)} onCommit={(v) => onPatch({ visible: writeExpressionSource(draft.visible, v) })} objectName={objectName} disabled={readOnly} scope="record" roots={CLIENT_CONDITION_ROOTS} onBlockingIssuesChange={(n) => reportCel('visible', n)} />
<ConditionBuilder label="Disabled when" value={expressionSource(draft.disabled)} onCommit={(v) => onPatch({ disabled: writeExpressionSource(draft.disabled, v) })} objectName={objectName} disabled={readOnly} scope="record" roots={CLIENT_CONDITION_ROOTS} onBlockingIssuesChange={(n) => reportCel('disabled', n)} />
</div>

{/* 7 ─ AI exposure */}
Expand Down
Loading
Loading