Skip to content

fix(ui): let a producer-marked refusal reach the drag-write surfaces - #6376

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5902-drag-write-refusal-message
Aug 25, 2026
Merged

fix(ui): let a producer-marked refusal reach the drag-write surfaces#6376
os-support-ai merged 2 commits into
mainfrom
claude/issue-5902-drag-write-refusal-message

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #5902

Applies the objectui#5210 ruling — a refusal the producer marked as user-facing must reach the user rather than being replaced by a generic string — to the drag-write surfaces. Declared-≠-enforced restoration: no accept set widens, no new key is accepted, and every existing substitution still governs every unmarked refusal.

All measurements below are on e5fb2be3b, the head this PR pushes.


1. The census, re-derived — it is three, not two

The card named two surfaces. Re-derived on this branch's base (9180fd139):

$ grep -rn "userMessage" packages/plugin-kanban/src packages/plugin-calendar/src packages/plugin-form/src
(no output — zero occurrences)
# Surface File What it does with a refusal
1 Kanban card-move packages/plugin-kanban/src/ObjectKanban.tsx toast.error(...) after a failed persist
2 Calendar reschedule packages/plugin-calendar/src/ObjectCalendar.tsx toast.error(...) after a failed drag-write
3 OCC conflict dialog packages/plugin-form/src/occSave.tsx renders a dialog; named by no card

Surface 3 confirmed independently: occSave.tsx already reached into the conflict error for currentVersion and had no userMessage reader, so a 409 an author had marked showed the same canned sentence as every other 409. It did not already read the marking, so there is no fork to report here.

Dependency direction checked before importing. All three packages already declare @object-ui/react in dependencies, so the shared reader is reachable from each and nothing had to be duplicated. All three also already declare @object-ui/data-objectstack in devDependencies, which is what lets the pins build fixtures through the real boundary.

packages/app-shell/src/views/RecordAttachmentsPanel.tsx branches on isPermissionError too but selects an empty-state kind rather than rendering server text — a different question, excluded by the card, and untouched.

2. The fix

One reader everywhere: declaredUserMessage from @object-ui/react. Nothing is duck-typed at any surface — the marking's landing spot is asymmetric (ConcurrentUpdateError.userMessage is a typed readonly member; DataApiValidationError's lands in err.details.userMessage), and that reader is the one place that knows both.

The two toasts substitute, exactly as form.tsx does:

toast.error(
  declaredUserMessage(err) ??
    (isPermissionError(err)
      ? tt('errors.unauthorized', 'You are not authorized to perform this action.')
      : extractWriteErrorMessage(err) ?? tt('table.saveFailed', 'Save failed')),
);

The conflict dialog augments — a deliberate per-surface deviation, called out for review. Its description does two different jobs in one paragraph: it says why the write was refused, and it explains what the destructive Overwrite button will do. userMessage is a refusal message, not affordance copy this surface owns, so evicting the paragraph would leave Overwrite unexplained on the one surface where the choice is irreversible. The marking leads, in its own right; the paragraph stays. Both halves are pinned, so the choice is visible rather than implied — flip it by changing one assertion if review disagrees.

No new i18n key on any surface, so no locale pack moves.

3. Per-surface coverage: which error shapes, and both arms

15 new tests. Every fixture is built wire-shaped and pushed through the real normaliseClientError — the same boundary a real dataSource.update failure crosses — following the pattern at packages/react/src/utils/error-message.normalisation-boundary.test.ts. Hand-rolling the post-boundary shape would have pinned each surface against the test file's own idea of where the marking lives, which is precisely the asymmetry worth pinning.

Surface 409 CONCURRENT_UPDATE 400 VALIDATION_FAILED 403 passthrough
Kanban toast marked + unmarked marked + unmarked marked + unmarked
Calendar toast marked + unmarked marked + unmarked marked + unmarked
OCC dialog marked + unmarked rethrow, marking intact n/a — not this surface's shape

The 409 and 400 rows are the two shapes that land the marking in different places, so a surface pinned on one only would be half-fixed. The 403 column pins the contract's status-agnosticism: a marked 403 must beat the errors.unauthorized substitution, which is the branch the ruling was originally reported on.

The marked arms assert the marked text itself, not that a toast appeared — the fixture's userMessage is deliberately unlike every generic string these surfaces can produce (Save failed, You are not authorized to perform this action., and the raw server text), so the assertion cannot pass on the pre-fix path.

The unmarked arms are the other half. Without them a fix that simply printed String(error) would pass every marked arm, and that is a different defect — leaking raw server diagnostics to end users, which is what objectstack#3821's substitution exists to prevent. The unmarked-403 arm asserts both that the localized string is used and that insufficient privileges to update task t1 does not appear.

For occSave the second shape is covered as what that seam actually owes: saveWithOcc rethrows everything that is not a conflict, so the pin asserts the rejection arrives at the caller unchanged (toBe, identity) with declaredUserMessage still finding the marking — it cannot quietly swallow or re-wrap it on the way past.

4. rejectedMoveRollback — nothing moved

packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx is untouched; zero assertions changed, and it does not appear in this PR's diff.

Its fixture (invalidTransition() — a 400 invalid_transition) carries no userMessage, so declaredUserMessage answers null and the toast text is unchanged. Its expect(toast.error).toHaveBeenCalledWith('Invalid status transition') therefore still pins the extractWriteErrorMessage arm, and the rollback behaviour it guards is not weakened in any way. It ran green in both the red and the green runs below (5/5).

5. Red before, green after — predicted first

Predictions were recorded before the pre-fix run: 7 of the 15 new tests should fail, being the 3 marked arms on each toast surface plus the marked-409 dialog arm; all 6 unmarked arms and all 5 rejectedMoveRollback tests green on both sides.

Pre-fix (9180fd139 + pins only): Test Files 3 failed | 1 passed (4) · Tests 7 failed | 13 passed (20)

All seven failures matched the predicted identity exactly — no prediction was wrong in either count or identity:

FAIL ObjectKanban.markedRefusalToast   > 409 CONCURRENT_UPDATE ... expected 'Record was modified by another user'
FAIL ObjectKanban.markedRefusalToast   > 400 VALIDATION_FAILED ... expected 'Validation failed'
FAIL ObjectKanban.markedRefusalToast   > 403 ahead of "not authorized" ... expected 'You are not authorized to perform this action.'
FAIL ObjectCalendar.markedRefusalToast > (the same three)
FAIL occSave.markedRefusal             > renders the marking on a MARKED 409

Post-fix: Test Files 5 passed (5) · Tests 28 passed (28) (the 15 new + rejectedMoveRollback 5 + the pre-existing occSave.test.tsx 8).

6. Ablation — are the unmarked arms load-bearing?

The red run above already establishes that the marked arms gate the fix. What it cannot show is whether the negative arms do any work, so they were ablated separately, after the fix was committed (so restoration had a safe anchor).

Mutation: in ObjectKanban.tsx, keep the marking read but drop the isPermissionError substitution — i.e. the plausible wrong fix that leaks raw server text.

Predicted: exactly 1 failure, and specifically keeps the localized substitution for an UNMARKED 403; all three marked arms and the other two unmarked arms stay green.

Actual: exactly that.

Tests  1 failed | 5 passed (6)
FAIL  ObjectKanban.markedRefusalToast > keeps the localized substitution for an UNMARKED 403

Mutation confirmed on disk before measuring — injected marker present ×1, removed anchor text errors.unauthorized present ×0, and the mutated blob hash differing from the HEAD blob — so this was not a no-op reading. The script carried a trap … EXIT INT TERM with absolute paths. Restore leg proven by bytes, not by an exit code: git hash-object returned 2c99db839a55bcbbb25e6599fb8140f441967ab6, identical to the HEAD blob, with git diff HEAD empty and errors.unauthorized back to ×1.

7. Gates

Derived from the CI job step lists under .github/workflows/ (ci.yml, lint.yml, and the standalone gate workflows). Each row quotes the gate's own verdict line; exit codes were captured before any pipe.

Gate Verdict line Exit
check-changeset-presence.mjs ✅ 6 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s) 0
check-changeset-no-major.mjs ✅ No changeset declares a major bump. 0
check:control-bytes ✅ check-control-bytes: OK (scanned 5248 tracked text file(s); skipped 85 binary). 0
check:vi-mock-specifiers ✅ check-vi-mock-specifiers: OK (… 443 carry a mock …) 0
check:phantom-deps ✅ Every in-scope import is declared by the package that publishes it. 0
check:self-import ✅ No package names itself inside its own src/. 0
check:i18n-keys Every in-scope call-site key resolves against the en pack (2824 keys) … 0
check:i18n-drift No en value changed in this range. 0
type-check:coverage ✅ type-check coverage: 45/46 … · ✅ test type-check coverage: 41/41 packages compile their tests 0
lint:coverage ✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total). 0
check:entry-guard ✓ check:entry-guard: 47 scripts/ file(s) — no entry guard outside the baseline 0
check:spec-symbols ✅ spec symbol derivation: 1306 files scanned against 4959 spec export names 0
check:esm-specifiers --specifiers-only: the load leg (which builds) was not run. (specifier leg only) 0
check:pre-install-import-graph ✅ check-pre-install-import-graph: OK 0
check:shell-escape-residue ✅ check-shell-escape-residue: OK (4/4 root(s) resolved …) 0
turbo run type-check (3 pkgs) Tasks: 17 successful, 17 total 0
turbo run lint (3 pkgs) Tasks: 4 successful, 4 total 0
vitest run packages/plugin-{kanban,calendar,form}/ Test Files 97 passed (97) · Tests 862 passed (862) 0
vitest run scripts/__tests__/ Test Files 79 passed (79) · Tests 2279 passed (2279) 0

NOT MEASURED locally — recorded as such rather than as green, because each says in its own words that it measured nothing:

Gate Why Exit
check:eager-closure ❌ No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a passing budget. Needs a console vite build; CI builds it. 2
check:readme-exports ❌ check-readme-exports: the population COLLAPSED -- this run proves nothing — 23 of 40 packages unbuilt in this worktree; CI installs and builds. 1
check:published-dist Killed by the container's 10-minute foreground cap mid-build (Build finished in 409s. then SIGTERM). Not implicated: no package.json or build config changed. 143

half-state-patrol is a scheduled board sweep triggered by scripts/pm/check-half-states.mjs (untouched) — N/A, not a gate this diff owes. The docs, shadcn, spec-floors and skills-paths workflows match no path in this diff.

Type-check actually covered the new tests. These packages run tsc --noEmit && tsc -p tsconfig.test.json, and that second project is the one that compiles tests. Verified rather than assumed — --listFiles reports each new test file present in its package's program (1 hit each), so the green above is not the "compiled, but excluded your tests" reading.

Repo-wide pnpm lint was narrowed, and the narrowing is measured. Ran turbo run lint scoped to the three packages — CI's own command, over each package in full rather than only the changed files — plus a --format json run over the diff. Three pieces of evidence that the narrowing excluded nothing: (1) the population comes from eslint's own config resolution, not a guess about which files count; (2) --format json reports files linted: 6 | errors: 0 | warnings: 104; (3) linting here is not type-awareeslint.config.js declares no parserOptions.project and no projectService — so no rule reads cross-file type information and this diff cannot move the verdict on any file it did not touch. The 104 warnings are pre-existing no-explicit-any / react-hooks/* findings on the two large existing components (ObjectCalendar.tsx 68, ObjectKanban.tsx 33, occSave.tsx 3); all three new test files are 0 errors / 0 warnings, and the source edits add no any and no hook.

CI still runs the full farm exactly once regardless.

8. Out of scope

Filed as #6375 (unassigned, finding): normaliseClientError's first CONCURRENT_UPDATE guard is subsumed by the line under it, so its httpStatus === 409 half decides nothing. Recorded rather than edited — packages/data-objectstack is scoped out of this card, and this PR does not touch it.


Generated by Claude Code

claude added 2 commits August 25, 2026 16:48
The kanban card-move toast, the calendar reschedule toast and the OCC
conflict dialog each substituted a generic string for a refusal the PRODUCER
had marked as user-facing (`userMessage`, objectstack#9934), so a user was
told "Save failed" where the author had written a sentence for them.

All three now read the marking through the shared `declaredUserMessage`
reader, which knows both places the adapter boundary parks it: the typed
member on `ConcurrentUpdateError` and the details bag on
`DataApiValidationError`. Nothing unmarked reaches the user - the reader
answers null for it and every existing substitution stands, so
objectstack#3821's protection holds by construction.

The two toasts substitute; the conflict dialog augments. Its description
also explains what the destructive "Overwrite" button does, which is
affordance copy this surface owns rather than a refusal message, so the
marking leads and that paragraph stays.

Applies the objectui#5210 ruling, already implemented for the console form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3223.6 KB 3266.6 KB
Main entry chunk (gzip) 154.1 KB 350 KB
Entry file index-ZcFabfLi.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 10.96KB 4.16KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 505.90KB 114.59KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.18KB 47.97KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.66KB 12.84KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.45KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 127.74KB 31.09KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.21KB 54.43KB
plugin-kanban (index.js) 52.87KB 14.57KB
plugin-list (index.js) 111.94KB 27.24KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.49KB 2.14KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 18:20
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 3e853c9 Aug 25, 2026
28 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5902-drag-write-refusal-message branch August 25, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(ui): the kanban and calendar drag-write surfaces still discard a producer-marked refusal message

2 participants