Skip to content

Commit 2a349a9

Browse files
committed
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 <noreply@anthropic.com>
1 parent 2a4d68c commit 2a349a9

6 files changed

Lines changed: 132 additions & 29 deletions

File tree

‎ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
type CelSuggestionKind,
5555
type CelValueType,
5656
} from './celAuthoring.js';
57+
import { useServerErrorsAt } from './draftVerdict.js';
5758

5859
const LINT_DEBOUNCE_MS = 250;
5960

@@ -118,6 +119,15 @@ export interface CelPredicateFieldProps {
118119
* has no such second verdict (the permission matrix included).
119120
*/
120121
scopeIssue?: boolean;
122+
/**
123+
* objectstack#26858 — the item-relative path this editor writes in the body
124+
* the page holds (`rowLevelSecurity.0.using`). When the page's served verdict
125+
* (`DraftVerdictContext`) has an error at exactly that path — what publish
126+
* will refuse — the editor shows the server's message and withholds "Valid
127+
* CEL". The lint, its findings and `onLintChange` are unaffected, as with
128+
* `scopeIssue`. Omitted: no server verdict is read.
129+
*/
130+
verdictPath?: string;
121131
/** Reports the current lint issues up so the editor can gate Save on errors. */
122132
onLintChange?: (issues: CelLintIssue[]) => void;
123133
/**
@@ -158,6 +168,7 @@ export function CelPredicateField({
158168
slot,
159169
role,
160170
scopeIssue,
171+
verdictPath,
161172
onLintChange,
162173
onInferredTypeChange,
163174
t,
@@ -354,9 +365,13 @@ export function CelPredicateField({
354365

355366
const errors = issues.filter((i) => i.severity === 'error');
356367
const warnings = issues.filter((i) => i.severity === 'warning');
357-
// "Valid CEL" only when nothing — the lint, or the host's scope verdict —
358-
// says otherwise (objectui#11789).
359-
const clean = linted && !!value.trim() && issues.length === 0 && !scopeIssue;
368+
// objectstack#26858 — the server's errors at this editor's path, each one
369+
// the lint does not already say in the same words.
370+
const atPath = useServerErrorsAt(verdictPath);
371+
const serverErrors = atPath.filter((e) => !issues.some((iss) => issueText(iss, t) === e.message));
372+
// "Valid CEL" only when nothing — the lint, the host's scope verdict
373+
// (objectui#11789) or the server's verdict at this path — says otherwise.
374+
const clean = linted && !!value.trim() && issues.length === 0 && !scopeIssue && atPath.length === 0;
360375
// Result-type affordance (role="value"): shown once the expression parses,
361376
// even alongside warnings — the type is what dataset measure eligibility
362377
// keys off, so the author should see it whenever it is known.
@@ -384,7 +399,7 @@ export function CelPredicateField({
384399
aria-expanded={open}
385400
aria-autocomplete="list"
386401
aria-controls={open ? listId : undefined}
387-
aria-invalid={errors.length > 0 || undefined}
402+
aria-invalid={errors.length > 0 || atPath.length > 0 || undefined}
388403
onChange={(e) => {
389404
onChange(e.target.value);
390405
refreshAc();
@@ -397,7 +412,7 @@ export function CelPredicateField({
397412
}}
398413
className={cn(
399414
'font-mono text-xs',
400-
errors.length > 0 && 'border-destructive focus-visible:ring-destructive/40',
415+
(errors.length > 0 || atPath.length > 0) && 'border-destructive focus-visible:ring-destructive/40',
401416
)}
402417
/>
403418
{open && suggestions.length > 0 && (
@@ -431,15 +446,21 @@ export function CelPredicateField({
431446
)}
432447
</div>
433448

434-
{/* Inline lint findings. */}
435-
{(errors.length > 0 || warnings.length > 0) && (
449+
{/* Inline lint findings, then the server's at this path (as written). */}
450+
{(errors.length > 0 || warnings.length > 0 || serverErrors.length > 0) && (
436451
<ul className="mt-1 space-y-1" aria-live="polite">
437452
{errors.map((iss, i) => (
438453
<li key={`e${i}`} className="flex items-start gap-1.5 text-[11px] text-destructive">
439454
<AlertCircle className="mt-[1px] h-3 w-3 shrink-0" />
440455
<span>{issueText(iss, t)}</span>
441456
</li>
442457
))}
458+
{serverErrors.map((e, i) => (
459+
<li key={`s${i}`} className="flex items-start gap-1.5 text-[11px] text-destructive" data-testid="cel-server-error">
460+
<AlertCircle className="mt-[1px] h-3 w-3 shrink-0" />
461+
<span>{e.message}</span>
462+
</li>
463+
))}
443464
{warnings.map((iss, i) => (
444465
<li key={`w${i}`} className="flex items-start gap-1.5 text-[11px] text-amber-600 dark:text-amber-500">
445466
<AlertTriangle className="mt-[1px] h-3 w-3 shrink-0" />

‎ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ export function PermissionAdvancedFacets({
677677
objectName={pol.object}
678678
fieldNames={fieldsFor(pol.object)}
679679
clause="using"
680+
verdictPath={`rowLevelSecurity.${i}.using`}
680681
onLintChange={(issues) => reportClause(`${i}:using`, issues)}
681682
onChange={(v) => {
682683
const next = [...policies];
@@ -693,6 +694,7 @@ export function PermissionAdvancedFacets({
693694
objectName={pol.object}
694695
fieldNames={fieldsFor(pol.object)}
695696
clause="check"
697+
verdictPath={`rowLevelSecurity.${i}.check`}
696698
onLintChange={(issues) => reportClause(`${i}:check`, issues)}
697699
onChange={(v) => {
698700
const next = [...policies];

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx‎

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ import { HistoryPanel } from './ResourceHistoryPage.js';
9393
import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata.js';
9494
// objectui#11773 — the package door's draft save sends the version it was built on.
9595
import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js';
96+
// objectstack#26858 — the served verdict the RLS clause editors read at their paths.
97+
import {
98+
DraftVerdictContext,
99+
draftEnvelopeDiagnostics,
100+
editedItemDiagnostics,
101+
type ServerDiagnostics,
102+
} from './draftVerdict.js';
96103
// objectui#11787 — the package door autosaves through the pillars' own autosave.
97104
import { useDraftAutoSave, type DraftSend } from '../studio-design/useDraftAutoSave.js';
98105
// objectui#12456 — the Studio Access pillar's presentation draws the shared item header.
@@ -727,6 +734,11 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
727734
const [fieldsByObject, setFieldsByObject] = React.useState<Record<string, FieldSummary[]>>({});
728735
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
729736
const [loading, setLoading] = React.useState(true);
737+
// objectstack#26858 — the server's verdict on the set on screen: the pending
738+
// draft's while the package door holds one (what its publish will refuse),
739+
// else the layered read's. Taken off each read, never computed here.
740+
const [servedVerdict, setServedVerdict] = React.useState<ServerDiagnostics | undefined>(undefined);
741+
const verdictReadSeq = React.useRef(0);
730742
const [saving, setSaving] = React.useState(false);
731743
// objectui#11787 — when the package door's last draft save landed, for the
732744
// status line that replaces its Save button ("Saved 10:42").
@@ -849,6 +861,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
849861
]);
850862
if (cancelled) return;
851863
const pendingDraft = pendingRead?.answer ?? null;
864+
verdictReadSeq.current += 1;
865+
setServedVerdict(
866+
editedItemDiagnostics({
867+
hasDraft: !!pendingDraft,
868+
draftDiagnostics: draftEnvelopeDiagnostics(pendingDraft),
869+
layered: lay,
870+
}),
871+
);
852872
// ARTIFACT tier input (objectui#4518) — the `code` layer, and its
853873
// `provenance` (objectui#4526), of the SAME envelope the display
854874
// baseline comes from, so the writability verdict and the body on
@@ -1326,6 +1346,13 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
13261346
// baseline hasn't moved. Show what we just staged.
13271347
resetDraftBaseline(toDisplayDraft(toSave));
13281348
}
1349+
// objectstack#26858 — the draft this save stored carries its own publish
1350+
// verdict on its next read; the one held described the body before it.
1351+
// Read off the save's path, so a later read (or a load) wins.
1352+
const seq = ++verdictReadSeq.current;
1353+
void readDraftForSave(client, type, payload.name, packageId).then((read) => {
1354+
if (seq === verdictReadSeq.current) setServedVerdict(draftEnvelopeDiagnostics(read.answer));
1355+
});
13291356
// Let the surface count it.
13301357
onDraftSaved?.();
13311358
} else {
@@ -1356,6 +1383,8 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
13561383
// fallback this line already spelled for the resolved-but-empty
13571384
// (404-shaped) envelope.
13581385
resetDraftBaseline(toDisplayDraft((lay?.effective ?? toSave) as PermissionSetDraft));
1386+
verdictReadSeq.current += 1;
1387+
setServedVerdict(editedItemDiagnostics({ hasDraft: false, draftDiagnostics: undefined, layered: lay }));
13591388
setPostSaveRereadFailed(rereadFailed);
13601389
// The write landed, so the server holds the set now whatever the
13611390
// confirming read said (objectui#7611: its holders can be listed). Only
@@ -2050,17 +2079,19 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
20502079
{capabilitiesEl}
20512080
</div>
20522081
<div hidden={facetTab === null} data-testid="perm-tabpanel-facets" className="min-h-0 flex-1 overflow-auto">
2053-
<PermissionAdvancedFacets
2054-
draft={draft}
2055-
setDraft={setDraft}
2056-
writable={writable}
2057-
allSetNames={allSetNames}
2058-
loadObjectFields={loadObjectFields}
2059-
onCelErrorsChange={setCelErrorCount}
2060-
t={t}
2061-
only={facetTab}
2062-
packageBound={!!packageId}
2063-
/>
2082+
<DraftVerdictContext.Provider value={servedVerdict}>
2083+
<PermissionAdvancedFacets
2084+
draft={draft}
2085+
setDraft={setDraft}
2086+
writable={writable}
2087+
allSetNames={allSetNames}
2088+
loadObjectFields={loadObjectFields}
2089+
onCelErrorsChange={setCelErrorCount}
2090+
t={t}
2091+
only={facetTab}
2092+
packageBound={!!packageId}
2093+
/>
2094+
</DraftVerdictContext.Provider>
20642095
</div>
20652096
</div>
20662097
)}
@@ -2222,16 +2253,18 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
22222253
{/* Advanced facets (ADR-0056 P3) — RLS / tab visibility / delegated
22232254
admin scope, structured editors instead of raw JSON. Collapsed by
22242255
default so they don't crowd the object matrix. */}
2225-
<PermissionAdvancedFacets
2226-
draft={draft}
2227-
setDraft={setDraft}
2228-
writable={writable}
2229-
allSetNames={allSetNames}
2230-
loadObjectFields={loadObjectFields}
2231-
onCelErrorsChange={setCelErrorCount}
2232-
t={t}
2233-
packageBound={!!packageId}
2234-
/>
2256+
<DraftVerdictContext.Provider value={servedVerdict}>
2257+
<PermissionAdvancedFacets
2258+
draft={draft}
2259+
setDraft={setDraft}
2260+
writable={writable}
2261+
allSetNames={allSetNames}
2262+
loadObjectFields={loadObjectFields}
2263+
onCelErrorsChange={setCelErrorCount}
2264+
t={t}
2265+
packageBound={!!packageId}
2266+
/>
2267+
</DraftVerdictContext.Provider>
22352268
</div>
22362269
{/* ADR-0056 P4 — assigning users is a Setup act, so the DESIGN surface
22372270
(Studio, embedded) never shows it. objectui#7611 — the Setup catalog

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ import { useMetadataLocale, t, tFormat, translateMetadataType, translateValidati
147147
import { JsonSourceEditor } from './JsonSourceEditor.js';
148148
import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js';
149149
import {
150+
DraftVerdictContext,
150151
draftEnvelopeDiagnostics,
151152
editedItemDiagnostics,
152153
withServerOnlyErrors,
@@ -2643,7 +2644,7 @@ function MetadataResourceEditPageImpl({
26432644
</>
26442645
);
26452646

2646-
return (
2647+
const page = (
26472648
<PageShell
26482649
entry={entry ?? { type, label: type }}
26492650
itemName={createMode ? '(new)' : name}
@@ -3536,6 +3537,9 @@ function MetadataResourceEditPageImpl({
35363537
</AlertDialog>
35373538
</PageShell>
35383539
);
3540+
// objectstack#26858 — the verdict on the body on screen, for an expression
3541+
// field to read at its own path (`useServerErrorsAt`).
3542+
return <DraftVerdictContext.Provider value={serverDiagnostics}>{page}</DraftVerdictContext.Provider>;
35393543
}
35403544

35413545
/**

‎ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* the live client parse.
2020
*/
2121

22+
import * as React from 'react';
23+
2224
/** One `_diagnostics` entry, as served (`MetadataValidationResult`). */
2325
export interface ServerDiagnosticEntry {
2426
path: string;
@@ -75,3 +77,35 @@ export function withServerOnlyErrors<T extends { path: string; message: string }
7577
const extra = (serverErrors ?? []).filter((e) => !seen.has(`${e.path}\n${e.message}`));
7678
return [...clientErrors, ...extra];
7779
}
80+
81+
/**
82+
* objectstack#26858 — the served verdict on the body on screen, for a field
83+
* that edits one path of it. The page holding the draft read provides it
84+
* (`ResourceEditPage`, the permission-set editor); a field asks for the errors
85+
* at its own item-relative path with {@link useServerErrorsAt}. Absent outside
86+
* such a page, so a field mounted anywhere else reads none.
87+
*/
88+
export const DraftVerdictContext = React.createContext<ServerDiagnostics | undefined>(undefined);
89+
90+
const NO_ERRORS: readonly ServerDiagnosticEntry[] = [];
91+
92+
/**
93+
* The served errors located exactly at `path` — the dotted, item-relative
94+
* spelling `_diagnostics` uses (`rowLevelSecurity.0.using`, `edges.1.condition`).
95+
* Equality, never a prefix: a sibling's error (`edges.10.condition`, the same
96+
* policy's `check`) is not this field's. ⛔ Reads the path the server wrote;
97+
* no rule is restated here.
98+
*/
99+
export function serverErrorsAt(
100+
diagnostics: ServerDiagnostics | undefined,
101+
path: string | undefined,
102+
): readonly ServerDiagnosticEntry[] {
103+
if (!path || !diagnostics?.errors?.length) return NO_ERRORS;
104+
const hits = diagnostics.errors.filter((e) => e.path === path);
105+
return hits.length ? hits : NO_ERRORS;
106+
}
107+
108+
/** {@link serverErrorsAt} against the verdict the enclosing page provides. */
109+
export function useServerErrorsAt(path: string | undefined): readonly ServerDiagnosticEntry[] {
110+
return serverErrorsAt(React.useContext(DraftVerdictContext), path);
111+
}

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowEdgeInspector.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { useConnectorRegistry } from './connector-input-fields.js';
4848
import { VariableTextInput } from './VariableTextInput.js';
4949
import { findUnknownRefs, scopeRoots, describeUnknownRefs } from './flow-ref-check.js';
5050
import { writeExpressionSource } from './expression-envelope.js';
51+
import { useServerErrorsAt } from '../draftVerdict.js';
5152
import { describeEdgeConnectionRefusal, edgeConnectionRefusal } from '../previews/flow-problems.js';
5253
import { flowEdgeDisplayName } from '../previews/flow-canvas-parts.js';
5354
// objectui#7265 — the selected edge as this panel reads it out of the draft.
@@ -89,6 +90,9 @@ export function FlowEdgeInspector({
8990
// time it evaluates this guard (objectui#11085). Called unconditionally — a
9091
// missing edge resolves the flow variables alone.
9192
const { groups: scopeGroups } = useEdgeScope(draft as Record<string, unknown>, edge, connectors);
93+
// objectstack#26858 — what the page's served verdict says at this edge's
94+
// `condition` (an `expression-invalid` its publish would refuse), as written.
95+
const conditionVerdict = useServerErrorsAt(index >= 0 ? `edges.${index}.condition` : undefined);
9296

9397
if (!edge) {
9498
return (
@@ -330,6 +334,11 @@ export function FlowEdgeInspector({
330334
</p>
331335
) : null;
332336
})()}
337+
{conditionVerdict.map((e, i) => (
338+
<p key={i} className="text-[11px] leading-snug text-destructive" role="alert" data-testid="flow-edge-server-error">
339+
{e.message}
340+
</p>
341+
))}
333342
<InspectorCheckboxField
334343
label={t('engine.inspector.flowEdge.isDefault', locale)}
335344
value={isDefault}

0 commit comments

Comments
 (0)