diff --git a/.changeset/26858-edge-condition-finding-path.md b/.changeset/26858-edge-condition-finding-path.md
new file mode 100644
index 00000000000..17794ccf0fc
--- /dev/null
+++ b/.changeset/26858-edge-condition-finding-path.md
@@ -0,0 +1,15 @@
+---
+'@objectstack/lint': patch
+---
+
+fix(lint): a flow edge condition's `expression-invalid` finding carries the slot's key path (`flows[0].edges[1].condition`) beside its `where` text
+
+Clause-②: no
+Metadata-protocol: none
+
+An `expression-invalid` finding on a top-level flow edge's `condition` used to be located by its `where` prose alone (`flow 'f' · edge 'e2' (decide→end) condition`), which a reader keyed on paths could not address. Its `path` is now the key path in the spelling the other rules' findings use, so a draft's `_diagnostics` reports it item-relative (`edges.1.condition`) and Studio's edge condition field shows the refusal under itself.
+
+- `where`, the message and the verdict are unchanged: nothing is accepted or refused differently.
+- The edge is indexed in the list as authored (a `null` member before it does not renumber it).
+- A nested region's edge, and a flow in a map-form `flows` collection, keep `where` as their `path`, as before.
+- `ExprIssue` gains the optional `path`.
diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts
index 95b28ab2b71..04c6d823e81 100644
--- a/packages/lint/src/authoring-rules.ts
+++ b/packages/lint/src/authoring-rules.ts
@@ -791,7 +791,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
severity: i.severity ?? 'error',
rule: EXPRESSION_INVALID,
where: i.where,
- path: i.where,
+ // [#26858] The slot's key path where the pass spells one (a flow edge's
+ // `condition`), so a draft's `_diagnostics` can address the field.
+ path: i.path ?? i.where,
// [#22161] The authored source is a QUOTE of what the author wrote, not
// a fix, so it rides the verdict — `— source: \`…\`` is the spelling
// the flow engine's runtime refusals already use — and reaches the CLI
diff --git a/packages/lint/src/validate-expressions.edge-condition-path.test.ts b/packages/lint/src/validate-expressions.edge-condition-path.test.ts
new file mode 100644
index 00000000000..c298bb51546
--- /dev/null
+++ b/packages/lint/src/validate-expressions.edge-condition-path.test.ts
@@ -0,0 +1,102 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #26858 — a flow edge's `condition` finding carries the slot's key path.
+ *
+ * `expression-invalid` is what publish refuses a flow edge's CEL with, and its
+ * finding was located by its `where` prose alone (`flow 'f' · edge 'e2'
+ * (decide→end) condition`). A draft's `_diagnostics` is item-relative and
+ * dotted (`edges.1.condition`), so Studio's edge condition field could not ask
+ * whether the refusal was its own. The finding now carries the key path in the
+ * spelling the other rules use (`flows[0].edges[1].condition`) beside the
+ * unchanged `where` and message.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { runRuntimeAuthoringRules } from './runtime-gate.js';
+import { validateStackExpressions } from './validate-expressions.js';
+
+type AnyRec = Record;
+
+const BROKEN = 'record.name ==';
+
+function flow(edges: unknown[], extra: AnyRec = {}): AnyRec {
+ return {
+ name: 'edge_cel',
+ label: 'Edge CEL',
+ type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start' },
+ { id: 'decide', type: 'decision', label: 'Decide' },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges,
+ ...extra,
+ };
+}
+
+const EDGES = [
+ { id: 'e1', source: 'start', target: 'decide' },
+ { id: 'e2', source: 'decide', target: 'end', type: 'conditional', condition: BROKEN },
+];
+
+describe('expression-invalid on a flow edge condition carries a key path (#26858)', () => {
+ it('names the edge by its index in the authored list, where and message unchanged', () => {
+ const issues = validateStackExpressions({ flows: [flow(EDGES)] });
+ const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
+ expect(onEdge.length).toBeGreaterThan(0);
+ for (const i of onEdge) {
+ expect(i.path).toBe('flows[0].edges[1].condition');
+ expect(i.where).toBe("flow 'edge_cel' · edge 'e2' (decide→end) condition");
+ }
+ });
+
+ it('indexes the raw list: a null member before the edge does not renumber it', () => {
+ const issues = validateStackExpressions({ flows: [flow([null, ...EDGES])] });
+ const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
+ expect(onEdge.length).toBeGreaterThan(0);
+ for (const i of onEdge) expect(i.path).toBe('flows[0].edges[2].condition');
+ });
+
+ it('indexes the flow in the stack', () => {
+ const other = { ...flow([{ id: 'x1', source: 'start', target: 'end' }]), name: 'clean' };
+ const issues = validateStackExpressions({ flows: [other, flow(EDGES)] });
+ const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
+ expect(onEdge.length).toBeGreaterThan(0);
+ for (const i of onEdge) expect(i.path).toBe('flows[1].edges[1].condition');
+ });
+
+ it('a map-form flows collection has no index to spell, so the finding keeps where alone', () => {
+ const { name: _name, ...body } = flow(EDGES);
+ const issues = validateStackExpressions({ flows: { edge_cel: body } });
+ const onEdge = issues.filter((i) => i.where.includes("edge 'e2'"));
+ expect(onEdge.length).toBeGreaterThan(0);
+ for (const i of onEdge) expect(i.path).toBeUndefined();
+ });
+
+ it('a finding off the edge condition gains no path', () => {
+ const issues = validateStackExpressions({
+ flows: [
+ flow([{ id: 'e1', source: 'start', target: 'end' }], {
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start', config: { condition: BROKEN } },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ }),
+ ],
+ });
+ expect(issues.length).toBeGreaterThan(0);
+ for (const i of issues) expect(i.path).toBeUndefined();
+ });
+
+ it('reaches the publish gate: the refusal is located at the edge condition, message unchanged', () => {
+ const result = runRuntimeAuthoringRules({ type: 'flow', item: flow(EDGES) });
+ const refusals = result.errors.filter((f) => f.rule === 'expression-invalid');
+ expect(refusals.length).toBeGreaterThan(0);
+ for (const f of refusals) {
+ expect(f.path).toMatch(/^flows(\[0\]|\.edge_cel)\.edges\[1\]\.condition$/);
+ expect(f.where).toBe("flow 'edge_cel' · edge 'e2' (decide→end) condition");
+ }
+ });
+});
diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts
index e2f13e13626..0faec702003 100644
--- a/packages/lint/src/validate-expressions.ts
+++ b/packages/lint/src/validate-expressions.ts
@@ -161,6 +161,13 @@ export interface ExprIssue {
* condition, which might be a flow variable). Absent ⇒ treat as `error`.
*/
severity?: 'error' | 'warning';
+ /**
+ * [#26858] The judged slot's key path in the stack, in the spelling the other
+ * rules' findings carry (`flows[0].edges[1].condition`), beside the human
+ * `where`. Set only where the slot has one: a top-level flow edge's
+ * `condition`. Absent, the finding is located by `where` alone.
+ */
+ path?: string;
}
type AnyRec = Record;
@@ -2511,6 +2518,10 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex);
for (const flow of objectWrite ? [] : recordsOf(stack.flows)) {
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
+ // [#26858] The flow's index in the authored list, for a finding's key path
+ // (`recordsOf` hands an array's own members back). A map-form `flows` has
+ // no index to spell, so its findings keep `where` alone.
+ const flowIndex = Array.isArray(stack.flows) ? stack.flows.indexOf(flow) : -1;
// `Array.isArray` proves the LIST, never its MEMBERS — the sentence #15742
// removed from one reader below in this same file. A YAML `nodes:` item
// left empty deserialises to `null`, and `nodes.find(n => n.type === …)` on
@@ -2897,12 +2908,23 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
// and not a check.
for (const edge of recordsOf(graph.edges)) {
const edgeCondWhere = `${at} · edge '${edge.id}' (${edge.source}→${edge.target}) condition`;
+ const first = issues.length;
if (!checkStructuralCondition(edgeCondWhere, edge.condition).refused) {
const from = issues.length;
check(edgeCondWhere, edge.condition, objectName);
warnShadowedFieldReads(edgeCondWhere, edge.condition);
refuseUnboundRoots(edgeCondWhere, edge.condition, from, 'predicate');
}
+ // [#26858] A top-level edge's findings also carry the slot's key path,
+ // indexed over the list the author wrote (the coerced one drops `null`
+ // members and would renumber). A nested region's edge keeps `where`.
+ const edgeIndex = flowIndex >= 0 && graph.path.length === 0 && Array.isArray(flow.edges)
+ ? flow.edges.indexOf(edge)
+ : -1;
+ if (edgeIndex >= 0) {
+ const path = `flows[${flowIndex}].edges[${edgeIndex}].condition`;
+ for (let k = first; k < issues.length; k++) issues[k] = { ...issues[k]!, path };
+ }
}
// No `checkNullGuards` on node/edge conditions — and NOT for the reason
// #4811 first recorded (#4811 re-measured it). The stated blocker was the
diff --git a/ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx b/ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx
index a3963e09ad3..eae42795f22 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx
@@ -54,6 +54,7 @@ import {
type CelSuggestionKind,
type CelValueType,
} from './celAuthoring.js';
+import { useServerErrorsAt } from './draftVerdict.js';
const LINT_DEBOUNCE_MS = 250;
@@ -118,6 +119,15 @@ export interface CelPredicateFieldProps {
* has no such second verdict (the permission matrix included).
*/
scopeIssue?: boolean;
+ /**
+ * objectstack#26858 — the item-relative path this editor writes in the body
+ * the page holds (`rowLevelSecurity.0.using`). When the page's served verdict
+ * (`DraftVerdictContext`) has an error at exactly that path — what publish
+ * will refuse — the editor shows the server's message and withholds "Valid
+ * CEL". The lint, its findings and `onLintChange` are unaffected, as with
+ * `scopeIssue`. Omitted: no server verdict is read.
+ */
+ verdictPath?: string;
/** Reports the current lint issues up so the editor can gate Save on errors. */
onLintChange?: (issues: CelLintIssue[]) => void;
/**
@@ -158,6 +168,7 @@ export function CelPredicateField({
slot,
role,
scopeIssue,
+ verdictPath,
onLintChange,
onInferredTypeChange,
t,
@@ -354,9 +365,13 @@ export function CelPredicateField({
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
- // "Valid CEL" only when nothing — the lint, or the host's scope verdict —
- // says otherwise (objectui#11789).
- const clean = linted && !!value.trim() && issues.length === 0 && !scopeIssue;
+ // objectstack#26858 — the server's errors at this editor's path, each one
+ // the lint does not already say in the same words.
+ const atPath = useServerErrorsAt(verdictPath);
+ const serverErrors = atPath.filter((e) => !issues.some((iss) => issueText(iss, t) === e.message));
+ // "Valid CEL" only when nothing — the lint, the host's scope verdict
+ // (objectui#11789) or the server's verdict at this path — says otherwise.
+ const clean = linted && !!value.trim() && issues.length === 0 && !scopeIssue && atPath.length === 0;
// Result-type affordance (role="value"): shown once the expression parses,
// even alongside warnings — the type is what dataset measure eligibility
// keys off, so the author should see it whenever it is known.
@@ -384,7 +399,7 @@ export function CelPredicateField({
aria-expanded={open}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
- aria-invalid={errors.length > 0 || undefined}
+ aria-invalid={errors.length > 0 || atPath.length > 0 || undefined}
onChange={(e) => {
onChange(e.target.value);
refreshAc();
@@ -397,7 +412,7 @@ export function CelPredicateField({
}}
className={cn(
'font-mono text-xs',
- errors.length > 0 && 'border-destructive focus-visible:ring-destructive/40',
+ (errors.length > 0 || atPath.length > 0) && 'border-destructive focus-visible:ring-destructive/40',
)}
/>
{open && suggestions.length > 0 && (
@@ -431,8 +446,8 @@ export function CelPredicateField({
)}
- {/* Inline lint findings. */}
- {(errors.length > 0 || warnings.length > 0) && (
+ {/* Inline lint findings, then the server's at this path (as written). */}
+ {(errors.length > 0 || warnings.length > 0 || serverErrors.length > 0) && (
{errors.map((iss, i) => (
@@ -440,6 +455,12 @@ export function CelPredicateField({
{issueText(iss, t)}
))}
+ {serverErrors.map((e, i) => (
+
+
+ {e.message}
+
+ ))}
{warnings.map((iss, i) => (
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx
index 11bf4633b54..3d4beb535c2 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx
@@ -745,6 +745,7 @@ export function PermissionAdvancedFacets({
objectName={pol.object}
fieldNames={fieldsFor(pol.object)}
clause="using"
+ verdictPath={`rowLevelSecurity.${i}.using`}
onLintChange={(issues) => reportClause(`${i}:using`, issues)}
onChange={(v) => {
const next = [...policies];
@@ -761,6 +762,7 @@ export function PermissionAdvancedFacets({
objectName={pol.object}
fieldNames={fieldsFor(pol.object)}
clause="check"
+ verdictPath={`rowLevelSecurity.${i}.check`}
onLintChange={(issues) => reportClause(`${i}:check`, issues)}
onChange={(v) => {
const next = [...policies];
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.objectGrantInherited-27010.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.objectGrantInherited-27010.test.tsx
index a75e15c7970..f4bcefdf746 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.objectGrantInherited-27010.test.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.objectGrantInherited-27010.test.tsx
@@ -43,6 +43,8 @@ function makeClient(server: FakeServer) {
return {
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
+ // objectstack#26840 — the package door reads its objects through the draft-overlaid door.
+ withPreviewDrafts() { return this; },
list: async (type: string) =>
type === 'object' ? server.catalog : type === 'permission' ? [{ name: 'project_editor' }] : [],
get: async (type: string) => (type === 'object' ? { fields: [{ name: 'notes' }] } : null),
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.packageDoorDraftBase-26837.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.packageDoorDraftBase-26837.test.tsx
index be79bdf52d0..2bf3c7a4585 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.packageDoorDraftBase-26837.test.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.packageDoorDraftBase-26837.test.tsx
@@ -99,6 +99,8 @@ function makeClient(server: Server, opts: { draft: Record | nul
if (opts.rejectSaveTimeDraft && server.getDraftCalls > 1) throw new Error('draft read failed');
return opts.draft ? { type: 'permission', name: 'sales_perms', item: opts.draft, version: 'v1' } : null;
},
+ // objectstack#26840 — the package door reads its objects through the draft-overlaid door.
+ withPreviewDrafts() { return this; },
// The package declares `a_account` only.
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async () => null,
@@ -168,8 +170,9 @@ describe('PermissionMatrixEditPage — the PACKAGE door saves onto the pending d
// Out-of-scope rows come from the draft too: it is what this save replaces.
expect(body.objects.b_order).toEqual(DRAFT.objects.b_order);
expect(body.fields).toEqual(DRAFT.fields);
- // CONTROL: the save-time draft read ran (#1 is the load).
- expect(server.getDraftCalls).toBe(2);
+ // CONTROL: the save-time draft read ran (#1 is the load). objectstack#26858 —
+ // #3 is the read after the save that refreshes the draft's served verdict.
+ await waitFor(() => expect(server.getDraftCalls).toBe(3));
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' });
expect(screen.queryByText(REFUSAL)).toBeNull();
});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdict-26858.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdict-26858.test.tsx
new file mode 100644
index 00000000000..09ebf5c1be3
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdict-26858.test.tsx
@@ -0,0 +1,137 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26858 — the permission-set editor hands its RLS clause editors
+ * the served verdict on the set on screen.
+ *
+ * The set's page is its own: it holds the draft read itself and strips the
+ * read decorations off the body, so the verdict the draft read serves (since
+ * objectstack#26707, what its publish will refuse) never reached a field. The
+ * page now keeps that verdict and provides it, and reads it again after each
+ * package-door save, so the field follows the draft it describes.
+ *
+ * The fake door below serves a draft whose `_diagnostics` refuses its USING
+ * clause the way `rls-predicate-unenforceable` does, until the clause changes.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+const REFUSED_USING = 'deal_score > 5';
+const REFUSAL =
+ "rowLevelSecurity 'hot_deals' using reads 'deal_score', a formula field the database cannot filter on";
+
+const SET = {
+ name: 'sales_perms',
+ label: 'Sales',
+ objects: { a_account: { allowRead: true } },
+ fields: {},
+};
+
+interface Server {
+ draft: Record | null;
+ draftReads: number;
+}
+
+/** The door's publish verdict on a stored draft: it refuses the one USING clause. */
+function verdictOf(body: Record) {
+ const rows = (body.rowLevelSecurity ?? []) as Array<{ using?: string }>;
+ const errors = rows.flatMap((r, i) =>
+ r.using === REFUSED_USING
+ ? [{ path: `rowLevelSecurity.${i}.using`, message: REFUSAL, code: 'rls-predicate-unenforceable' }]
+ : [],
+ );
+ return errors.length ? { valid: false, errors } : { valid: true };
+}
+
+// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
+type FakeClient = Record unknown>;
+
+function makeClient(server: Server): FakeClient {
+ return {
+ layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }),
+ getDraft: async () => {
+ server.draftReads += 1;
+ return server.draft
+ ? { type: 'permission', name: 'sales_perms', item: { ...server.draft, _draft: true, _diagnostics: verdictOf(server.draft) } }
+ : null;
+ },
+ // objectstack#26840 — the package door reads its objects through the draft-overlaid door.
+ withPreviewDrafts() { return this; },
+ list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
+ get: async () => null,
+ save: async (_type: string, _name: string, payload: Record) => {
+ server.draft = JSON.parse(JSON.stringify(payload)) as Record;
+ return payload;
+ },
+ };
+}
+
+let clientImpl: FakeClient;
+
+vi.mock('./useMetadata', () => ({
+ useMetadataClient: () => clientImpl,
+ useMetadataTypes: () => ({
+ loading: false,
+ error: null,
+ entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
+ }),
+}));
+
+vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
+
+import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
+
+afterEach(cleanup);
+
+const USING_PLACEHOLDER = 'organization_id == current_user.organization_id';
+
+async function openPackageDoor(server: Server) {
+ clientImpl = makeClient(server);
+ render(
+
+
+ ,
+ );
+ await screen.findByText('a_account');
+ fireEvent.click(screen.getByRole('button', { name: /Row-Level Security/ }));
+}
+
+const policy = (using: string) => ({
+ ...SET,
+ rowLevelSecurity: [{ name: 'hot_deals', object: 'a_account', operation: 'select', using, enabled: true }],
+});
+
+describe('the permission-set editor provides the served verdict to its RLS clauses (objectstack#26858)', () => {
+ it('a pending draft the publish refuses: the USING clause shows the server message, not "Valid CEL"', async () => {
+ const server: Server = { draft: policy(REFUSED_USING), draftReads: 0 };
+ await openPackageDoor(server);
+ expect(await screen.findByText(REFUSAL)).toBeInTheDocument();
+ // The lint itself answered: the clause parses, and only the server refuses it.
+ await waitFor(() => expect(screen.getByPlaceholderText(USING_PLACEHOLDER)).toHaveValue(REFUSED_USING));
+ await new Promise((r) => setTimeout(r, 600));
+ expect(screen.queryByText('Valid CEL')).toBeNull();
+ });
+
+ it('the verdict follows the draft: after an autosave of a changed clause the refusal goes', async () => {
+ const server: Server = { draft: policy(REFUSED_USING), draftReads: 0 };
+ await openPackageDoor(server);
+ await screen.findByText(REFUSAL);
+ const readsBefore = server.draftReads;
+
+ fireEvent.change(screen.getByPlaceholderText(USING_PLACEHOLDER), { target: { value: 'owner_id == current_user.id' } });
+
+ await waitFor(() => expect(server.draftReads).toBeGreaterThan(readsBefore), { timeout: 5000 });
+ await waitFor(() => expect(screen.queryByText(REFUSAL)).toBeNull(), { timeout: 5000 });
+ expect(await screen.findByText('Valid CEL', {}, { timeout: 3000 })).toBeInTheDocument();
+ });
+
+ it('control: a draft the publish accepts reads "Valid CEL"', async () => {
+ const server: Server = { draft: policy('owner_id == current_user.id'), draftReads: 0 };
+ await openPackageDoor(server);
+ expect(await screen.findByText('Valid CEL', {}, { timeout: 3000 })).toBeInTheDocument();
+ expect(screen.queryByText(REFUSAL)).toBeNull();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx
index f47b50b12a6..8686e2a93e4 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx
@@ -93,6 +93,13 @@ import { HistoryPanel } from './ResourceHistoryPage.js';
import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata.js';
// objectui#11773 — the package door's draft save sends the version it was built on.
import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js';
+// objectstack#26858 — the served verdict the RLS clause editors read at their paths.
+import {
+ DraftVerdictContext,
+ draftEnvelopeDiagnostics,
+ editedItemDiagnostics,
+ type ServerDiagnostics,
+} from './draftVerdict.js';
// objectui#11787 — the package door autosaves through the pillars' own autosave.
import { useDraftAutoSave, type DraftSend } from '../studio-design/useDraftAutoSave.js';
// objectui#12456 — the Studio Access pillar's presentation draws the shared item header.
@@ -777,6 +784,11 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
const [fieldsByObject, setFieldsByObject] = React.useState>({});
const [expanded, setExpanded] = React.useState>(new Set());
const [loading, setLoading] = React.useState(true);
+ // objectstack#26858 — the server's verdict on the set on screen: the pending
+ // draft's while the package door holds one (what its publish will refuse),
+ // else the layered read's. Taken off each read, never computed here.
+ const [servedVerdict, setServedVerdict] = React.useState(undefined);
+ const verdictReadSeq = React.useRef(0);
const [saving, setSaving] = React.useState(false);
// objectui#11787 — when the package door's last draft save landed, for the
// status line that replaces its Save button ("Saved 10:42").
@@ -907,6 +919,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
]);
if (cancelled) return;
const pendingDraft = pendingRead?.answer ?? null;
+ verdictReadSeq.current += 1;
+ setServedVerdict(
+ editedItemDiagnostics({
+ hasDraft: !!pendingDraft,
+ draftDiagnostics: draftEnvelopeDiagnostics(pendingDraft),
+ layered: lay,
+ }),
+ );
// ARTIFACT tier input (objectui#4518) — the `code` layer, and its
// `provenance` (objectui#4526), of the SAME envelope the display
// baseline comes from, so the writability verdict and the body on
@@ -1423,6 +1443,13 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// baseline hasn't moved. Show what we just staged.
resetDraftBaseline(toDisplayDraft(toSave));
}
+ // objectstack#26858 — the draft this save stored carries its own publish
+ // verdict on its next read; the one held described the body before it.
+ // Read off the save's path, so a later read (or a load) wins.
+ const seq = ++verdictReadSeq.current;
+ void readDraftForSave(client, type, payload.name, packageId).then((read) => {
+ if (seq === verdictReadSeq.current) setServedVerdict(draftEnvelopeDiagnostics(read.answer));
+ });
// Let the surface count it.
onDraftSaved?.();
} else {
@@ -1453,6 +1480,8 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// fallback this line already spelled for the resolved-but-empty
// (404-shaped) envelope.
resetDraftBaseline(toDisplayDraft((lay?.effective ?? toSave) as PermissionSetDraft));
+ verdictReadSeq.current += 1;
+ setServedVerdict(editedItemDiagnostics({ hasDraft: false, draftDiagnostics: undefined, layered: lay }));
setPostSaveRereadFailed(rereadFailed);
// The write landed, so the server holds the set now whatever the
// confirming read said (objectui#7611: its holders can be listed). Only
@@ -2167,18 +2196,20 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
{capabilitiesEl}
-
+
+
+
)}
@@ -2347,17 +2378,19 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
{/* Advanced facets (ADR-0056 P3) — RLS / tab visibility / delegated
admin scope, structured editors instead of raw JSON. Collapsed by
default so they don't crowd the object matrix. */}
-
+
+
+
{/* ADR-0056 P4 — assigning users is a Setup act, so the DESIGN surface
(Studio, embedded) never shows it. objectui#7611 — the Setup catalog
IS this editor in the environment scope (ADR-0131 D3), so there the
diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverVerdict-26858.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverVerdict-26858.test.tsx
new file mode 100644
index 00000000000..5c4a08b41bb
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverVerdict-26858.test.tsx
@@ -0,0 +1,107 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26858 — the edit page provides the served verdict on the body
+ * on screen (`DraftVerdictContext`), so a field inside it — the flow edge
+ * condition, the CEL editors — can read the errors at its own path.
+ *
+ * The canvas stand-in below reads the context exactly as a field does
+ * (`useServerErrorsAt`) at one path and at a sibling. The server double serves
+ * a pending draft whose verdict refuses that path, the way the draft read does
+ * since objectstack#26707.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+const REFUSAL = 'region "main" names a component this deployment does not have';
+
+const server = vi.hoisted(() => ({ draft: null as Record | null }));
+
+const mockClient = vi.hoisted(() => ({
+ list: vi.fn(async () => []),
+ listDrafts: vi.fn(async () => []),
+ get: vi.fn(async () => null),
+ references: vi.fn(async () => []),
+ layered: vi.fn(async () => ({
+ code: null,
+ overlay: server.draft,
+ overlayScope: 'env',
+ effective: server.draft,
+ _diagnostics: { valid: true },
+ editable: true,
+ deletable: true,
+ resettable: false,
+ lock: 'none',
+ })),
+ getDraft: vi.fn(async (type: string, name: string) => {
+ if (!server.draft) throw Object.assign(new Error('No pending draft'), { code: 'NO_DRAFT', status: 404 });
+ return {
+ type,
+ name,
+ item: {
+ ...server.draft,
+ _draft: true,
+ _diagnostics: { valid: false, errors: [{ path: 'regions.0', message: REFUSAL, code: 'page-component-unknown' }] },
+ },
+ };
+ }),
+ save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })),
+ publish: vi.fn(async () => ({ success: true })),
+ reset: vi.fn(async () => ({})),
+}));
+
+vi.mock('./useMetadata', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ useMetadataClient: () => mockClient,
+ useMetadataTypes: () => ({
+ entries: [{ type: 'page', name: 'page', label: 'Page', allowEnvironmentOverlay: true, schema: { required: [] } }],
+ }),
+ };
+});
+
+import { MetadataResourceEditPage } from './ResourceEditPage';
+import { registerMetadataPreview, getMetadataPreview } from './preview-registry';
+import { useServerErrorsAt } from './draftVerdict';
+
+/** Reads the page's verdict the way an expression field does. */
+function VerdictProbe() {
+ const here = useServerErrorsAt('regions.0');
+ const sibling = useServerErrorsAt('regions.1');
+ return (
+ <>
+
{here.map((e) => e.message).join('|')}
+
{sibling.map((e) => e.message).join('|')}
+ >
+ );
+}
+
+const realPage = getMetadataPreview('page');
+
+beforeEach(() => {
+ server.draft = { name: 'home', label: 'Home', type: 'home', template: 'default', regions: [{ name: 'main', components: [] }] };
+ registerMetadataPreview('page', VerdictProbe as never);
+});
+
+afterEach(() => {
+ cleanup();
+ if (realPage) registerMetadataPreview('page', realPage);
+ window.history.replaceState(null, '', '/');
+});
+
+describe('MetadataResourceEditPage provides the served verdict to the fields inside it (objectstack#26858)', () => {
+ it('a field at the refused path reads the refusal; a sibling path reads nothing', async () => {
+ window.history.replaceState(null, '', '/metadata/page/home');
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('at-path')).toHaveTextContent(REFUSAL), { timeout: 8000 });
+ expect(screen.getByTestId('at-sibling')).toHaveTextContent('');
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
index e0f7e261594..5a4eff23c97 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx
@@ -147,6 +147,7 @@ import { useMetadataLocale, t, tFormat, translateMetadataType, translateValidati
import { JsonSourceEditor } from './JsonSourceEditor.js';
import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js';
import {
+ DraftVerdictContext,
draftEnvelopeDiagnostics,
editedItemDiagnostics,
withServerOnlyErrors,
@@ -2643,7 +2644,7 @@ function MetadataResourceEditPageImpl({
>
);
- return (
+ const page = (
);
+ // objectstack#26858 — the verdict on the body on screen, for an expression
+ // field to read at its own path (`useServerErrorsAt`).
+ return {page};
}
/**
diff --git a/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts
index 7c8b6868704..fbbf0091985 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts
+++ b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts
@@ -19,6 +19,8 @@
* the live client parse.
*/
+import * as React from 'react';
+
/** One `_diagnostics` entry, as served (`MetadataValidationResult`). */
export interface ServerDiagnosticEntry {
path: string;
@@ -75,3 +77,35 @@ export function withServerOnlyErrors !seen.has(`${e.path}\n${e.message}`));
return [...clientErrors, ...extra];
}
+
+/**
+ * objectstack#26858 — the served verdict on the body on screen, for a field
+ * that edits one path of it. The page holding the draft read provides it
+ * (`ResourceEditPage`, the permission-set editor); a field asks for the errors
+ * at its own item-relative path with {@link useServerErrorsAt}. Absent outside
+ * such a page, so a field mounted anywhere else reads none.
+ */
+export const DraftVerdictContext = React.createContext(undefined);
+
+const NO_ERRORS: readonly ServerDiagnosticEntry[] = [];
+
+/**
+ * The served errors located exactly at `path` — the dotted, item-relative
+ * spelling `_diagnostics` uses (`rowLevelSecurity.0.using`, `edges.1.condition`).
+ * Equality, never a prefix: a sibling's error (`edges.10.condition`, the same
+ * policy's `check`) is not this field's. ⛔ Reads the path the server wrote;
+ * no rule is restated here.
+ */
+export function serverErrorsAt(
+ diagnostics: ServerDiagnostics | undefined,
+ path: string | undefined,
+): readonly ServerDiagnosticEntry[] {
+ if (!path || !diagnostics?.errors?.length) return NO_ERRORS;
+ const hits = diagnostics.errors.filter((e) => e.path === path);
+ return hits.length ? hits : NO_ERRORS;
+}
+
+/** {@link serverErrorsAt} against the verdict the enclosing page provides. */
+export function useServerErrorsAt(path: string | undefined): readonly ServerDiagnosticEntry[] {
+ return serverErrorsAt(React.useContext(DraftVerdictContext), path);
+}
diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx
index b6db80a8f87..1dbdac79631 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx
@@ -48,6 +48,7 @@ import { useConnectorRegistry } from './connector-input-fields.js';
import { VariableTextInput } from './VariableTextInput.js';
import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-check.js';
import { writeExpressionSource } from './expression-envelope.js';
+import { useServerErrorsAt } from '../draftVerdict.js';
import { describeEdgeConnectionRefusal, edgeConnectionRefusal } from '../previews/flow-problems.js';
import { flowEdgeDisplayName } from '../previews/flow-canvas-parts.js';
// objectui#7265 — the selected edge as this panel reads it out of the draft.
@@ -89,6 +90,9 @@ export function FlowEdgeInspector({
// time it evaluates this guard (objectui#11085). Called unconditionally — a
// missing edge resolves the flow variables alone.
const { groups: scopeGroups } = useEdgeScope(draft as Record, edge, connectors);
+ // objectstack#26858 — what the page's served verdict says at this edge's
+ // `condition` (an `expression-invalid` its publish would refuse), as written.
+ const conditionVerdict = useServerErrorsAt(index >= 0 ? `edges.${index}.condition` : undefined);
if (!edge) {
return (
@@ -330,6 +334,11 @@ export function FlowEdgeInspector({