Skip to content
71 changes: 71 additions & 0 deletions .changeset/sys-user-locale-user-writable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
"@objectstack/platform-objects": minor
"@objectstack/plugin-auth": minor
"@objectstack/service-messaging": minor
---

feat(platform-objects,plugin-auth): a user may set their own `sys_user.locale` (#14787)

Maintainer ruling 2026-09-03, option B, quoted verbatim and untranslated as
adopted:

> 「同意」

The identity table's user-writable set grows from two fields to three. This is a
security-boundary act, taken by the maintainer and recorded as one — it is the
first widening of the ADR-0092 D2 self-service whitelist since that ADR shipped
`{name, image}` as its first and only entry. `sys_user.locale` landed
`readonly` and off the whitelist three weeks earlier (#13881 / #14775), which
recorded a decision nobody had made yet; the ruling made it.

Three edits move together, and each one is inert without the other two:

- `SYS_USER_PROFILE_EDIT_FIELDS` becomes `{name, image, locale}`, so the
identity write guard admits the column instead of stripping it (and, on a
locale-only PATCH, throwing). `SYS_USER_IMPORT_UPDATE_FIELDS` inherits the
widening by construction — it is a spread of the profile set, not a second
list.
- `MANAGED_EXTENSION_EDITABLE_FIELDS` gains a `sys_user` entry holding
`locale` and nothing else.
- `sys_user.locale` drops `readonly`. Without this the engine's readonly strip
removes a caller-supplied value before the guard or the validator ever sees
it, so the whitelist entry alone would have been a silent no-op.

**A malformed value is refused, not stored.** The column now declares a
`locale_bcp47_shape` `format` validation rule carrying the same BCP-47 pattern
the delivery-time reader uses, so objectql's rule validator rejects a malformed
tag on insert, by-id update and bulk update with the standard
`VALIDATION_FAILED` / `invalid_format` envelope (HTTP 400). The check is of
SHAPE, not of membership: an unknown-but-well-formed tag is accepted and falls
to the delivery ladder's floor rather than dead-lettering a notification, which
is the property #13881's per-recipient chain was built to hold. An absent, null
or empty column stays legal — clearing it is how a user returns to the
deployment default, which remains the fallback.

**What did NOT widen.** The ADR-0092 D6 session-snapshot mirror keeps
`{name, image}`: better-auth has no `locale` on its user model and it is
deliberately not an `additionalFields` entry, so there is no cached copy to keep
coherent, and merging one in would manufacture a `user.locale` key present only
on sessions that happen to be cached and only after a profile edit. The mirror
set is now named separately from the update whitelist rather than derived from
it.

**Who may perform the write is unchanged, and is a separate question.** ADR-0092
D5 leaves that with the permission layer: `member_default` still denies
`allowEdit` on `sys_user`, so a rank-and-file member reaches this column through
no shipped surface yet — the widening opens the COLUMN, not a self-service
route. Granting one (the `sys_api_key` shape: an explicit `member_default` entry
plus a `_self` row-scope for writes) is a further security-boundary decision
that this ruling did not take.

The `identity-write-guard` and `managed-extension-fields` pins that recorded the
old posture are FLIPPED, not deleted, each naming the ruling that reversed it —
a pin that recorded a real decision is evidence, and evidence of a superseded
decision is what tells the next reader the reversal was deliberate.

`@objectstack/service-messaging` is a docs-and-export change only: its
`LOCALE_TAG_SHAPE` is unchanged in behaviour and now exported so a parity pin
can hold it byte-identical to the write-side pattern. Read-side normalization
stays — it is strictly the stricter of the two (`"null"` is shape-legal and only
the read side refuses it) and it guards values that arrive below the data API,
where no write rule runs.
4 changes: 2 additions & 2 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ that silently does not happen.
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` |
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
Expand All @@ -117,7 +117,7 @@ that silently does not happen.
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5891` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3735`, `:3745`, `:3772` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` |
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6589` |
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12084` |
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12013` |
Expand Down
8 changes: 4 additions & 4 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.**

Tenancy itself is enabled *by default* — `isTenancyDisabled()` reads
`tenancy.enabled === false` and nothing else — so the object registry only has to
find the opt-outs. Across 297 declared objects — the dated, ⛔ unenforced
find the opt-outs. Across 298 declared objects — the dated, ⛔ unenforced
corpus-scale figure below — exactly two opt out (`sys_api_key`,
`sys_sso_provider`), and no write call site on this surface targets either.

Expand Down Expand Up @@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-09-03 at `98b1cf0b7`.
Measured on 2026-09-03 at `631038b03`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 540 |
| tracked non-test sources scanned | 542 |
| engine-shaped types recognised | 57 |
| declared objects in the registry | 297 |
| declared objects in the registry | 298 |
| same-named calls subtracted as non-engine | 130 |

{/* END GENERATED: tenant-audit-census */}
6 changes: 3 additions & 3 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-09-03 at `98b1cf0b7`.
Measured on 2026-09-03 at `631038b03`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 540 |
| tracked non-test sources scanned | 542 |
| engine-shaped types recognised | 57 |
| declared objects in the registry | 297 |
| declared objects in the registry | 298 |
| same-named calls subtracted as non-engine | 130 |

## Every site
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
}
}
},
_validations: {
locale_bcp47_shape: {
message: "Locale must be a BCP-47 language tag, such as zh-CN or ja-JP."
}
}
},
sys_session: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
}
}
}
},
_validations: {
locale_bcp47_shape: {
message: "El idioma debe ser una etiqueta de idioma BCP-47, por ejemplo zh-CN o ja-JP."
}
}
},
sys_session: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
}
}
}
},
_validations: {
locale_bcp47_shape: {
message: "言語は BCP-47 言語タグ(例: zh-CN、ja-JP)である必要があります。"
}
}
},
sys_session: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
}
}
}
},
_validations: {
locale_bcp47_shape: {
message: "语言必须是 BCP-47 语言标签,如 zh-CN 或 ja-JP。"
}
}
},
sys_session: {
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

// ── Core Auth Objects ──────────────────────────────────────────────────────
export { SysUser } from './sys-user.object.js';
export { SysUser, SYS_USER_LOCALE_TAG_PATTERN } from './sys-user.object.js';
export { SysSession } from './sys-session.object.js';
export { SysAccount } from './sys-account.object.js';
export { SysVerification } from './sys-verification.object.js';
Expand Down
96 changes: 87 additions & 9 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
* The BCP-47 shape `sys_user.locale` accepts — a 2–8 letter language subtag
* followed by any number of 1–8 alphanumeric subtags (`zh`, `zh-CN`,
* `zh-Hans-CN`, `es-419`). Shape only: membership in a shipped template bundle
* is the bundle's business, and the delivery-time ladders handle a
* shipped-nowhere tag by falling to their floor.
*
* A JS regex SOURCE string, because that is what a `format` validation rule
* carries (`new RegExp(rule.regex)` in objectql's rule validator) — anchored at
* both ends on purpose, since the rule compiles it unanchored.
*
* ## Why this is exported rather than inlined
*
* There are two readers of this shape and they live in different packages: the
* WRITE side is the `locale_bcp47_shape` rule below, and the READ side is
* `LOCALE_TAG_SHAPE` in `@objectstack/service-messaging`'s
* `recipient-locale.ts`, which refuses anything non-tag-shaped before it
* reaches a template lookup. The two must not drift — a write path that
* accepts what the read path discards would store values that silently fall
* back to the deployment default forever. service-messaging keeps its own
* compiled copy rather than importing this one (that module is documented as
* pure and total, and pulling the identity barrel into it for a regex would
* make a per-recipient normalizer pay a package barrel's load), so the
* agreement is held by a PIN instead of by a shared binding:
* `recipient-locale-shape-parity.test.ts` in service-messaging asserts the two
* spellings are byte-identical and names what breaks if they are not.
*/
export const SYS_USER_LOCALE_TAG_PATTERN = '^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$';

/**
* sys_user — System User Object
*
Expand All @@ -23,7 +52,8 @@ export const SysUser = ObjectSchema.create({
// ADR-0092 D4 — the ONE generic affordance opened on an identity table:
// standard row editing. Safe because the plugin-auth identity write guard
// (ADR-0092 D2) enforces the profile whitelist server-side — a user-context
// update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image});
// update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image, locale}
// since the 2026-09-03 ruling; see the `locale` field below);
// everything else is stripped/rejected regardless of what a form submits.
// The permission layer still decides WHO may edit (platform admins only by
// default; member/org-admin sets keep allowEdit: false). create / import /
Expand Down Expand Up @@ -753,16 +783,35 @@ export const SysUser = ObjectSchema.create({
// MANAGED_EXTENSION_FIELDS, whose ADR-0105 D7 guard proves the name does
// not collide with better-auth's own user schema at the pinned version.
//
// `readonly` for the same reason as every non-whitelisted field above
// (ADR-0092 D4): the identity write guard's self-service whitelist is
// {name, image}, this column is not on it, so a form edit would be
// stripped server-side; rendering it editable would advertise a write the
// runtime refuses. Widening that whitelist is a security-boundary decision
// recorded as an open question on #13881, not made here.
// WRITABLE, and `readonly` is deliberately absent (maintainer ruling
// 2026-09-03, option B — quoted verbatim and untranslated on the ruling
// card): 「同意」to widening the ADR-0092 D2 self-service whitelist from
// {name, image} to {name, image, locale}. The column landed `readonly`
// three weeks earlier because the whitelist did NOT carry it and a
// readonly-but-not-whitelisted column would have advertised a write the
// runtime strips; the ruling moved the whitelist, so the `readonly` that
// mirrored it goes with it. Option A (system-context writes only) was
// considered and rejected: it would make every application build its own
// stamping route for a first-class user attribute.
//
// ⚠️ `readonly` here is a UI/strip affordance, never the boundary — the
// boundary is plugin-auth's identity write guard (ADR-0092 D2), whose
// whitelist is `SYS_USER_PROFILE_EDIT_FIELDS`. Removing `readonly`
// without that entry would change nothing; adding that entry without
// removing `readonly` would strip the value before the guard ever saw it
// (`stripReadonlyFields` runs on the update path). The two move together
// or not at all, and `sys-user-locale-write-contract.test.ts` in
// plugin-auth is what says so out loud.
//
// A malformed tag is REFUSED, not stored and not silently dropped: the
// `locale_bcp47_shape` rule in `validations` below is evaluated
// server-side on insert, by-id update and bulk update, so the only value
// that can reach the column is one the delivery-time reader
// (`service-messaging/src/recipient-locale.ts`) recognises. An unset or
// cleared column keeps falling back to the deployment default.
locale: Field.text({
label: 'Locale',
required: false,
readonly: true,
maxLength: 35,
group: 'Profile',
description:
Expand Down Expand Up @@ -850,7 +899,7 @@ export const SysUser = ObjectSchema.create({
// (ADR-0092 D2) and owned by better-auth (Invite / Create User / admin
// actions), so they are not exposed. `update` stays: it is the ONE
// generic write opened on an identity table (ADR-0092 D4), server-side
// clamped to the profile-field whitelist ({name, image}) by the guard —
// clamped to the profile-field whitelist ({name, image, locale}) by the guard —
// `userActions.edit: true` above declares the affordance. `bulk` grants the
// updateMany surface (bulk ∧ update after #3391); paired with the sole
// `update` write, only bulk-update is admitted (createMany/deleteMany still
Expand All @@ -862,4 +911,33 @@ export const SysUser = ObjectSchema.create({
// managed user table). A declarative `unique` validation rule is intentionally
// not used — uniqueness needs a DB lookup, not a synchronous validation, so it
// is not one of the declarable validation-rule types.
//
// What IS declarable — and is the whole reason this array exists — is the
// shape of a column a user may now set for themselves.
validations: [
{
// The loud half of the 2026-09-03 ruling that made `locale` writable:
// "a malformed value is refused loudly … and never dead-letters a
// notification". Enforcement, not decoration — objectql's rule validator
// runs `validations` on insert, by-id update AND bulk update, so there is
// no write shape that reaches the column without passing here.
//
// Why a `format` rule and not a field-level flag: field-level `readonly`
// is a strip, `maxLength` bounds length only, and the field schema's
// `format` key is authoring metadata no write path reads. The object's
// `validations` array is the platform's one server-enforced channel for
// "this column's values must look like X" (ADR-0049 declared = enforced).
//
// An absent, null or empty value is NOT a violation (`checkFormat`
// returns early) — clearing the column is how a user goes back to the
// deployment default, which the ruling preserves as the fallback for an
// unset column.
type: 'format',
name: 'locale_bcp47_shape',
field: 'locale',
regex: SYS_USER_LOCALE_TAG_PATTERN,
severity: 'error',
message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.',
},
],
});
9 changes: 8 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1347,7 +1347,14 @@ export class AuthPlugin implements Plugin {
// fields stay rejected. `managed-extension-fields.test.ts` proves the two
// populations are disjoint at the pinned better-auth version.
for (const [object, fields] of Object.entries(MANAGED_EXTENSION_EDITABLE_FIELDS)) {
if (object === SystemObjectName.USER) continue; // sys_user tiering above
// `sys_user` is registered ABOVE, from the tiered constant, and is
// skipped here rather than merged: this map has one tier and that
// table has two (form vs. admin bulk import). Its entry there is a
// declaration that must stay a SUBSET of what was registered above —
// pinned in `managed-extension-fields.test.ts`, because a name that
// reaches this map but never the whitelist is a write the platform
// advertises and the guard refuses (ADR-0049).
if (object === SystemObjectName.USER) continue;
registerManagedUpdateWhitelist(object, fields);
}
// [#8317] Canonicalise `sys_member.role` on every ObjectQL write, at
Expand Down
Loading
Loading