Skip to content

Commit 2fd3f1c

Browse files
claude[bot]claude
andauthored
feat(platform-objects,plugin-auth): make sys_user.locale user-writable, with a loud BCP-47 refusal (#14787) (#14958)
* wip(#14787): sys_user.locale becomes user-writable (ruling B) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * test(#14787): pin the BCP-47 check as shape-only, not membership Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(#14787): changeset, i18n bundles for the new rule message, census anchors Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(#14787): re-render the tenant-audit census artefacts this diff moved The new `locale_bcp47_shape` validation-rule name is a snake_case `name:` literal inside a `*.object.ts` file, which `declaredObjects()` counts, so the corpus-scale figure moved 297 -> 298 (and sources scanned 540 -> 542 for the two new test files). Regenerated with the one mechanical repair path, `node scripts/tenant-audit-census.mjs --write`, and updated the hand-written prose figure outside the generated region so it still cites the table it points at. The gate and its self-test are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(#14787): bump service-messaging changeset to minor `@objectstack/service-messaging` now exports `LOCALE_TAG_SHAPE`, a new public symbol. Per the director's contract review (631038b, comment 5527214281): "by the same mechanical floor that makes a new export Clause-2 yes, the level is minor." Declared `patch`; corrected to `minor`. No other change -- this is exactly the "(b) the service-messaging changeset level" half of the envelope the same review authorized alongside the census prose fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3386493 commit 2fd3f1c

19 files changed

Lines changed: 776 additions & 39 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/plugin-auth": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
feat(platform-objects,plugin-auth): a user may set their own `sys_user.locale` (#14787)
8+
9+
Maintainer ruling 2026-09-03, option B, quoted verbatim and untranslated as
10+
adopted:
11+
12+
> 「同意」
13+
14+
The identity table's user-writable set grows from two fields to three. This is a
15+
security-boundary act, taken by the maintainer and recorded as one — it is the
16+
first widening of the ADR-0092 D2 self-service whitelist since that ADR shipped
17+
`{name, image}` as its first and only entry. `sys_user.locale` landed
18+
`readonly` and off the whitelist three weeks earlier (#13881 / #14775), which
19+
recorded a decision nobody had made yet; the ruling made it.
20+
21+
Three edits move together, and each one is inert without the other two:
22+
23+
- `SYS_USER_PROFILE_EDIT_FIELDS` becomes `{name, image, locale}`, so the
24+
identity write guard admits the column instead of stripping it (and, on a
25+
locale-only PATCH, throwing). `SYS_USER_IMPORT_UPDATE_FIELDS` inherits the
26+
widening by construction — it is a spread of the profile set, not a second
27+
list.
28+
- `MANAGED_EXTENSION_EDITABLE_FIELDS` gains a `sys_user` entry holding
29+
`locale` and nothing else.
30+
- `sys_user.locale` drops `readonly`. Without this the engine's readonly strip
31+
removes a caller-supplied value before the guard or the validator ever sees
32+
it, so the whitelist entry alone would have been a silent no-op.
33+
34+
**A malformed value is refused, not stored.** The column now declares a
35+
`locale_bcp47_shape` `format` validation rule carrying the same BCP-47 pattern
36+
the delivery-time reader uses, so objectql's rule validator rejects a malformed
37+
tag on insert, by-id update and bulk update with the standard
38+
`VALIDATION_FAILED` / `invalid_format` envelope (HTTP 400). The check is of
39+
SHAPE, not of membership: an unknown-but-well-formed tag is accepted and falls
40+
to the delivery ladder's floor rather than dead-lettering a notification, which
41+
is the property #13881's per-recipient chain was built to hold. An absent, null
42+
or empty column stays legal — clearing it is how a user returns to the
43+
deployment default, which remains the fallback.
44+
45+
**What did NOT widen.** The ADR-0092 D6 session-snapshot mirror keeps
46+
`{name, image}`: better-auth has no `locale` on its user model and it is
47+
deliberately not an `additionalFields` entry, so there is no cached copy to keep
48+
coherent, and merging one in would manufacture a `user.locale` key present only
49+
on sessions that happen to be cached and only after a profile edit. The mirror
50+
set is now named separately from the update whitelist rather than derived from
51+
it.
52+
53+
**Who may perform the write is unchanged, and is a separate question.** ADR-0092
54+
D5 leaves that with the permission layer: `member_default` still denies
55+
`allowEdit` on `sys_user`, so a rank-and-file member reaches this column through
56+
no shipped surface yet — the widening opens the COLUMN, not a self-service
57+
route. Granting one (the `sys_api_key` shape: an explicit `member_default` entry
58+
plus a `_self` row-scope for writes) is a further security-boundary decision
59+
that this ruling did not take.
60+
61+
The `identity-write-guard` and `managed-extension-fields` pins that recorded the
62+
old posture are FLIPPED, not deleted, each naming the ruling that reversed it —
63+
a pin that recorded a real decision is evidence, and evidence of a superseded
64+
decision is what tells the next reader the reversal was deliberate.
65+
66+
`@objectstack/service-messaging` is a docs-and-export change only: its
67+
`LOCALE_TAG_SHAPE` is unchanged in behaviour and now exported so a parity pin
68+
can hold it byte-identical to the write-side pattern. Read-side normalization
69+
stays — it is strictly the stricter of the two (`"null"` is shape-legal and only
70+
the read side refuses it) and it guards values that arrive below the data API,
71+
where no write rule runs.

content/docs/permissions/system-context.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1405` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 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` |
103103
| 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` |
@@ -117,7 +117,7 @@ that silently does not happen.
117117
| 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` |
118118
| 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` |
119119
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
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` |
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:99` |
121121
| 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` |
122122
| 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` |
123123
| 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` |

content/docs/permissions/tenant-audit-census.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.**
8484

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

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

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

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

236236
{/* END GENERATED: tenant-audit-census */}

docs/audits/2026-08-tenant-audit-write-call-sites.counts.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
5252
their values are not compared. The reasoning, and the measurement behind it,
5353
are in `scripts/check-tenant-audit-census.mjs`.
5454

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

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

6464
## Every site

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
304304
}
305305
}
306306
}
307+
},
308+
_validations: {
309+
locale_bcp47_shape: {
310+
message: "Locale must be a BCP-47 language tag, such as zh-CN or ja-JP."
311+
}
307312
}
308313
},
309314
sys_session: {

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
304304
}
305305
}
306306
}
307+
},
308+
_validations: {
309+
locale_bcp47_shape: {
310+
message: "El idioma debe ser una etiqueta de idioma BCP-47, por ejemplo zh-CN o ja-JP."
311+
}
307312
}
308313
},
309314
sys_session: {

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
304304
}
305305
}
306306
}
307+
},
308+
_validations: {
309+
locale_bcp47_shape: {
310+
message: "言語は BCP-47 言語タグ(例: zh-CN、ja-JP)である必要があります。"
311+
}
307312
}
308313
},
309314
sys_session: {

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
304304
}
305305
}
306306
}
307+
},
308+
_validations: {
309+
locale_bcp47_shape: {
310+
message: "语言必须是 BCP-47 语言标签,如 zh-CN 或 ja-JP。"
311+
}
307312
}
308313
},
309314
sys_session: {

packages/platform-objects/src/identity/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
// ── Core Auth Objects ──────────────────────────────────────────────────────
10-
export { SysUser } from './sys-user.object.js';
10+
export { SysUser, SYS_USER_LOCALE_TAG_PATTERN } from './sys-user.object.js';
1111
export { SysSession } from './sys-session.object.js';
1212
export { SysAccount } from './sys-account.object.js';
1313
export { SysVerification } from './sys-verification.object.js';

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@
22

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

5+
/**
6+
* The BCP-47 shape `sys_user.locale` accepts — a 2–8 letter language subtag
7+
* followed by any number of 1–8 alphanumeric subtags (`zh`, `zh-CN`,
8+
* `zh-Hans-CN`, `es-419`). Shape only: membership in a shipped template bundle
9+
* is the bundle's business, and the delivery-time ladders handle a
10+
* shipped-nowhere tag by falling to their floor.
11+
*
12+
* A JS regex SOURCE string, because that is what a `format` validation rule
13+
* carries (`new RegExp(rule.regex)` in objectql's rule validator) — anchored at
14+
* both ends on purpose, since the rule compiles it unanchored.
15+
*
16+
* ## Why this is exported rather than inlined
17+
*
18+
* There are two readers of this shape and they live in different packages: the
19+
* WRITE side is the `locale_bcp47_shape` rule below, and the READ side is
20+
* `LOCALE_TAG_SHAPE` in `@objectstack/service-messaging`'s
21+
* `recipient-locale.ts`, which refuses anything non-tag-shaped before it
22+
* reaches a template lookup. The two must not drift — a write path that
23+
* accepts what the read path discards would store values that silently fall
24+
* back to the deployment default forever. service-messaging keeps its own
25+
* compiled copy rather than importing this one (that module is documented as
26+
* pure and total, and pulling the identity barrel into it for a regex would
27+
* make a per-recipient normalizer pay a package barrel's load), so the
28+
* agreement is held by a PIN instead of by a shared binding:
29+
* `recipient-locale-shape-parity.test.ts` in service-messaging asserts the two
30+
* spellings are byte-identical and names what breaks if they are not.
31+
*/
32+
export const SYS_USER_LOCALE_TAG_PATTERN = '^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$';
33+
534
/**
635
* sys_user — System User Object
736
*
@@ -23,7 +52,8 @@ export const SysUser = ObjectSchema.create({
2352
// ADR-0092 D4 — the ONE generic affordance opened on an identity table:
2453
// standard row editing. Safe because the plugin-auth identity write guard
2554
// (ADR-0092 D2) enforces the profile whitelist server-side — a user-context
26-
// update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image});
55+
// update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image, locale}
56+
// since the 2026-09-03 ruling; see the `locale` field below);
2757
// everything else is stripped/rejected regardless of what a form submits.
2858
// The permission layer still decides WHO may edit (platform admins only by
2959
// default; member/org-admin sets keep allowEdit: false). create / import /
@@ -753,16 +783,35 @@ export const SysUser = ObjectSchema.create({
753783
// MANAGED_EXTENSION_FIELDS, whose ADR-0105 D7 guard proves the name does
754784
// not collide with better-auth's own user schema at the pinned version.
755785
//
756-
// `readonly` for the same reason as every non-whitelisted field above
757-
// (ADR-0092 D4): the identity write guard's self-service whitelist is
758-
// {name, image}, this column is not on it, so a form edit would be
759-
// stripped server-side; rendering it editable would advertise a write the
760-
// runtime refuses. Widening that whitelist is a security-boundary decision
761-
// recorded as an open question on #13881, not made here.
786+
// WRITABLE, and `readonly` is deliberately absent (maintainer ruling
787+
// 2026-09-03, option B — quoted verbatim and untranslated on the ruling
788+
// card): 「同意」to widening the ADR-0092 D2 self-service whitelist from
789+
// {name, image} to {name, image, locale}. The column landed `readonly`
790+
// three weeks earlier because the whitelist did NOT carry it and a
791+
// readonly-but-not-whitelisted column would have advertised a write the
792+
// runtime strips; the ruling moved the whitelist, so the `readonly` that
793+
// mirrored it goes with it. Option A (system-context writes only) was
794+
// considered and rejected: it would make every application build its own
795+
// stamping route for a first-class user attribute.
796+
//
797+
// ⚠️ `readonly` here is a UI/strip affordance, never the boundary — the
798+
// boundary is plugin-auth's identity write guard (ADR-0092 D2), whose
799+
// whitelist is `SYS_USER_PROFILE_EDIT_FIELDS`. Removing `readonly`
800+
// without that entry would change nothing; adding that entry without
801+
// removing `readonly` would strip the value before the guard ever saw it
802+
// (`stripReadonlyFields` runs on the update path). The two move together
803+
// or not at all, and `sys-user-locale-write-contract.test.ts` in
804+
// plugin-auth is what says so out loud.
805+
//
806+
// A malformed tag is REFUSED, not stored and not silently dropped: the
807+
// `locale_bcp47_shape` rule in `validations` below is evaluated
808+
// server-side on insert, by-id update and bulk update, so the only value
809+
// that can reach the column is one the delivery-time reader
810+
// (`service-messaging/src/recipient-locale.ts`) recognises. An unset or
811+
// cleared column keeps falling back to the deployment default.
762812
locale: Field.text({
763813
label: 'Locale',
764814
required: false,
765-
readonly: true,
766815
maxLength: 35,
767816
group: 'Profile',
768817
description:
@@ -850,7 +899,7 @@ export const SysUser = ObjectSchema.create({
850899
// (ADR-0092 D2) and owned by better-auth (Invite / Create User / admin
851900
// actions), so they are not exposed. `update` stays: it is the ONE
852901
// generic write opened on an identity table (ADR-0092 D4), server-side
853-
// clamped to the profile-field whitelist ({name, image}) by the guard —
902+
// clamped to the profile-field whitelist ({name, image, locale}) by the guard —
854903
// `userActions.edit: true` above declares the affordance. `bulk` grants the
855904
// updateMany surface (bulk ∧ update after #3391); paired with the sole
856905
// `update` write, only bulk-update is admitted (createMany/deleteMany still
@@ -862,4 +911,33 @@ export const SysUser = ObjectSchema.create({
862911
// managed user table). A declarative `unique` validation rule is intentionally
863912
// not used — uniqueness needs a DB lookup, not a synchronous validation, so it
864913
// is not one of the declarable validation-rule types.
914+
//
915+
// What IS declarable — and is the whole reason this array exists — is the
916+
// shape of a column a user may now set for themselves.
917+
validations: [
918+
{
919+
// The loud half of the 2026-09-03 ruling that made `locale` writable:
920+
// "a malformed value is refused loudly … and never dead-letters a
921+
// notification". Enforcement, not decoration — objectql's rule validator
922+
// runs `validations` on insert, by-id update AND bulk update, so there is
923+
// no write shape that reaches the column without passing here.
924+
//
925+
// Why a `format` rule and not a field-level flag: field-level `readonly`
926+
// is a strip, `maxLength` bounds length only, and the field schema's
927+
// `format` key is authoring metadata no write path reads. The object's
928+
// `validations` array is the platform's one server-enforced channel for
929+
// "this column's values must look like X" (ADR-0049 declared = enforced).
930+
//
931+
// An absent, null or empty value is NOT a violation (`checkFormat`
932+
// returns early) — clearing the column is how a user goes back to the
933+
// deployment default, which the ruling preserves as the fallback for an
934+
// unset column.
935+
type: 'format',
936+
name: 'locale_bcp47_shape',
937+
field: 'locale',
938+
regex: SYS_USER_LOCALE_TAG_PATTERN,
939+
severity: 'error',
940+
message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.',
941+
},
942+
],
865943
});

0 commit comments

Comments
 (0)