Blocked-by: objectstack-ai/objectstack#14787
Why
The platform now has a first-class per-user notification language. sys_user.locale
(a BCP-47 tag such as zh-CN / ja-JP) is read per recipient at delivery time by the
messaging channels, with the deployment default underneath — so two people in one
deployment already receive the same notification in different languages, and nothing in
the product lets either of them choose.
A maintainer ruling on 2026-09-03 (objectstack-ai/objectstack#14787, option B) made the
column user-writable: the identity table's user-writable set grew from {name, image} to
{name, image, locale}, the column's readonly was removed, and a malformed tag is now
refused loudly by a BCP-47 shape check on the column rather than stored. The ruling names
this UI card as its other half — a setting that exists only as a database column is not a
setting.
What to build
A "Language" item in the account / profile settings surface, on the user's own record:
- a picker rather than a free-text box. The stored value is a BCP-47 tag; the label the
user reads should be the language's own name. - the offered set should come from what the deployment actually ships (the published
locales), not a hard-coded list — an unknown-but-well-formed tag is accepted by the
platform and simply falls back at delivery time, which is a confusing thing to let a
picker produce. - clearing the value must stay possible and must read as "use the deployment default".
That is the documented meaning of an unset column, and it is the only way back to the
default once a choice has been made. - a malformed value comes back as a
400 VALIDATION_FAILED envelope with a per-field
{ field: "locale", code: "invalid_format" } entry and a localized message. Surface
the field error on the item rather than as a bare toast.
⚠️ The write route is not settled yet — check before building
The ruling opened the COLUMN, not a self-service route, and the two are separate
decisions in ADR-0092 (D2 decides which columns, D5 leaves who to the permission layer).
As of the ruling:
- the identity write guard now admits
locale on a user-context update to sys_user; - but
member_default still declares allowEdit: false on sys_user, so a rank-and-file
member's PATCH to their own row is refused by the permission layer before the guard is
consulted; - and better-auth's
/update-user (what the existing update_my_profile action posts to)
cannot carry the field: locale is deliberately not a better-auth additionalFields
entry, so that endpoint does not know the column.
So a platform admin can set it today and an ordinary member cannot. Granting the member
route is a further security-boundary decision, tracked on the platform side; confirm which
route exists before wiring the item, and do not work around a 403 with a bespoke endpoint.
Acceptance
- the item appears on the signed-in user's own account settings and nowhere else;
- picking a language persists and survives a reload;
- clearing it returns the account to the deployment default;
- a refused value renders the server's per-field message on the item;
- the item is hidden (or read-only with a reason) when the deployment's write route is not
available to the signed-in user, rather than rendering a control that answers 403.
Generated by Claude Code
Blocked-by: objectstack-ai/objectstack#14787
Why
The platform now has a first-class per-user notification language.
sys_user.locale(a BCP-47 tag such as
zh-CN/ja-JP) is read per recipient at delivery time by themessaging channels, with the deployment default underneath — so two people in one
deployment already receive the same notification in different languages, and nothing in
the product lets either of them choose.
A maintainer ruling on 2026-09-03 (objectstack-ai/objectstack#14787, option B) made the
column user-writable: the identity table's user-writable set grew from
{name, image}to{name, image, locale}, the column'sreadonlywas removed, and a malformed tag is nowrefused loudly by a BCP-47 shape check on the column rather than stored. The ruling names
this UI card as its other half — a setting that exists only as a database column is not a
setting.
What to build
A "Language" item in the account / profile settings surface, on the user's own record:
user reads should be the language's own name.
locales), not a hard-coded list — an unknown-but-well-formed tag is accepted by the
platform and simply falls back at delivery time, which is a confusing thing to let a
picker produce.
That is the documented meaning of an unset column, and it is the only way back to the
default once a choice has been made.
400 VALIDATION_FAILEDenvelope with a per-field{ field: "locale", code: "invalid_format" }entry and a localized message. Surfacethe field error on the item rather than as a bare toast.
The ruling opened the COLUMN, not a self-service route, and the two are separate
decisions in ADR-0092 (D2 decides which columns, D5 leaves who to the permission layer).
As of the ruling:
localeon a user-context update tosys_user;member_defaultstill declaresallowEdit: falseonsys_user, so a rank-and-filemember's PATCH to their own row is refused by the permission layer before the guard is
consulted;
/update-user(what the existingupdate_my_profileaction posts to)cannot carry the field:
localeis deliberately not a better-authadditionalFieldsentry, so that endpoint does not know the column.
So a platform admin can set it today and an ordinary member cannot. Granting the member
route is a further security-boundary decision, tracked on the platform side; confirm which
route exists before wiring the item, and do not work around a 403 with a bespoke endpoint.
Acceptance
available to the signed-in user, rather than rendering a control that answers 403.
Generated by Claude Code