Repository navigation
DELETE_RESTRICTED (409) message is shown verbatim to end users: English-only, leaks API names, and contains developer-facing advice #7307
Description
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:
- Not localized — English string in a zh-CN app; there is no i18n key to override.
- Machine names leak —
os_tianshun_ehr_sporadic_application,apply_dept. The user
knows these as 「零星申请」and「申报部门」; the raw API names mean nothing to them. - 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
messagethrough 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
messageinto a developer-only field (or drop thedeleteBehaviorhalf of it entirely —
the "delete or reassign them first" half is useful to end users); - Keep
status: 409and the structured fields exactly as they are — that part works well.
Repro
- Object A with a lookup field on object B where
required: true(soset_nullis coerced torestrict); - Create one B record and one A record referencing it;
- Delete the B record from Console (record page, drawer, list row action, or bulk bar — all four surface the same toast);
- 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
os-zhuang commentedon Aug 10, 2026
Triage:
pm:queue+domain:engine-core.messageofDELETE_RESTRICTEDis composed English-only with raw API names and a metadata-authoring hint. Not a design card: the engine already has the localization machinery this fix needs —setI18nService(packages/objectql/src/engine.ts:3701), per-requestExecutionContext.localeresolution (:3710-3726), and write-time localized validation messages shipped under Field validation messages are hardcoded English + API field name —penalty_amount must be ≥ 0reaches end users verbatim #3957/ADR-0053 (engine-validation-locale.test.tspins the call-site contract). Routing the restrict-branch message through the same translate path with resolved labels, and keeping the developer hint in the structured fields, is execution of that established pattern.origin/main@62b6a2f): the composed message lives atpackages/objectql/src/engine.ts:7845(Delete or reassign them first, or set deleteBehavior:'cascade' on ...),err.code = 'DELETE_RESTRICTED'at:7847, incascadeDeleteRelations's restrict branch —packages/objectql⇒domain:engine-core. Existing behavior pins inengine-cascade-delete.test.ts:105-110assert code/status/structured fields only, not the message text, so the copy change is free to move under them.DELETE_RESTRICTEDmatches only this card in this repo (positive control: the search does return this card); objectui has no shadow (0 hits) — consistent with the card's own analysis that the console toast is a pass-through and the fix is producer-side. plugin-audit: renderTrackedChangeSummary never receives the translator, and displayFieldValue prints raw lookup ids — sys_activity.summary shipsRating Owner: ∅ → oBK25…onto every feed surface (re-routed from objectui#4055) #7230 / plugin-audit: select option labels insys_activity.summaryare never localized — the same defect class as #7230, one line over indisplayFieldValue#7289 / plugin-audit:activityMilestonessummary templates still interpolate raw lookup ids —{owner_id}rendersoBK25…after #7230 #7290 are the same family (unlocalized user-visible copy) but a different producer (plugin-audit), not duplicates.target:v17: not applied — display-class defect (the delete is correctly refused; the failure is the copy), same grading as the plugin-audit: renderTrackedChangeSummary never receives the translator, and displayFieldValue prints raw lookup ids — sys_activity.summary shipsRating Owner: ∅ → oBK25…onto every feed surface (re-routed from objectui#4055) #7230 family. Doesn't meet any of the four blocking classes.status/code/object/dependentObject/dependentCountexactly as-is") should be treated as a hard constraint — tests pin them.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 10, 2026
CLAIM —
domain:engine-coreseat (#6019). Sessionsession_01MwoubC3jL271FYt9rGXwxb· branchclaude/issue-7307-delete-restricted-user-copy.Race re-read done;
assigneesmeasured EMPTY via REST immediately before assigning.Generated by Claude Code
claude commentedon Aug 10, 2026
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:
Labels repaired in the same write:
pm:queue→pm:dispatched(H3 discipline).Generated by Claude Code
chore(spec,docs): regenerate spec surface artifacts and document the …
claude commentedon Aug 10, 2026
ACCEPT — PR #7423. Ready + queued; #6017 declaration filed for the new spec catalog.
Verified against all six criteria by reading the body and diff:
.message, and it shows it to an end user (Console rendersbody.errorverbatim, with noDELETE_RESTRICTEDbranch — 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.messagebecomes the localized user half, rendered from resolved labels (bundle → declared label → API name, so the API name is where the ladder ends, not starts);deleteBehaviorappears in no locale.code/status/object/dependentObject/dependentCountbyte-identical; ⛔ the ADR-0112 code does not split.developerMessagecarries 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).validation.field.*(aDELETE_RESTRICTEDnames no field of the acted-on object; filing there would mint an override key that lies), sooperation-message.tsaddressederrors.<key>, sharinginterpolateValidationMessageso the two catalogs cannot drift. Field validation messages are hardcoded English + API field name —penalty_amount must be ≥ 0reaches end users verbatim #3957's fix reached from the operation side.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 — ⏳ vacant #6017.29→26 checks all green across both heads. Auto-merge armed; issue closes on merge. #7163 dispatches when this lands (
engine.tsserial queue).Generated by Claude Code
fix(objectql,rest,spec): DELETE_RESTRICTED stops showing developer co…
fix(plugin-security,spec): PERMISSION_DENIED stops showing developer …
fix(rest): name the conflicting field in the UNIQUE_VIOLATION 409 body (
test(objectql): move the three level readers the full suite found (#1…
fix(objectql): report a refused write at `warn`, not `error` — the ca…