console: the global record create/edit dialog drops a server refusal silently — no toast, no field error, the dialog just stays open #13205
Description
Filing gate ①, class (a): a user-visible product defect with a public-door reproduction and a named landing.
Reader (who acts): objectui triage routes it to the lane that owns packages/app-shell / packages/plugin-form. Filed by domain:services seat 2 of objectstack-ai/objectstack (seat post objectstack-ai/objectstack#21118) · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y, from the dev report on objectstack-ai/objectstack#24764 and its PR objectstack-ai/objectstack#24834. ⛔ Not a claim. Grade and lane are triage's.
What happens
- Door: Setup → Approvals → Delegations → New, on a seeded HotCRM install (
@objectstack/*18.0.0-next.5; the maintainer's browser pass of 2026-10-20, QA: full functional browser pass of HotCRM main b94cf856 (18.0.0-next.5) over all 161 feature-inventory rows — one cloud tester, screenshots (maintainer task 2026-10-20) hotcrm#2188, finding F-14 of track 2, filed here as Approvals delegation: the form lets an admin pick any delegator, the server refuses with 403, and the UI says nothing objectstack#24764). - Steps: the form submits a record the server refuses (here
POST /api/v1/data/sys_approval_delegation→ 403FORBIDDEN, a delegator other than the signed-in user). - Actual: no toast, no field error, no banner. The dialog stays open and the admin cannot tell the save failed or why.
- Expected: the refusal is shown, as the record page's own saves and the action-param dialog (objectui#13161) show theirs.
- reach: the measured door above. fix(plugin-approvals): the delegation form's Delegator picker offers only the signed-in user objectstack#24834 removes this particular refusal (the Delegator picker now offers only the accepted value), but any other server refusal of a create/edit through the global dialog stays silent the same way.
Where (read on objectui main 3cec090, by code; no browser reading at that sha)
packages/app-shell/src/console/AppContent.tsxmounts the globalModalForm(around line 1453) with anonSuccessand noonError.packages/plugin-form/src/ModalForm.tsx: the submitcatchcallsschema.onErroronly when it is given, then rethrows (around lines 747–752). Its onlytoast.erroris the upload gate's.- The console pin at objectstack (
.objectui-sha12dc7b47c3) reads the same, per the dev's measurement on Approvals delegation: the form lets an admin pick any delegator, the server refuses with 403, and the UI says nothing objectstack#24764.
The route is the lane's. Two shapes are visible: the console passes an onError that surfaces the ADR-0112 envelope, or ModalForm surfaces a refusal itself when no onError is given. ⛔ No server-meaning mapping in the console.
Dedupe
objectui, MCP search_issues, open and closed:
- "record create dialog server refusal silent no toast ModalForm 403 error not shown": 13 hits.
- "ModalForm onError create form server error swallowed dialog stays open": 11 hits.
Nearest, none covering this surface:
- objectui#13161 (closed 2026-10-20): the action-param dialog closes on a server error. A different component, and the opposite symptom.
- objectui#2793 (closed): a required lookup left empty fails silently on create. A client-side check, not a server refusal.
- objectui#11902 (closed): the record page maps a refused read to "not found".
Dedupe words: create dialog silent 403; ModalForm server error no toast; global record form refusal not surfaced.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 1 (
priority:p1,bug)Session:
session_01GL347th7aWQUnY2k8diSLGAccount:
os-support-ai(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-13205-dialog-refusal-shownWorktree:
objectui-issue-13205Domain:
domain:uiSeat:
domain:ui#3File surface:
packages/plugin-form/src/ModalForm.tsx(read at objectuimain52907d7), the submitcatch, which hands a refusal toschema.onErroronly when a host gives one and otherwise drops it: a server refusal of a create or edit is shown to the user (the server's own message, and per-field errors on their rows where the refusal carries them), whatever the host.packages/plugin-form/src/DrawerForm.tsxhas the samecatchand takes the same fix. If the dev measures that the console's global dialog (packages/app-shell/src/console/AppContent.tsx, itsModalFormmount) is the right place instead, that file, with the reason in the report. Tests beside the forms. One.changeset/13205-dialog-refusal-shown.md(patchfor each package touched;@object-ui/i18nonly if a pack moves, then every pack the parity gates require). ⛔ No server-meaning mapping in the console (no table of codes to sentences). ⛔ No new package export or prop. ⛔ Not the action-param dialog (objectui#13161, closed). Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: opus(default tier;dispatch-gates --tier --repo objectstack-ai/objectuiover this surface reads no path-derived mandate)Clause-②: no
Responsibility: the risk is produced by objectui's own record forms:
ModalForm(andDrawerForm) pass a refused write only to a hostonError, and the console's global dialog gives none, so the refusal vanishes and the dialog stays open. The platform path that covers it: none for this dialog; the record page's own saves and the action-param dialog already show refusals. Who reaches it: every user whose create or edit through the global dialog is refused (measured: Setup → Approvals → Delegations → New, 403FORBIDDEN), and every react page opening<ObjectForm formType="modal"|"drawer">withoutonError.Thread-read: none
Serial constraints cleared: open objectui PRs touching
ModalForm.tsxorAppContent.tsx: none (the eleven open PRs' file lists were read at this claim).DrawerForm.tsxis held by this seat's own PR #13235 (objectui#13219, in the merge queue; two lines onSheetContent, not the submitcatch); the dev syncs withgit merge origin/mainonce it lands.ModalForm.tsxlast took9eb1479(objectui#13130).Why
Clause-②: no: a form shows a refusal it already receives. No export, prop, schema key or accept set of a published package moves.Claimed at: 2026-10-20T11:12Z
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{ "issue": 13205, "status": "done", "branch": "claude/issue-13205-dialog-refusal-shown", "pr": null, "session": "session_01GL347th7aWQUnY2k8diSLG (mode:subagent, the dispatching seat's session; identity = the branch)", "premise_still_valid": false, "summary": "Premise disproved; no PR. The global create/edit dialog does not drop a server refusal. ModalForm's and DrawerForm's submit catch hands the error to a host onError when there is one and rethrows. The rethrow lands in the form renderer's own submit catch (packages/components/src/renderers/form/form.tsx, submitThroughValidation), which already shows every refusal. Per-field errors go onto their rows (extractFieldErrors, then form.setError) with a toast naming the fields. Otherwise it raises the in-form banner (submitError, an Alert with role alert) and a toast.error, both with declaredUserMessage(err), else 'You don't have permission to save this record.' for a permission refusal, else extractWriteErrorMessage(err), else 'Could not save. Please try again.'. The dialog stays open. Measured at objectui main ab4e4d7 with a scratch probe (never committed): 14 cases across ModalForm and DrawerForm, all with the console's global-dialog prop shape and no host onError, read from the DOM and a spy on the deep sonner toast. The card's own evidence agrees: hotcrm ed4f40e42 qa/2026-10-20-setup-pass/shots/21-F-delegations-create-403.png shows the red banner 'You don't have permission to save this record.' at the top of the Create Approval Delegation dialog. That string is the renderer's form.noPermissionToSave, so the rethrow reached that catch in the real console, and toast.error is the next statement. The same catch is in the console pin's objectui 12dc7b47c3: form.tsx has setSubmitError(errorMessage) and toast.error(errorMessage, { id: outcomeToastId }); ModalForm and DrawerForm have the same schema.onError(err as Error) catch. The objectstack#24764 report that fed the card read ModalForm alone ('its only toast.error is the upload gate') and missed the renderer one hop up. The suggested route, ModalForm and DrawerForm toasting when no host onError is given, would put a second toast on screen for every refusal. That is the double-raiser class objectui#7354 removed: MasterDetailForm's handleError comment names the renderer catch as the one raiser every host shares, and MasterDetailForm.singleRefusalRaiser.test.tsx pins it ('leaves exactly one error toast on screen, not one per raiser'). So it was not built. Existing pin for this exact surface: createOwnerPick-12359.test.tsx, 'a refused pick surfaces as the form's error, and the dialog stays open' (ModalForm, 403 FORBIDDEN, asserts the permission sentence). Conflict named, not settled silently: the claim's file surface asks for 'the server's own message'. For an UNMARKED permission refusal the ruled contract shows the generic sentence instead: the maintainer ruling 2026-08-19 on objectui#5210, objectstack#3821, and the userMessage opt-in read by declaredUserMessage. A producer-marked userMessage is shown verbatim (measured below). I kept the ruled behaviour and changed nothing; reversing it is the maintainer's call and outside this card. Suggested seat action: close objectui#13205 as not planned, citing this report.", "tests": "Scratch probe packages/plugin-form/src/zz-probe-13205.test.tsx, written in the worktree, run twice and deleted (never staged, committed or pushed; git status empty after). Run: bash os-verify-lock.sh -c 'NODE_OPTIONS=--max-old-space-size=4096 pnpm exec vitest run packages/plugin-form/src/zz-probe-13205.test.tsx', repo-root form, at HEAD ab4e4d7. Output: 'Test Files 1 passed (1)', 'Tests 14 passed (14)', 'os-verify-lock: VERDICT command-exit 0'. Readings, verbatim from the probe's file (each with dialogOpen=true, onSuccess not called, onOpenChange never called with false). 'modal 403-unmarked :: toastError=[\"You don't have permission to save this record.\"] :: bodyHasServerText=false :: alerts=[\"You don't have permission to save this record.\"] :: fieldMsgs=[]'. 'modal 403-marked :: toastError=[\"Only the delegator can declare this delegation.\"] :: alerts=[\"Only the delegator can declare this delegation.\"]'. 'modal 400-fields :: toastError=[\"Please check the highlighted fields: Delegator\"] :: alerts=[] :: fieldMsgs=[\"Delegator must be the signed-in user\"]'. 'modal 400-plain :: toastError=[\"Valid Until must be after Valid From\"] :: alerts=[\"Valid Until must be after Valid From\"]'. 'modal 403-edit' (update rejects, OCC path) and 'modal 403-sections' (form-view sections arm) read the same as 403-unmarked. DrawerForm gave the identical six readings. With a host onError: 'modal 403-hostOnError onErrorCalls=1 :: toastError=[\"You don't have permission to save this record.\"]', one toast and not two, and drawer the same. The error doubles were shaped like the adapter's: an Error with httpStatus 403, code FORBIDDEN and details.error carrying the server text; for 400, code VALIDATION_FAILED with details.fields. No ablation: no tested behaviour changed and there is no diff. Setup: os-verify-lock 'pnpm run setup' VERDICT command-exit 0.", "gates": "None owed: the branch carries no commit beyond its base. git rev-parse --short HEAD = ab4e4d7 = origin/main at cut, git status empty, and the remote branch equals ab4e4d7 (git ls-remote). No PR, so there are no check-runs to cite, and no changeset is owed because nothing changed.", "line_budget": "n/a: no diff.", "files_changed": [], "deviations": [ "Suggested route not taken (measured to reintroduce the objectui#7354 double toast); premise reported false instead, per the dispatch's 'disproving one is a good result'.", "Mechanism assumptions: 1 disproved (the rethrow reaches the form renderer's catch, which shows banner, toast and field rows). 2 and 3 confirmed as code facts: AppContent mounts ModalForm with onSuccess and no onError, and DrawerForm's catch has the same shape. Neither silences a refusal. 4: the reader already in use is @object-ui/react error-message.ts (extractFieldErrors, declaredUserMessage, isPermissionError, extractWriteErrorMessage), called by the form renderer that every record form, the record page included, submits through.", "The DrawerForm merge step was moot: objectui#13235 had already landed and is the base (ab4e4d7).", "Read the card's evidence by git fetch --no-write-fetch-head of hotcrm commit ed4f40e42 into /home/user/hotcrm (objects only, no ref created) and git show of the screenshot into the scratchpad.", "The empty remote branch claude/issue-13205-dialog-refusal-shown stays at ab4e4d7, because remote ref deletion is refused (objectui AGENTS.md). The local branch ref created by the worktree recipe also remains in objectui's shared .git. The worktree is removed (node_modules deleted, git worktree remove without --force, exit 0)." ], "mcp_calls": "0", "api_writes": "1: this os-dev-report comment, POST /repos/objectstack-ai/objectui/issues/13205/comments through scripts/pm/post-stamped.mjs (relay). git push of the empty branch is not REST. Reads were gh api GETs: objectui#13205 and its comments, and objectstack#24764 and its comments.", "browser_reading": "Not run (not owed). Two recipes for the seat, derived from the showcase metadata and objectql rule-validator, not browser-measured. Backend: examples/app-showcase, pnpm exec objectstack dev --seed-admin --fresh -p PORT, signed in as admin@objectos.ai. (1) Field-row refusal: Projects (showcase_project), New, any Project Name, a Start Date later than the Target End Date, Create. The cross_field rule end_after_start answers 400 VALIDATION_FAILED attributed to start_date (the rule's first field). Expect the row message 'Target End Date must be on or after the Start Date.' under Start Date, a toast 'Please check the highlighted fields: ...', and the dialog open. (2) Form-level refusal: the same New form with Budget 100 and Spent 200. The script rule spent_within_budget has no fields, so it is attributed to _record and falls through to the banner and toast 'Spend exceeds 120% of budget — escalate before continuing.'. A 403 needs a non-admin persona; its text is the generic sentence, pinned in unit by createOwnerPick-12359. Selectors: toast '[data-sonner-toaster] [data-sonner-toast][data-type=\"error\"]'; in-dialog banner '[role=\"dialog\"] form [role=\"alert\"]'; field row error '[role=\"dialog\"] [data-field=\"start_date\"] [id$=\"-form-item-message\"]' (FormMessage, class text-destructive). The ConsoleToaster duration is 4s, so read the toast within that window.", "open_questions": [], "out_of_scope_findings": [ "carrier: none · noted, not filed: objectstack plugin-approvals' delegation write guard (bindDelegationWriteGuard) refuses without a producer-marked userMessage, so the console shows the generic permission sentence, not the guard's 'you may only manage out-of-office delegations where you are the delegator'. Under the userMessage opt-in contract that is the producer's choice, not a defect, and objectstack#24834 already stops the picker offering a refused delegator. Polish; no filing class.", "carrier: none · noted, not filed: QA finding F-14 (hotcrm ed4f40e42, qa/2026-10-20-setup-pass/REPORT.md), the source of objectstack#24764 and this card, says 'no toast, no field error, the dialog just stays'. Its own screenshot shows the in-dialog banner. The toast likely expired (4s, pinned by ConsoleToaster.autoDismiss-7482) before it was seen. Observation only." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Release:
session_01GL347th7aWQUnY2k8diSLG(domain:uiseat 3) · premise falsified by measurement · →pm:queue+pm:retriage(triage: close as not planned, or re-scope) · 2026-10-20T11:32ZResume-from: none (the dev's branch
claude/issue-13205-dialog-refusal-showncarries no commit beyondmainab4e4d7; no PR was opened).What the dispatched dev measured (its report, comment above this one): the global create/edit dialog does not drop a server refusal.
ModalForm's andDrawerForm's submitcatchrethrows when no hostonErroris given, and the rethrow lands in the form renderer's own submitcatch(packages/components/src/renderers/form/form.tsx,submitThroughValidation), which shows every refusal: per-field errors on their rows plus a toast naming the fields; otherwise an in-form banner (role="alert") and an error toast carryingdeclaredUserMessage(err), else the generic "You don't have permission to save this record." for an unmarked permission refusal, else the server's message. The dialog stays open. The dev's probe read this through bothModalFormandDrawerFormwith the console's prop shape and no hostonError(14 cases).The seat's own readings,
origin/main24001c1:form.tsximportsextractFieldErrors,declaredUserMessage,isPermissionErrorandextractWriteErrorMessage, setssubmitErrorand callstoast.error(errorMessage, …)in thatcatch.9d13b5fc8, Chromium): Project Name, Account "Acme Retail", Budget 100, Spent 200, Create.POST /data/showcase_projectanswered 400VALIDATION_FAILED("Spend exceeds 120% of budget — escalate before continuing.", field_record). The error toast and the in-dialogrole="alert"banner both read that sentence, and the dialog stayed open.ConsoleToaster) had likely expired before it was seen.Correction (this seat's dispatch): the suggested route,
ModalForm/DrawerFormtoasting when no hostonErroris given, would have put a second toast on every refusal: the double-raiser class objectui#7354 removed andMasterDetailForm.singleRefusalRaiser.test.tsxpins. It was rightly not built. For an unmarked 403, the generic sentence instead of the server's text is the ruleduserMessageopt-in contract (objectui#5210), not a defect.Asked of triage (
pm:retriage): close this card as not planned on the readings above, or re-scope it if triage reads a remaining defect. ⛔ Not dispatched again by this seat until then.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Retriage answer (triage seat, R538): closed
not_planned. The premise was disproved by measurement, and no defect remains. ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T12:57Zorigin/main:packages/plugin-form/src/ModalForm.tsx:754–757hands the error toschema.onErrorwhen a host gives one, then rethrows.catch,packages/components/src/renderers/form/form.tsx. Thatcatchputs per-field errors on their rows throughextractFieldErrors(:2982). Otherwise it callssetSubmitError(errorMessage)(:3030) and shows an error toast, with the text fromdeclaredUserMessage→form.noPermissionToSave→ the server's message (:3023–3025).6289784607) and the seat's live global-dialog reading (6289896569) both show: an in-dialogrole="alert"banner and a toast, with the dialog staying open.userMessageopt-in contract (objectui#5210). Adding a second toast inModalForm/DrawerFormwould bring back the double-raiser class that objectui#7354 removed andMasterDetailForm.singleRefusalRaiser.test.tsxpins.pm:queueandpm:retriagecome off with the close.Generated by Claude Code