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({

    ) : null; })()} + {conditionVerdict.map((e, i) => ( +

    + {e.message} +

    + ))} Date: Tue, 27 Oct 2026 23:43:28 +0000 Subject: [PATCH 3/9] wip: tests and changeset for the served verdict at a field's path Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx Co-Authored-By: Claude --- .../26858-edge-condition-finding-path.md | 15 ++ ...nMatrixEditor.serverVerdict-26858.test.tsx | 134 +++++++++++++ ...ourceEditPage.serverVerdict-26858.test.tsx | 107 +++++++++++ .../serverVerdictAtPath-26858.test.tsx | 178 ++++++++++++++++++ 4 files changed, 434 insertions(+) create mode 100644 .changeset/26858-edge-condition-finding-path.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdict-26858.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverVerdict-26858.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx 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/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..9b8ce3b48ae --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdict-26858.test.tsx @@ -0,0 +1,134 @@ +// 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 }; +} + +type FakeClient = Record Promise>; + +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; + }, + 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/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/serverVerdictAtPath-26858.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx new file mode 100644 index 00000000000..8508eb458c9 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26858 — an expression field reads the server's verdict at its + * own path. + * + * Since objectstack#26707 a draft's `_diagnostics` carries what its publish + * will refuse, and the banner and canvas Problems panel show it. The fields + * themselves still printed their own client verdict: the RLS clause editor + * said "Valid CEL" for a predicate over a formula field that publish refuses + * (`rls-predicate-unenforceable`), and the flow edge condition said nothing + * for a condition publish refuses (`expression-invalid`). Each field now asks + * the page's served verdict (`DraftVerdictContext`) for the errors at exactly + * its item-relative path, shows the server's message and withholds "Valid + * CEL". ⛔ No rule is restated client-side: the verdicts below are served + * doubles, and the fields are handed nothing but the path. + * + * Every absence is read against a presence from the same render: the clause + * the verdict names beside the sibling clause it does not. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('./previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { PermissionAdvancedFacets } from './PermissionAdvancedFacets'; +import { FlowEdgeInspector } from './inspectors/FlowEdgeInspector'; +import { DraftVerdictContext, serverErrorsAt, type ServerDiagnostics } from './draftVerdict'; +import type { MetadataSelection } from './preview-registry'; + +afterEach(cleanup); + +const t = (k: string) => k; +const VALID = 'perm.cel.valid'; + +const RLS_REFUSAL = + "rowLevelSecurity 'hot_deals' using reads 'deal_score', a formula field the database cannot filter on"; + +function verdict(errors: ServerDiagnostics['errors']): ServerDiagnostics { + return { valid: false, errors }; +} + +const CLEAN = 'organization_id == current_user.organization_id'; + +async function renderRls(served: ServerDiagnostics | undefined, policies = 1) { + const user = userEvent.setup(); + const rows = Array.from({ length: policies }, (_, i) => ({ + name: `p${i}`, + object: 'account', + operation: 'all', + using: CLEAN, + check: CLEAN, + enabled: true, + })); + const facets = ( + {}, + writable: true, + allSetNames: [] as string[], + loadObjectFields: async () => ['organization_id', 'owner_id'], + t, + } as unknown as React.ComponentProps)} + /> + ); + render(served === undefined ? facets : {facets}); + await user.click(screen.getByText('perm.rls.title')); +} + +/** The editor wrapping each clause, in render order: policy 0 USING, policy 0 CHECK, policy 1 USING, … */ +const clauseBoxes = () => + screen.getAllByRole('combobox').map((ta) => ta.closest('div')!.parentElement as HTMLElement); + +describe('the RLS clause editor reads the served verdict at its path (objectstack#26858)', () => { + it('a USING clause the server refuses shows the server message and no "Valid CEL"; its CHECK still reads valid', async () => { + await renderRls(verdict([{ path: 'rowLevelSecurity.0.using', message: RLS_REFUSAL, code: 'rls-predicate-unenforceable' }])); + // The CHECK clause's lint answered clean: the lint has spoken on both. + await waitFor(() => expect(screen.getAllByText(VALID)).toHaveLength(1), { timeout: 3000 }); + const [using, check] = clauseBoxes(); + expect(within(using).getByText(RLS_REFUSAL)).toBeInTheDocument(); + expect(within(using).queryByText(VALID)).toBeNull(); + expect(within(using).getByRole('combobox')).toHaveAttribute('aria-invalid', 'true'); + expect(within(check).getByText(VALID)).toBeInTheDocument(); + expect(within(check).queryByText(RLS_REFUSAL)).toBeNull(); + }); + + it('a verdict at another policy\'s clause does not leak to this one', async () => { + await renderRls(verdict([{ path: 'rowLevelSecurity.1.using', message: RLS_REFUSAL, code: 'rls-predicate-unenforceable' }]), 2); + await waitFor(() => expect(screen.getAllByText(VALID)).toHaveLength(3), { timeout: 3000 }); + const boxes = clauseBoxes(); + expect(within(boxes[0]).getByText(VALID)).toBeInTheDocument(); + expect(within(boxes[0]).queryByText(RLS_REFUSAL)).toBeNull(); + expect(within(boxes[2]).getByText(RLS_REFUSAL)).toBeInTheDocument(); + }); + + it('control: no verdict at its path — or no verdict at all — and the clause still reads "Valid CEL"', async () => { + await renderRls(verdict([{ path: 'objects.account.allowRead', message: 'unrelated', code: 'x' }])); + await waitFor(() => expect(screen.getAllByText(VALID)).toHaveLength(2), { timeout: 3000 }); + cleanup(); + await renderRls(undefined); + await waitFor(() => expect(screen.getAllByText(VALID)).toHaveLength(2), { timeout: 3000 }); + }); +}); + +const EDGE_REFUSAL = "Unexpected token: EOF — source: `record.name ==`"; + +function edgeDraft() { + return { + variables: [{ name: 'threshold', type: 'number' }], + nodes: [ + { id: 'start', type: 'start' }, + { id: 'decide', type: 'decision' }, + { id: 'end', type: 'end' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'decide' }, + { id: 'e2', source: 'decide', target: 'end', type: 'conditional', condition: 'record.name ==' }, + ], + }; +} + +function renderEdge(id: string, served: ServerDiagnostics | undefined) { + const selection: MetadataSelection = { kind: 'edge', id }; + const inspector = ( + + ); + return render( + served === undefined ? inspector : {inspector}, + ); +} + +describe('the flow edge condition reads the served verdict at its path (objectstack#26858)', () => { + const served = verdict([{ path: 'edges.1.condition', message: EDGE_REFUSAL, code: 'expression-invalid' }]); + + it('the refused edge shows the server message under its condition', () => { + renderEdge('e2', served); + expect(screen.getByTestId('flow-edge-server-error')).toHaveTextContent(EDGE_REFUSAL); + }); + + it('the other edge of the same flow shows nothing', () => { + renderEdge('e1', served); + expect(screen.queryByText(EDGE_REFUSAL)).toBeNull(); + }); + + it('control: with no served verdict the refused edge shows nothing from the server', () => { + renderEdge('e2', undefined); + expect(screen.queryByTestId('flow-edge-server-error')).toBeNull(); + }); +}); + +describe('serverErrorsAt — equality on the served path, never a prefix', () => { + const served = verdict([ + { path: 'edges.1.condition', message: 'a' }, + { path: 'edges.10.condition', message: 'b' }, + { path: 'edges.1', message: 'c' }, + ]); + it('returns only the entries at exactly the path', () => { + expect(serverErrorsAt(served, 'edges.1.condition').map((e) => e.message)).toEqual(['a']); + expect(serverErrorsAt(served, 'edges.2.condition')).toEqual([]); + expect(serverErrorsAt(served, undefined)).toEqual([]); + expect(serverErrorsAt(undefined, 'edges.1.condition')).toEqual([]); + }); +}); From 2df2f42faa8ece4a0f4e40a56d2c7fd3a5a7a645 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:47:08 +0000 Subject: [PATCH 4/9] wip(app-shell): the Automations page provides the served flow verdict Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx Co-Authored-By: Claude --- ...mationsPillar.serverVerdict-26858.test.tsx | 180 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 65 +++++-- 2 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/AutomationsPillar.serverVerdict-26858.test.tsx diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.serverVerdict-26858.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.serverVerdict-26858.test.tsx new file mode 100644 index 00000000000..331e12204c6 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.serverVerdict-26858.test.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26858 — on the Studio Automations page, the flow edge condition + * shows what the pending draft's publish will refuse at that condition. + * + * The page reads the flow's draft itself (it is not the metadata edit page), + * so the verdict the draft read serves since objectstack#26707 never reached + * an inspector. The page now keeps that verdict, provides it to the inspector + * (`DraftVerdictContext`), and reads it again after each draft save. + * + * The inspector is the real registered `FlowInspector`, which opens + * `FlowEdgeInspector` for an edge; the canvas is a stand-in that selects an + * edge and edits the flow on demand. The client is a server double whose draft + * read judges a stored draft the way `expression-invalid` does: it refuses an + * edge condition that does not parse, at `edges.N.condition`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; +const BROKEN = 'record.name =='; +const REFUSAL = 'Unexpected token: EOF — source: `record.name ==`'; + +const FLOW = { + name: 'edge_cel', + label: 'Edge CEL', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'decide', type: 'decision', label: 'Decide' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'decide' }, + { id: 'e2', source: 'decide', target: 'end', type: 'conditional', condition: BROKEN }, + ], +}; + +const server = vi.hoisted(() => ({ draft: null as Record | null, draftReads: 0 })); + +/** The publish verdict on a stored draft: an edge condition that does not parse is refused at its path. */ +function verdictOf(body: Record) { + const edges = (body.edges ?? []) as Array<{ condition?: string }>; + const errors = edges.flatMap((e, i) => + e.condition === BROKEN ? [{ path: `edges.${i}.condition`, message: REFUSAL, code: 'expression-invalid' }] : [], + ); + return errors.length ? { valid: false, errors } : { valid: true }; +} + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async () => [{ name: 'edge_cel', label: 'Edge CEL' }]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ code: null, overlay: null, overlayScope: null, effective: null, editable: true })), + getDraft: vi.fn(async (type: string, name: string) => { + server.draftReads += 1; + if (!server.draft) { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + } + return { type, name, item: { ...server.draft, _draft: true, _diagnostics: verdictOf(server.draft) } }; + }), + save: vi.fn(async (type: string, name: string, item: unknown) => { + server.draft = JSON.parse(JSON.stringify(item)) as Record; + return { type, name, item }; + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { getMetadataPreview, registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; + +const dataSource = createEmptyDataSource(); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +/** Canvas stand-in: selects the conditional edge, or rewrites its condition. */ +function StubCanvas({ + onSelectionChange, + onPatch, +}: { + onSelectionChange?: (s: { kind: 'edge'; id: string; label?: string }) => void; + onPatch?: (patch: Record) => void; +}) { + return ( + <> + + + + + ); +} + +const realFlow = getMetadataPreview('flow'); +registerMetadataInspector('flow', FlowInspector); + +beforeEach(() => { + server.draft = JSON.parse(JSON.stringify(FLOW)); + server.draftReads = 0; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + registerMetadataPreview('flow', StubCanvas as never); +}); + +afterEach(() => { + cleanup(); + if (realFlow) registerMetadataPreview('flow', realFlow); +}); + +function renderPillar() { + render( + + + , + ); +} + +describe('Automations page — the edge condition shows the draft\'s served verdict at its path (objectstack#26858)', () => { + it('the refused edge shows the server message; the other edge shows none', async () => { + renderPillar(); + fireEvent.click(await screen.findByRole('button', { name: 'select e2' }, { timeout: 8000 })); + expect(await screen.findByTestId('flow-edge-server-error', {}, { timeout: 8000 })).toHaveTextContent(REFUSAL); + + fireEvent.click(screen.getByRole('button', { name: 'select e1' })); + await waitFor(() => expect(screen.queryByTestId('flow-edge-server-error')).toBeNull()); + }); + + it('the verdict follows the draft: after the autosave of a fixed condition the refusal goes', async () => { + renderPillar(); + fireEvent.click(await screen.findByRole('button', { name: 'select e2' }, { timeout: 8000 })); + await screen.findByTestId('flow-edge-server-error', {}, { timeout: 8000 }); + const readsBefore = server.draftReads; + + fireEvent.click(screen.getByRole('button', { name: 'fix e2' })); + + await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 6000 }); + await waitFor(() => expect(server.draftReads).toBeGreaterThan(readsBefore), { timeout: 6000 }); + await waitFor(() => expect(screen.queryByTestId('flow-edge-server-error')).toBeNull(), { timeout: 6000 }); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 2ed269061bd..f8f4506c099 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -225,6 +225,13 @@ import { getMetadataResource } from '../metadata-admin/registry.js'; import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata.js'; // objectui#11773 — every draft save of an existing item sends the version its buffer was built on. import { readDraftForSave, useDraftSaveGuard } from '../metadata-admin/DraftConflictDialog.js'; +// objectstack#26858 — the served verdict a flow's inspectors read at their own paths. +import { + DraftVerdictContext, + draftEnvelopeDiagnostics, + editedItemDiagnostics, + type ServerDiagnostics, +} from '../metadata-admin/draftVerdict.js'; import { DESIGNER_SURFACE_PARAM, formatSurfaceParam, @@ -7325,6 +7332,20 @@ export function AutomationsPillar({ const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); const [hasDraft, setHasDraft] = React.useState(false); + // objectstack#26858 — the server's verdict on the flow on screen: the pending + // draft's (what its publish will refuse), else the layered read's. Taken off + // each read, never computed here; the inspectors read it at their paths. + const [flowVerdict, setFlowVerdict] = React.useState(undefined); + const flowVerdictSeq = React.useRef(0); + const rereadFlowVerdict = React.useCallback( + (flowName: string) => { + const seq = ++flowVerdictSeq.current; + void readDraftForSave(client, 'flow', flowName, draftPackageId).then((read) => { + if (seq === flowVerdictSeq.current) setFlowVerdict(draftEnvelopeDiagnostics(read.answer)); + }); + }, + [client, draftPackageId], + ); // objectui#11785 — a load failure as it was (`plainRefusal`), a refused save // as an author sentence naming the step and its input, raw text behind Details. const [error, setError] = React.useState(null); @@ -7555,6 +7576,14 @@ export function AutomationsPillar({ // Served draft as-is, baseline only without one (objectui#10765). setDraft(draftBody ?? baseline); setDraftFor(`flow:${current.name}`); + flowVerdictSeq.current += 1; + setFlowVerdict( + editedItemDiagnostics({ + hasDraft: !!draftBody, + draftDiagnostics: draftEnvelopeDiagnostics(draftRead.answer), + layered: layRaw, + }), + ); // objectui#11773 — the next save is pinned to what the draft read served. recordFlowRead('flow', current.name, draftPackageId, draftRead.pin); setHasDraft(!!draftBody); @@ -7594,6 +7623,8 @@ export function AutomationsPillar({ // objectui#11773 — the author chose the saved version; the load replaces the buffer. if (outcome === 'reloaded') return; setHasDraft(true); + // objectstack#26858 — the stored draft carries its own verdict now. + rereadFlowVerdict(current.name); // objectui#11204 — clean only if nothing was edited while it was in flight. if (sent.unmoved()) setAutoDirty(false); onDraftSaved?.(); @@ -7603,7 +7634,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale]); + }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, rereadFlowVerdict]); // objectui#11786 — an edit that leaves a step's required input out (a step // just added, before it is filled in) is held, not sent: the server would // refuse the flow with a 422 naming that input. The step's inspector hints the @@ -7663,6 +7694,7 @@ export function AutomationsPillar({ // objectui#11773 — a reload replaced the buffer the flip was taken on. if ((await saveFlowDraft('flow', flowName, nextDraft, { mode: 'draft', packageId: draftPackageId, surface: 'studio' })) === 'reloaded') return; setHasDraft(true); + rereadFlowVerdict(flowName); onDraftSaved?.(); toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale)); } catch (e) { @@ -7680,7 +7712,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft]); + }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft, rereadFlowVerdict]); // The rail's "+" — and the empty block's button, which runs this same // handler (objectui#12514). objectui#11553 — no "New" in the package-less @@ -7738,19 +7770,22 @@ export function AutomationsPillar({ // row, in the drawer over it (objectui#11795). const configBodyEl = selection && inspector && current && flowLoaded ? ( - React.createElement(inspector, { - type: 'flow', - name: current.name, - draft, - selection, - onPatch, - onClearSelection: () => setSelection(null), - onSelectionChange: setSelection, - // objectui#11124 — the pillar's real flag, threaded exactly as - // the Data pillar threads it (objectui#2259). - readOnly, - locale, - }) + // objectstack#26858 — the inspectors read the served verdict at their paths. + + {React.createElement(inspector, { + type: 'flow', + name: current.name, + draft, + selection, + onPatch, + onClearSelection: () => setSelection(null), + onSelectionChange: setSelection, + // objectui#11124 — the pillar's real flag, threaded exactly as + // the Data pillar threads it (objectui#2259). + readOnly, + locale, + })} + ) : designersUnregistered ? (
    From afe8f6fba2717924ca0681645fd34a97784a619f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:49:19 +0000 Subject: [PATCH 5/9] wip(app-shell): pick the CEL textareas, not the operation picker, in the RLS pins Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx Co-Authored-By: Claude --- .../metadata-admin/serverVerdictAtPath-26858.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx index 8508eb458c9..60322f8cc75 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/serverVerdictAtPath-26858.test.tsx @@ -73,9 +73,13 @@ async function renderRls(served: ServerDiagnostics | undefined, policies = 1) { await user.click(screen.getByText('perm.rls.title')); } -/** The editor wrapping each clause, in render order: policy 0 USING, policy 0 CHECK, policy 1 USING, … */ +/** The editor wrapping each clause, in render order: policy 0 USING, policy 0 CHECK, policy 1 USING, … + * (the CEL textareas only — the operation picker is a combobox too). */ const clauseBoxes = () => - screen.getAllByRole('combobox').map((ta) => ta.closest('div')!.parentElement as HTMLElement); + screen + .getAllByRole('combobox') + .filter((el) => el.tagName === 'TEXTAREA') + .map((ta) => ta.closest('div')!.parentElement as HTMLElement); describe('the RLS clause editor reads the served verdict at its path (objectstack#26858)', () => { it('a USING clause the server refuses shows the server message and no "Valid CEL"; its CHECK still reads valid', async () => { From ef55ac5900296cd26bb8a79c5bc2b2f2268b8314 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:42:09 +0000 Subject: [PATCH 6/9] test(rest): give the nested-relation cap case a 30 s budget (ported fix for #27037) The case seeds RELATION_FILTER_ID_CAP (1,000) rows and deletes 1,001: 2.3 s alone, past the default 5 s on a loaded full-suite queue shard, which ejected #26960, #27011 and #27020 (#27037). The slowdown itself is #27004's (since #26930 a 1,001-row predicate delete is about 18x slower); this only budgets for it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QUs3Bshfmr8srPY21BVS4u (cherry picked from commit 6674ba8e098bfc99513a0004960ca23436015ede) --- packages/rest/src/data-nested-object-door.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/data-nested-object-door.test.ts b/packages/rest/src/data-nested-object-door.test.ts index 956ac5ee152..0ff8d7b6186 100644 --- a/packages/rest/src/data-nested-object-door.test.ts +++ b/packages/rest/src/data-nested-object-door.test.ts @@ -309,7 +309,8 @@ for (const cell of CELLS) { await engine.delete(OBJECT, { where: { id: 'd_cap' } } as any); await engine.delete(OWNER, { where: { region: { $in: ['CAP', 'CAP_EXTRA'] } }, multi: true } as any); } - }); + // Budget for the 1,000-row seed and its 1,001-row delete on a loaded full-suite shard (#27037); the slowdown itself is #27004's. + }, 30_000); it('CONTROL the routes still answer the rows, and a file field\'s object reaches the driver unjudged', async () => { const na = await query({ where: { region: 'NA' } }, OWNER); From 80a1dae1e3c8a89217ff0f9b69acb5d6235d54d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:10:51 +0000 Subject: [PATCH 7/9] test(app-shell): the 26858 matrix fake answers withPreviewDrafts, as the package door now reads through it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx --- .../PermissionMatrixEditor.serverVerdict-26858.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 9b8ce3b48ae..09ebf5c1be3 100644 --- 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 @@ -46,7 +46,8 @@ function verdictOf(body: Record) { return errors.length ? { valid: false, errors } : { valid: true }; } -type FakeClient = Record Promise>; +// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840). +type FakeClient = Record unknown>; function makeClient(server: Server): FakeClient { return { @@ -57,6 +58,8 @@ function makeClient(server: Server): FakeClient { ? { 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) => { From 06b408abd57cffbff10d907bedfd5b0f433607d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:46:42 +0000 Subject: [PATCH 8/9] test(app-shell): give #27010's and #26837's matrix doubles the draft-overlaid read Both new package-door tests landed beside #26840, whose load now reads the package's objects through MetadataClient.withPreviewDrafts; their fake clients lacked it, which turned main red on Test UI. Co-Authored-By: Claude (cherry picked from commit 039a8750208725e581a28bcf0498d84bea051f2b) --- .../PermissionMatrixEditor.objectGrantInherited-27010.test.tsx | 2 ++ .../PermissionMatrixEditor.packageDoorDraftBase-26837.test.tsx | 2 ++ 2 files changed, 4 insertions(+) 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..eeb7820f968 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, From 5aa7e9722995abe0a6c970f86f1166b07fd86985 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:28:02 +0000 Subject: [PATCH 9/9] test(app-shell): the package-door draft-base pin counts the verdict re-read after the save Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbYiz7aUf8wSAnnRyMx --- ...ermissionMatrixEditor.packageDoorDraftBase-26837.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 eeb7820f968..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 @@ -170,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(); });