Skip to content

DELETE_RESTRICTED (409) message is shown verbatim to end users: English-only, leaks API names, and contains developer-facing advice #7307

Description

@baozhoutao

Summary

When a delete is refused by referential integrity, the engine throws a 409 DELETE_RESTRICTED
whose message is surfaced verbatim to end users by Console's toast. That message is
English-only, embeds machine object/field names, and hands the user a developer instruction
(set deleteBehavior:'cascade' on ...) — none of which is actionable or comprehensible for a
business user of a localized app.

The error transport itself is fine (status is set, so the message is not swallowed into a
generic 500). This is purely about the user-facing copy.

Where

packages/objectql — cascadeDeleteRelations, the behavior === "restrict" branch
(observed in @objectstack/objectql@17.0.0-rc.5, dist/core.js):

const reason = fdef.deleteBehavior !== "restrict" && fdef.required === true
  ? ` (${fieldName} is required, so it cannot be cleared)` : "";
const err = new Error(
  `Cannot delete ${object} (${id}): ${dependents.length} dependent ${childName} record(s) ` +
  `reference it via ${fieldName}${reason}. Delete or reassign them first, ` +
  `or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`
);
err.code = "DELETE_RESTRICTED";
err.status = 409;
err.object = object;
err.dependentObject = childName;
err.dependentCount = dependents.length;

Actual behaviour

Deleting a sys_business_unit referenced by a required lookup produces this red toast in
Console
, shown as-is to the operator in an otherwise fully Chinese UI:

Cannot delete sys_business_unit (<id>): 1 dependent os_tianshun_ehr_sporadic_application
record(s) reference it via apply_dept (apply_dept is required, so it cannot be cleared).
Delete or reassign them first, or set deleteBehavior:'cascade' on
os_tianshun_ehr_sporadic_application.apply_dept.

Three separate problems for the end user:

  1. Not localized — English string in a zh-CN app; there is no i18n key to override.
  2. Machine names leak — os_tianshun_ehr_sporadic_application, apply_dept. The user
    knows these as 「零星申请」and「申报部门」; the raw API names mean nothing to them.
  3. Developer advice shown to end users — set deleteBehavior:'cascade' on ... is a
    metadata-authoring instruction. A business user cannot act on it, and it invites
    support tickets asking where that setting lives.

Expected

A business user should get something like:「该 <对象label> 正被 1 条 <子对象label> 通过
「<字段label>」引用,且该字段为必填,无法清空。请先删除或改派这些记录。」 — i.e. localized,
using labels rather than API names, and without the metadata-authoring hint.

The structured fields (code, object, dependentObject, dependentCount) are already
present and are the right channel for developers/logs — the developer guidance belongs there
(or in a separate detail/devHint property), not in the user-visible message.

Suggested direction

  • Emit message through the i18n layer with an overridable key (e.g.
    errors.deleteRestricted), interpolating resolved labels for object/field where
    available and falling back to API names only when no label exists;
  • Move Delete or reassign them first, or set deleteBehavior:'cascade' on ... out of
    message into a developer-only field (or drop the deleteBehavior half of it entirely —
    the "delete or reassign them first" half is useful to end users);
  • Keep status: 409 and the structured fields exactly as they are — that part works well.

Repro

  1. Object A with a lookup field on object B where required: true (so set_null is coerced to restrict);
  2. Create one B record and one A record referencing it;
  3. Delete the B record from Console (record page, drawer, list row action, or bulk bar — all four surface the same toast);
  4. Observe the raw English message in the toast.

Found while building an app on 17.0.0-rc.5; reported per "platform issues go to the platform repo".

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:engine-core.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 10, 2026

    @claude
    Contributor

    CLAIM — domain:engine-core seat (#6019). Session session_01MwoubC3jL271FYt9rGXwxb · branch claude/issue-7307-delete-restricted-user-copy.

    Race re-read done; assignees measured EMPTY via REST immediately before assigning.


    Generated by Claude Code

  3. claude commented on Aug 10, 2026

    @claude
    Contributor

    Claim-format amendment — the 10:1xZ claim above predates this seat loading today's SKILL.md update (#7357/#7379); re-stating in the step-4 fixed shape so the half-state sweeper's H2 predicate can read it:

    Claim: PM loop wave 7 (seat #6019)
    Session: session_01MwoubC3jL271FYt9rGXwxb
    Branch: claude/issue-7307-delete-restricted-user-copy
    Worktree: objectstack-7307
    Domain: domain:engine-core

    Labels repaired in the same write: pm:queue → pm:dispatched (H3 discipline).


    Generated by Claude Code

  4. claude commented on Aug 10, 2026

    @claude
    Contributor

    ACCEPT — PR #7423. Ready + queued; #6017 declaration filed for the new spec catalog.

    Verified against all six criteria by reading the body and diff:

    1. ✅ The consumer table came first and decided the shape. Seven consumers measured; exactly one reads .message, and it shows it to an end user (Console renders body.error verbatim, with no DELETE_RESTRICTED branch — 0 hits). That measurement, not taste, is what makes the two-audience split the right shape. No objectui card needed — fixed at the producer, Console is pass-through.
    2. ✅ Two sentences, one wire code: message becomes the localized user half, rendered from resolved labels (bundle → declared label → API name, so the API name is where the ladder ends, not starts); deleteBehavior appears in no locale. code/status/object/dependentObject/dependentCount byte-identical; ⛔ the ADR-0112 code does not split.
    3. ✅ The developer hint survives in the developer channel — developerMessage carries the previous sentence byte for byte as a REST sibling field and in the engine's delete log; pinned both ways (present in the dev channel, absent from the user half).
    4. ✅ Transport pins unchanged and extended, not replaced; an i18n service that throws still yields 409, not 500 — the refusal never regresses to a 500 for want of a translation.
    5. ✅ The catalog home is argued, not assumed: not validation.field.* (a DELETE_RESTRICTED names no field of the acted-on object; filing there would mint an override key that lies), so operation-message.ts addressed errors.<key>, sharing interpolateValidationMessage so the two catalogs cannot drift. Field validation messages are hardcoded English + API field name — penalty_amount must be ≥ 0 reaches end users verbatim #3957's fix reached from the operation side.
    6. ✅ Changeset minor ×3 with the fixed-group reasoning (check-changeset-no-major.mjs); ⛔ no error-code-ledger touch (no new/renamed code). Spec touch declared on [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017.

    29→26 checks all green across both heads. Auto-merge armed; issue closes on merge. #7163 dispatches when this lands (engine.ts serial queue).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions