From 2a4d68c01603fa9e09d491f2a6194b6131ab9e39 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 23:38:50 +0000
Subject: [PATCH 1/9] wip(lint): a flow edge condition finding carries its key
path
Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx
Co-Authored-By: Claude
---
packages/lint/src/authoring-rules.ts | 4 +-
...te-expressions.edge-condition-path.test.ts | 102 ++++++++++++++++++
packages/lint/src/validate-expressions.ts | 22 ++++
3 files changed, 127 insertions(+), 1 deletion(-)
create mode 100644 packages/lint/src/validate-expressions.edge-condition-path.test.ts
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
From 2a349a9bb7ae8ed05984b596c10b6481aac04d44 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 27 Oct 2026 23:41:09 +0000
Subject: [PATCH 2/9] wip(app-shell): expression fields read the served verdict
at their own path
Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx
Co-Authored-By: Claude
---
.../metadata-admin/CelPredicateField.tsx | 35 +++++++--
.../PermissionAdvancedFacets.tsx | 2 +
.../metadata-admin/PermissionMatrixEditor.tsx | 75 +++++++++++++------
.../views/metadata-admin/ResourceEditPage.tsx | 6 +-
.../src/views/metadata-admin/draftVerdict.ts | 34 +++++++++
.../inspectors/FlowEdgeInspector.tsx | 9 +++
6 files changed, 132 insertions(+), 29 deletions(-)
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 1b2cc05943e..f2c33bf376f 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx
@@ -677,6 +677,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];
@@ -693,6 +694,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.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx
index c7835292564..89ae1b3c9b7 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.
@@ -727,6 +734,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").
@@ -849,6 +861,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
@@ -1326,6 +1346,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 {
@@ -1356,6 +1383,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
@@ -2050,17 +2079,19 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
{capabilitiesEl}
-
+
+
+
)}
@@ -2222,16 +2253,18 @@ 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
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({