From 8bd055e575edff9ee8383f27c7cef45796ab71a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:18:42 +0000 Subject: [PATCH 1/2] fix(app-shell): hold the permission-set autosave on a server error at a CEL clause's path The draft read's verdict reached the RLS clause's display (objectstack#26858) but never the count that holds the autosave, so a predicate the publish refuses kept saving. The clause now reports the served errors at its own path as blocking while it still shows the text that verdict judged. Claude-Session: https://claude.ai/code/session_01Utp6CaN9smHqkJcUWF4adR Co-authored-by: Claude --- .../metadata-admin/CelPredicateField.tsx | 50 ++++- ...rixEditor.serverVerdictHold-27482.test.tsx | 174 ++++++++++++++++++ .../metadata-admin/PermissionMatrixEditor.tsx | 75 +++++--- .../views/metadata-admin/draftVerdict.test.ts | 41 ++++- .../src/views/metadata-admin/draftVerdict.ts | 52 ++++++ 5 files changed, 359 insertions(+), 33 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdictHold-27482.test.tsx 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 eae42795f22..bab4d9cae39 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx @@ -54,7 +54,7 @@ import { type CelSuggestionKind, type CelValueType, } from './celAuthoring.js'; -import { useServerErrorsAt } from './draftVerdict.js'; +import { useServerErrorsAt, useServerHoldAt, type ServerDiagnosticEntry } from './draftVerdict.js'; const LINT_DEBOUNCE_MS = 250; @@ -124,11 +124,21 @@ export interface CelPredicateFieldProps { * 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. + * CEL". The lint and its findings are unaffected, as with `scopeIssue`. + * Omitted: no server verdict is read. + * + * objectstack#27482 — and while the text on screen is the text that verdict + * judged (`DraftVerdictBodyContext`), each of those errors is also reported + * through `onLintChange` as a blocking error, so the host's Save gate holds + * on what publish will refuse exactly as on a parse fault. Once the author + * edits the text away the verdict no longer describes it and holds nothing: + * the save goes out, and its re-read brings the next verdict. */ verdictPath?: string; - /** Reports the current lint issues up so the editor can gate Save on errors. */ + /** + * Reports the current lint issues up so the editor can gate Save on errors — + * plus the server's errors at {@link verdictPath} on the judged text. + */ onLintChange?: (issues: CelLintIssue[]) => void; /** * `role="value"` only: reports the inferred result type after each @@ -206,14 +216,42 @@ export function CelPredicateField({ const fieldsKey = fieldNames.join(','); const rootsKey = roots ? roots.join(',') : ''; + // objectstack#27482 — the server's errors at this editor's path while the + // text on screen is the text they judged; they ride the lint report up. + const held = useServerHoldAt(verdictPath, value); + const heldKey = held.map((e) => e.message).join('\n'); + // Keep the reporters out of the lint effect deps (parent may not memoize them). const onLintChangeRef = React.useRef(onLintChange); const onInferredTypeChangeRef = React.useRef(onInferredTypeChange); + const heldRef = React.useRef(held); React.useEffect(() => { onLintChangeRef.current = onLintChange; onInferredTypeChangeRef.current = onInferredTypeChange; + heldRef.current = held; }); + // The last lint result, so a verdict that arrives or lapses between two + // lint passes is reported against it. + const lintRef = React.useRef([]); + const report = React.useCallback((lint: CelLintIssue[]) => { + lintRef.current = lint; + const server = heldRef.current; + onLintChangeRef.current?.( + server.length === 0 + ? lint + : [...lint, ...server.map((e): CelLintIssue => ({ severity: 'error', message: e.message }))], + ); + }, []); + // Keyed on the messages, not on the array (rebuilt every render): report + // when the hold starts, changes or lapses, never on mount with none. + const reportedHeldRef = React.useRef(''); + React.useEffect(() => { + if (heldKey === reportedHeldRef.current) return; + reportedHeldRef.current = heldKey; + report(lintRef.current); + }, [heldKey, report]); + /* Debounced lint (and, for role="value", result-type inference). */ React.useEffect(() => { let cancelled = false; @@ -223,7 +261,7 @@ export function CelPredicateField({ if (cancelled) return; setIssues(res); setLinted(true); - onLintChangeRef.current?.(res); + report(res); }); if (role === 'value') { inferCelValueType(value, hint).then((res) => { @@ -246,7 +284,7 @@ export function CelPredicateField({ setIssues([]); setLinted(false); setInferredType(null); - onLintChangeRef.current?.([]); + report([]); if (role === 'value') onInferredTypeChangeRef.current?.(null); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdictHold-27482.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdictHold-27482.test.tsx new file mode 100644 index 00000000000..bc4c66d3ee3 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdictHold-27482.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27482 — a server error at an RLS clause's own path holds the + * package door's autosave, the way a CEL parse fault does. + * + * objectstack#26858 made the clause SHOW the draft read's verdict; it never + * reached `celErrorCount`, so an expression the publish refuses (a USING that + * reads a formula field) kept autosaving with the server's message on screen. + * The verdict judges the stored draft and nothing else, so it holds while the + * clause still shows the text it judged, and lets the edit that changes that + * text go out: only that save brings the next verdict. ⛔ The fake door below + * is the only place the refusal is decided; the editor restates no rule. + */ + +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 = 'f_total > 5'; +const ACCEPTED_USING = 'owner_id == current_user.id'; +const REFUSAL = "rowLevelSecurity 'hot_deals' using reads 'f_total', 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; + /** Every body a save sent. */ + sent: Array>; +} + +/** The door's publish verdict on a stored draft: it refuses the one USING clause. */ +function verdictOf(body: Record) { + const rows = (body.rowLevelSecurity ?? []) as Array<{ using?: string }>; + const errors = rows.flatMap((r, i) => + r.using === REFUSED_USING + ? [{ path: `rowLevelSecurity.${i}.using`, message: REFUSAL, code: 'rls-predicate-unenforceable' }] + : [], + ); + return errors.length ? { valid: false, errors } : { valid: true }; +} + +// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840). +type FakeClient = Record unknown>; + +function makeClient(server: Server): FakeClient { + return { + layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }), + getDraft: async () => { + server.draftReads += 1; + return server.draft + ? { type: 'permission', name: 'sales_perms', item: { ...server.draft, _draft: true, _diagnostics: verdictOf(server.draft) } } + : null; + }, + withPreviewDrafts() { return this; }, + list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []), + get: async () => null, + save: async (_type: string, _name: string, payload: Record) => { + const body = JSON.parse(JSON.stringify(payload)) as Record; + server.sent.push(body); + server.draft = body; + 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'; +const CHECK_PLACEHOLDER = 'optional — defaults to USING'; +/** The shared autosave fires 1.5s after the last edit. */ +const AUTOSAVE = { timeout: 5000 }; +/** Longer than the autosave's pause. */ +const PAST_THE_PAUSE = 2500; +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const policy = (using: string) => ({ + ...SET, + rowLevelSecurity: [{ name: 'hot_deals', object: 'a_account', operation: 'select', using, enabled: true }], +}); + +async function openPackageDoor(server: Server) { + clientImpl = makeClient(server); + render( + + + , + ); + await screen.findByText('a_account'); + fireEvent.click(screen.getByRole('button', { name: /Row-Level Security/ })); +} + +const edit = (placeholder: string, value: string) => + fireEvent.change(screen.getByPlaceholderText(placeholder), { target: { value } }); + +describe('a server error at a clause path holds the autosave (objectstack#27482)', () => { + it('(a) a stored draft the publish refuses: another edit is held, no PUT, the server message stays', async () => { + const server: Server = { draft: policy(REFUSED_USING), draftReads: 0, sent: [] }; + await openPackageDoor(server); + await screen.findByText(REFUSAL); + + edit(CHECK_PLACEHOLDER, ACCEPTED_USING); + + await waitFor(() => expect(screen.getByTestId('perm-autosave-held')).toBeInTheDocument(), AUTOSAVE); + await pause(PAST_THE_PAUSE); + expect(server.sent).toHaveLength(0); + expect(screen.getByText(REFUSAL)).toBeInTheDocument(); + }); + + it('(b) the card flow: the refused clause saves once, its re-read holds the next edit', async () => { + const server: Server = { draft: policy(ACCEPTED_USING), draftReads: 0, sent: [] }; + await openPackageDoor(server); + await screen.findByText('Valid CEL', {}, { timeout: 3000 }); + + // No client copy of the rule: the lint passes it and the first save goes out. + edit(USING_PLACEHOLDER, REFUSED_USING); + await waitFor(() => expect(server.sent).toHaveLength(1), AUTOSAVE); + expect(await screen.findByText(REFUSAL, {}, AUTOSAVE)).toBeInTheDocument(); + + edit(CHECK_PLACEHOLDER, ACCEPTED_USING); + await waitFor(() => expect(screen.getByTestId('perm-autosave-held')).toBeInTheDocument(), AUTOSAVE); + await pause(PAST_THE_PAUSE); + expect(server.sent).toHaveLength(1); + }); + + it('(c) editing the clause away releases: the save goes out and its clean re-read clears the verdict', async () => { + const server: Server = { draft: policy(REFUSED_USING), draftReads: 0, sent: [] }; + await openPackageDoor(server); + await screen.findByText(REFUSAL); + + edit(USING_PLACEHOLDER, ACCEPTED_USING); + + await waitFor(() => expect(server.sent).toHaveLength(1), AUTOSAVE); + await waitFor(() => expect(screen.queryByText(REFUSAL)).toBeNull(), AUTOSAVE); + expect(await screen.findByTestId('perm-saved-at', {}, AUTOSAVE)).toBeInTheDocument(); + expect(screen.queryByTestId('perm-autosave-held')).toBeNull(); + }); + + it('(d) typed back to the judged text before the autosave fires: held again, no PUT', async () => { + const server: Server = { draft: policy(REFUSED_USING), draftReads: 0, sent: [] }; + await openPackageDoor(server); + await screen.findByText(REFUSAL); + + edit(CHECK_PLACEHOLDER, ACCEPTED_USING); + edit(USING_PLACEHOLDER, 'f_total > 50'); + edit(USING_PLACEHOLDER, REFUSED_USING); + + await waitFor(() => expect(screen.getByTestId('perm-autosave-held')).toBeInTheDocument(), AUTOSAVE); + await pause(PAST_THE_PAUSE); + expect(server.sent).toHaveLength(0); + }); +}); 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 02be4e12ff7..91154a3be5e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -95,6 +95,7 @@ import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js'; // objectstack#26858 — the served verdict the RLS clause editors read at their paths. import { + DraftVerdictBodyContext, DraftVerdictContext, draftEnvelopeDiagnostics, editedItemDiagnostics, @@ -808,6 +809,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // 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); + // objectstack#27482 — the body that verdict judged, set beside it from the + // same read: an editor holds the save on a server error at its path only + // while it still shows the text judged here. + const [servedVerdictBody, setServedVerdictBody] = 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 @@ -948,6 +953,10 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, layered: lay, }), ); + // The same choice `editedItemDiagnostics` makes, for the body judged. + setServedVerdictBody( + pendingDraft && draftEnvelopeDiagnostics(pendingDraft) ? pendingDraftBody(pendingDraft) : lay?.effective, + ); // 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 @@ -1093,7 +1102,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // Count of blocking CEL parse errors in the RLS editor — gates Save // (objectui#2413): a malformed predicate silently mis-scopes rows, so we - // don't let it persist. + // don't let it persist. objectstack#27482 — it counts the served verdict's + // errors at a clause's path too, while that clause still shows the text + // the verdict judged (`CelPredicateField`'s `verdictPath`). const [celErrorCount, setCelErrorCount] = React.useState(0); // objectstack#25866 — blocking lint findings on the field rows' `when` // conditions, keyed `${object}.${field}`. Held like the RLS clauses: a @@ -1487,8 +1498,15 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, // 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)); + void readDraftForSave<{ item?: PermissionSetDraft } | PermissionSetDraft>( + client, + type, + payload.name, + packageId, + ).then((read) => { + if (seq !== verdictReadSeq.current) return; + setServedVerdict(draftEnvelopeDiagnostics(read.answer)); + setServedVerdictBody(pendingDraftBody(read.answer) ?? undefined); }); // Let the surface count it. onDraftSaved?.(); @@ -1522,6 +1540,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, resetDraftBaseline(toDisplayDraft((lay?.effective ?? toSave) as PermissionSetDraft)); verdictReadSeq.current += 1; setServedVerdict(editedItemDiagnostics({ hasDraft: false, draftDiagnostics: undefined, layered: lay })); + setServedVerdictBody(lay?.effective); 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 @@ -2238,18 +2257,20 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, @@ -2421,17 +2442,19 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, 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/draftVerdict.test.ts b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.test.ts index 2ae176be85b..9111bc3b56b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.test.ts @@ -1,7 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { draftEnvelopeDiagnostics, editedItemDiagnostics, withServerOnlyErrors } from './draftVerdict'; +import { + draftEnvelopeDiagnostics, + editedItemDiagnostics, + serverHoldAt, + valueAtPath, + withServerOnlyErrors, +} from './draftVerdict'; const refused = { valid: false, @@ -29,3 +35,36 @@ describe('draftVerdict (objectstack#26707)', () => { expect(withServerOnlyErrors(client, undefined)).toEqual(client); }); }); + +describe('serverHoldAt — a verdict holds only the text it judged (objectstack#27482)', () => { + const USING = 'f_total > 5'; + const judged = { name: 'sales_perms', rowLevelSecurity: [{ name: 'hot', using: USING }] }; + const diagnostics = { + valid: false, + errors: [{ path: 'rowLevelSecurity.0.using', message: 'cannot filter on f_total', code: 'rls-predicate-unenforceable' }], + }; + + it('reads a dotted path through arrays and objects, and answers undefined off the body', () => { + expect(valueAtPath(judged, 'rowLevelSecurity.0.using')).toBe(USING); + expect(valueAtPath(judged, 'rowLevelSecurity.1.using')).toBeUndefined(); + expect(valueAtPath(judged, 'rowLevelSecurity.x.using')).toBeUndefined(); + expect(valueAtPath(judged, 'name.length')).toBeUndefined(); + }); + + it('holds on the judged text, and not on any other', () => { + expect(serverHoldAt(diagnostics, judged, 'rowLevelSecurity.0.using', USING)).toEqual(diagnostics.errors); + expect(serverHoldAt(diagnostics, judged, 'rowLevelSecurity.0.using', 'f_total > 7')).toEqual([]); + }); + + it('holds nothing without a judged body, a path, or an error at that exact path', () => { + expect(serverHoldAt(diagnostics, undefined, 'rowLevelSecurity.0.using', USING)).toEqual([]); + expect(serverHoldAt(diagnostics, judged, undefined, USING)).toEqual([]); + expect(serverHoldAt(diagnostics, judged, 'rowLevelSecurity.0.check', '')).toEqual([]); + expect(serverHoldAt({ valid: true }, judged, 'rowLevelSecurity.0.using', USING)).toEqual([]); + }); + + it('reads an absent judged value as the empty text an editor shows', () => { + const atCheck = { valid: false, errors: [{ path: 'rowLevelSecurity.0.check', message: 'needed' }] }; + expect(serverHoldAt(atCheck, judged, 'rowLevelSecurity.0.check', '')).toEqual(atCheck.errors); + }); +}); 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 fbbf0091985..fa23083ca41 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts @@ -109,3 +109,55 @@ export function serverErrorsAt( export function useServerErrorsAt(path: string | undefined): readonly ServerDiagnosticEntry[] { return serverErrorsAt(React.useContext(DraftVerdictContext), path); } + +/** + * objectstack#27482 — the body the provided verdict JUDGED: the served draft + * (or layered) body the `_diagnostics` were computed on. A verdict describes + * that body and no other, so it may hold a save only while the field still + * shows the text it judged. A page that provides no body holds nothing. + */ +export const DraftVerdictBodyContext = React.createContext(undefined); + +/** + * The value at a dotted `_diagnostics` path (`rowLevelSecurity.0.using`) in a + * served body; `undefined` when any segment is missing. A numeric segment + * indexes an array, every other segment names an own key. + */ +export function valueAtPath(body: unknown, path: string): unknown { + let cur: unknown = body; + for (const seg of path.split('.')) { + if (Array.isArray(cur)) { + if (!/^\d+$/.test(seg)) return undefined; + cur = cur[Number(seg)]; + } else if (cur && typeof cur === 'object' && Object.prototype.hasOwnProperty.call(cur, seg)) { + cur = (cur as Record)[seg]; + } else { + return undefined; + } + } + return cur; +} + +/** + * The served errors at `path` that hold the save of a field showing `text`: + * those errors while the judged body's value at `path` is that same text; + * none once the author has edited it away (the verdict no longer describes + * what is on screen, and only the save fetches the next one), and none + * without a judged body. An absent value reads as empty text, the way the + * editors show it. ⛔ No rule is restated: the server's errors are taken whole. + */ +export function serverHoldAt( + diagnostics: ServerDiagnostics | undefined, + judged: unknown, + path: string | undefined, + text: string, +): readonly ServerDiagnosticEntry[] { + const hits = serverErrorsAt(diagnostics, path); + if (hits.length === 0 || judged === undefined || !path) return NO_ERRORS; + return (valueAtPath(judged, path) ?? '') === text ? hits : NO_ERRORS; +} + +/** {@link serverHoldAt} against the verdict and judged body the enclosing page provides. */ +export function useServerHoldAt(path: string | undefined, text: string): readonly ServerDiagnosticEntry[] { + return serverHoldAt(React.useContext(DraftVerdictContext), React.useContext(DraftVerdictBodyContext), path, text); +} From bee1b7d118bd23e3e68d7c317921f7881d3ab524 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:47:38 +0000 Subject: [PATCH 2/2] chore(changeset): patch for @object-ui/app-shell (server verdict holds the CEL autosave) Claude-Session: https://claude.ai/code/session_01Utp6CaN9smHqkJcUWF4adR Co-authored-by: Claude --- .../27482-server-verdict-holds-cel-autosave.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/27482-server-verdict-holds-cel-autosave.md diff --git a/.changeset/27482-server-verdict-holds-cel-autosave.md b/.changeset/27482-server-verdict-holds-cel-autosave.md new file mode 100644 index 00000000000..b8751c7e240 --- /dev/null +++ b/.changeset/27482-server-verdict-holds-cel-autosave.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): a server error at an RLS clause's path holds the permission-set autosave + +Clause-②: no +Metadata-protocol: none + +In Studio's permission-set editor, an RLS `using` or `check` clause that the publish will refuse (for example a `using` that reads a formula field, refused as `rls-predicate-unenforceable`) showed the server's message on the field but kept autosaving: the message was displayed, and the save gate never counted it. Now the server's errors at the clause's own path hold the autosave, the same way a CEL syntax error does, while the clause still shows the text that verdict judged. + +The verdict describes the stored draft and nothing else, and no door judges a text without saving it. So an edit that changes the clause's text is not held: it saves, and that save's re-read brings the next verdict, which holds or releases from there. The editor decides nothing itself: it takes the server's errors as served and restates no publish rule.