fix(objectql,rest,spec): DELETE_RESTRICTED stops showing developer copy to end users (#7307) - #7423
Conversation
…py to end users (#7307) The 409 refusal is correct and its transport is fine; the message was not. REST ships `error.message` verbatim as `body.error` and Console renders it as-is in a toast, so a business user in a zh-CN app got an English sentence naming two tables and a column, ending in `set deleteBehavior:'cascade' on …`. The error now carries two messages because it has two audiences: - `message` — the user's half, rendered in `ExecutionContext.locale` from a new operation-message catalog against resolved LABELS (bundle → declared label → API name). No `deleteBehavior` in any locale. - `developerMessage` — the developer's half, the previous sentence byte for byte. Shipped by `mapDataError` as a sibling field and logged by the engine's delete path, so the guidance is moved, not lost. `code` / `status` / `object` / `dependentObject` / `dependentCount` unchanged; one `DELETE_RESTRICTED` (ADR-0112), two sentences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CZbGQYbdUMyzTMdqcQxp9
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…two-audience DELETE_RESTRICTED response (#7307) `gen:export-origins` / `gen:api-surface` for the 8 added `@objectstack/spec/system` exports (0 breaking, 0 removed), and the lookup `deleteBehavior` note now says which of the two messages a client may show to an end user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CZbGQYbdUMyzTMdqcQxp9
…ete-restricted-user-copy
Closes #7307.
The defect
The refusal is correct and its transport is fine —
statusis set, the structured fields survivemapDataError. The message was the defect.error.messageis shipped verbatim asbody.error, and Console renders that as-is in a toast, so an operator deleting a 部门 in a fully Chinese app read:English in a zh-CN UI, naming two tables and a column the user has never seen (they know them as 「零星申请」 and 「申报部门」), ending in a metadata-authoring instruction a business user cannot act on.
Who consumes the thrown error — measured first
.message→body.error;code,dependentObject,dependentCount,objectpackages/rest/src/rest-server.ts:598(mapDataError)body.erroris now the localized user sentence; new siblingdeveloperMessagebody.error, verbatimDELETE_RESTRICTEDbranch (triage measured 0 hits).message+.stackengine.tsdeletecatchdeveloperMessagecode,status,dependentObject,dependentCount— never the textengine-cascade-delete.test.ts:110code→ 409 onlyrest-unclassified-fault-status.test.ts:229@objectstack/clienterr.code; noDELETE_RESTRICTEDbranch (0 hits)packages/clientcontent/docs/**Exactly one consumer reads
.message, and it shows it to an end user. That is what makes the split the right shape rather than a taste call.The shape
Two messages, because there are two audiences:
message— the user's half. Rendered inExecutionContext.localefrom a new built-in catalog against resolved labels for the object, the dependent object and the field: translation bundle → declaredlabel→ API name, so the API name is where the ladder ends rather than where it starts. The actionable half of the old advice ("delete or reassign them first") stays;deleteBehaviorappears in no locale.developerMessage— the developer's half, and the previous sentence byte for byte. The guidance is correct and useful, so it is moved to a channel that reaches developers, not dropped:mapDataErrorships it as a sibling field (disclosing nothing new —objectanddependentObjectare API names on the same body) and the engine's delete log carries it.code,status,object,dependentObject,dependentCountare byte-identical. The wire code does not split — oneDELETE_RESTRICTED(ADR-0112), two sentences, exactly as the field catalog splits a message key without splittingFieldErrorCode.Why a new catalog rather than
validation.field.*validation-message.tsis addressedvalidation.field.<key>because every entry names a field and the constraint that field broke. ADELETE_RESTRICTEDnames neither: the offending field is on a different object from the one the caller acted on, the caller supplied no value, and there is nofields[]entry to hang it off. Filing it there would hand deployments an override key that lies about what it overrides. So:packages/spec/src/system/operation-message.ts, addressederrors.<key>, same machinery and the same resolution order (override → locale →en), sharinginterpolateValidationMessageso the two cannot drift.This is #3957's fix reached from the operation side.
Pins
DELETE_RESTRICTED+ all structured fields (engine-cascade-delete.test.ts, extended not replaced)deleteBehavior— in en and zh-CNdeveloperMessagematches the pre-change sentence exactlydeveloperMessagewhen absentdeleteBehavior/cascadeChangeset
minorfor the three packages. Nothing breaks — structured fields untouched, no test or doc pinned the message text, both new fields additive.check-changeset-no-major.mjsis the second reason: every publishable package is in the Changesetsfixedgroup, so onemajorpromotes all ~70 packages, and the launch-window convention ships even genuinely breaking changes asminor.packages/specis touched (newoperation-message.ts+objectLabelKey). This is not an error-code-ledger touch — no new or renamederror.code, so ADR-0112's registry is untouched andcheck:error-code-casingpasses clean.Generated by Claude Code