Skip to content

Commit 3d4e92f

Browse files
claude[bot]claude
andauthored
docs(adr): ADR-0092 Tier 1 is {name, image, locale} — amended in place per the #14787 ruling (#14951) (#15752)
* docs(adr): ADR-0092 Tier 1 is {name, image, locale} — amended in place The 2026-09-03 maintainer ruling on #14787 widened the ADR-0092 D2 self-service whitelist to three fields, and the code has said so since: SYS_USER_PROFILE_EDIT_FIELDS in packages/plugins/plugin-auth/src/sys-user-writable-fields.ts holds {name, image, locale}. ADR-0092 still recorded the two-field set as the decision, which is the condition under which a later PR "restores" the old behaviour in good faith. Amend ADR-0092 in place per the vehicle ruling on this card (A: dated revision line + D1 rewritten, no superseding ADR): - Date line gains a 2026-09-05 amendment entry, in the file's own revision convention. - D1's Tier 1 list gains `locale`, with a D1 Amendment blockquote recording the four binding points. - TL;DR D1 and D2 bullets, and the D2 Behaviour sentence, restate the three-field set. - D6 gains a note stating the mirror set and Tier 1 are no longer the same set, naming SESSION_SNAPSHOT_MIRRORED_FIELDS in packages/plugins/plugin-auth/src/identity-write-guard.ts. - The D5 Amendment's closing "Not amended by this" paragraph is trued up: the table it said was unreconciled is reconciled here. - The D3 design sketch is left verbatim and annotated, so a code-shaped two-field literal is not read as the live value. - ADR-0105's passing two-field spelling is corrected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * docs(adr): make the ADR-0092 amendment's code citations machine-checked The three constants the amendment cites were written as bare backticked paths. `docs/adr/**` sets the symbol-anchor corpus's `checkBarePaths` off (a measured corpus decision -- judging every bare path there yields 1,056 findings, almost none of them anchors), so a bare path is counted but never resolved: the citation could rot silently. Rewritten in the symbol-anchor form the gate does resolve, which also satisfies the ruling's "cite it by name" more literally than prose did. check-adr-symbol-anchors goes 186 -> 189 symbol anchors and 167 -> 170 declaration anchors, 0 hard findings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e500f2 commit 3d4e92f

2 files changed

Lines changed: 104 additions & 10 deletions

File tree

docs/adr/0092-sys-user-profile-field-delegation.md

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ADR-0092: Identity-table write guard — engine-enforced `managedBy: 'better-auth'`, with sys_user profile fields as the first whitelist
22

33
- **Status:** Accepted
4-
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)**
4+
- **Date:** 2026-07-10 (proposed) · 2026-07-11 (revised: D2 generalized from a sys_user-only hook to a registry-driven guard over every better-auth-managed object, per review) · 2026-07-11 (accepted) · **2026-09-03 (amended: D5 — self-service edits of the whitelisted columns route through the generic data path; see the D5 Amendment)** · **2026-09-05 (amended: D1 — Tier 1 becomes `{name, image, locale}`, carrying the 2026-09-03 ruling on #14787; see the D1 Amendment)**
55
- **Implementation:** #2816 (generic identity write guard — D2/D3/D6) → #2817 (sys_user edit affordance + form field gating — D4, gated on #2816)
66
- **Deciders:** ObjectStack Protocol Architects
77
- **Relates to:** [ADR-0010](./0010-metadata-protection-model.md) (identity tables managed by better-auth), [ADR-0049](./0049-no-unenforced-security-properties.md) (no unenforced security properties), [ADR-0068](./0068-unified-user-context-and-built-in-identity-roles.md) (platform-admin gate), [ADR-0069](./0069-enterprise-authentication-hardening.md) (system-managed auth stamps), #2766 / PR #2771 (admin user management + identity import), #2784 (originating RFC)
@@ -21,13 +21,17 @@ not a bigger lock.
2121

2222
Decision — one mechanism serves both:
2323

24-
- **D1** — classify `sys_user` columns into three tiers; only `name` and `image` are
25-
profile-editable through the generic path.
24+
- **D1** — classify `sys_user` columns into three tiers; only `name`, `image` and
25+
`locale` are profile-editable through the generic path.
26+
⚠️ **Amended 2026-09-05** — this bullet read "only `name` and `image`" as
27+
originally accepted; `locale` was admitted to Tier 1 by the maintainer ruling of
28+
2026-09-03 on #14787. Read the D1 Amendment below.
2629
- **D2** — plugin-auth installs a **generic identity write guard**: engine
2730
`before{Insert,Update,Delete}` hooks that fail-closed reject **user-context**
2831
writes to *every* object whose schema declares `managedBy: 'better-auth'`
2932
(registry-driven, no hardcoded table list). A per-object **update whitelist**
30-
is the only opening; `sys_user → {name, image}` is its first entry. Internal
33+
is the only opening; `sys_user → {name, image, locale}` is its first entry
34+
(`{name, image}` as originally shipped — see the D1 Amendment). Internal
3135
writes (better-auth adapter, `isSystem` plugin/system contexts) bypass untouched.
3236
- **D3** — one whitelist module; the import upsert's `UPDATE_ALLOWED_FIELDS`
3337
(PR #2771) becomes a superset-by-construction of the sys_user entry.
@@ -107,10 +111,20 @@ whitelist" is how drift ships — coherence is handled explicitly (D6).
107111

108112
### D1 — sys_user field tiers
109113

114+
*(Tier 1 as amended 2026-09-05 — see the D1 Amendment below. This list is rewritten
115+
in place rather than kept verbatim-plus-note, unlike D5: it is the set the D2 guard
116+
is read against, and a reader who took the pre-amendment list as current would
117+
delete an enforced member in good faith. The superseded wording is quoted inside
118+
the Amendment so nothing is lost.)*
119+
110120
**Tier 1: profile-editable** (standard form / data API, guarded by D2):
111121

112122
- `name` — display name. No auth semantics (`displayNameField`, not a login key).
113123
- `image` — avatar URL. No auth semantics.
124+
- `locale` — preferred BCP-47 language tag. No auth semantics: not a login key, not
125+
authorization state, and better-auth is oblivious to it. Admitted 2026-09-03 by
126+
maintainer ruling on #14787; shape-checked at the write by the column's own
127+
`locale_bcp47_shape` rule. See the D1 Amendment.
114128

115129
**Tier 2: admin-surface-only** — legitimate admin writes exist, but each has a
116130
dedicated surface with its own semantics; the generic form must not become a second
@@ -141,6 +155,54 @@ door:
141155
`locked_until`, `failed_login_count`, `mfa_required_at`, `last_login_at`,
142156
`last_login_ip`, `source`, `id`, `created_at`, `updated_at`.
143157

158+
> **D1 Amendment (2026-09-05, #14951)** — carrying the maintainer ruling of
159+
> 2026-09-03 on #14787 (option B, adopted verbatim 「同意」). The *vehicle* was itself
160+
> ruled on #14951: amend ADR-0092 in place, no superseding ADR.
161+
>
162+
> **1 — Tier 1 is `{name, image, locale}`.** The list above is the amended one; as
163+
> accepted 2026-07-11 it read "`name` — display name … `image` — avatar URL" and
164+
> nothing else. The enforced constant
165+
> `packages/plugins/plugin-auth/src/sys-user-writable-fields.ts#SYS_USER_PROFILE_EDIT_FIELDS`
166+
> has held three members since that ruling shipped. This ADR — not the constant — was the record
167+
> out of date, and Prime Directive #13 is why that is not a cosmetic gap: an
168+
> accepted ADR binds until it says otherwise, so a reader reaching the old table
169+
> first would read the third field as drift to be corrected. That is backwards, and
170+
> it is the condition under which a later PR "restores" the old behaviour in good
171+
> faith.
172+
>
173+
> **2 — `locale` qualifies on D1's own test, unchanged.** It has no auth semantics:
174+
> not a login key (unlike `phone_number`, Tier 2), not authorization state (unlike
175+
> `role` and the ban columns, Tier 2), not credential-adjacent (Tier 3). better-auth
176+
> is oblivious to it — neither one of its own user fields nor a declared
177+
> `additionalFields` entry, the latter deliberately, because declaring it there
178+
> would make `getSession` SELECT a column an environment that has not run
179+
> schema-sync does not have. That is exactly the argument that put `name` and
180+
> `image` in Tier 1, applied to a third column without weakening it.
181+
>
182+
> **3 — D6's mirror set deliberately did NOT widen, and the two sets are no longer
183+
> the same set.** Tier 1 and the session-snapshot mirror coincided only while the
184+
> whitelist happened to match better-auth's user model. They no longer do:
185+
> `packages/plugins/plugin-auth/src/identity-write-guard.ts#SESSION_SNAPSHOT_MIRRORED_FIELDS`
186+
> holds `{name, image}` and is a separately named constant for that reason. Mirroring `locale` would
187+
> manufacture an incoherence rather than repair one — see the D6 amendment note.
188+
>
189+
> **4 — D5 is unchanged by this amendment, and is restated because it is where
190+
> readers go next.** Two independent answers from two layers: the **whitelist
191+
> decides which columns** any permitted actor may touch (D2's guard), and
192+
> **permission sets decide who** may update at all (D5). Widening Tier 1 does not
193+
> widen who; widening a permission set does not widen which columns.
194+
>
195+
> ⚠️ **On member self-service, read the D5 Amendment rather than this paragraph.**
196+
> The #14787 ruling that admitted `locale` opened *columns*, not *principals*; it
197+
> did not decide whether an ordinary member reaches their own row. That question
198+
> was taken separately by the 2026-09-03 ruling on #14959 (decision batch #22,
199+
> verbatim 「同意」) and is already recorded below as the D5 Amendment:
200+
> `member_default` now names `sys_user` explicitly with `allowEdit: true`,
201+
> row-scoped to the caller by the `sys_user_self` RLS carve-out. Anything past that
202+
> one row — an org admin editing a colleague's profile — **remains open**:
203+
> `sys_user_org_members` stays `select`-only precisely so it cannot compose into
204+
> that write, and no ruling has taken it. This amendment does not take it either.
205+
144206
### D2 — A generic, registry-driven identity write guard
145207

146208
`plugin-auth` registers (at `kernel:ready`, same pattern as the existing SCIM
@@ -166,7 +228,10 @@ Behaviour:
166228
registry — `registerManagedUpdateWhitelist(object, fields)` — consulted by the
167229
`beforeUpdate` guard. Non-whitelisted keys are stripped; if the payload becomes
168230
empty the hook throws (loud failure, not a silent no-op). First and only entry
169-
shipped by this ADR: `sys_user → SYS_USER_PROFILE_EDIT_FIELDS` (D1 Tier 1).
231+
shipped by this ADR: `sys_user → SYS_USER_PROFILE_EDIT_FIELDS` (D1 Tier 1 —
232+
`{name, image, locale}` since the 2026-09-05 amendment, `{name, image}` as
233+
originally shipped). Still the only entry: what the amendment widened is that
234+
entry's field set, not the number of registered objects.
170235
Unknown/new columns are non-whitelisted by construction — adding a field to any
171236
identity table never silently opens it.
172237
- **Covers both update shapes**: single-id updates and `options.multi` bulk updates
@@ -214,6 +279,11 @@ export const SYS_USER_IMPORT_UPDATE_FIELDS = new Set([
214279
]);
215280
```
216281

282+
⚠️ **As amended 2026-09-05:** the sketch above is the 2026-07-10 design text and is
283+
left verbatim; the shipped `SYS_USER_PROFILE_EDIT_FIELDS` holds
284+
`{name, image, locale}`. D1's tier list is the current statement of the set — do
285+
not read the illustrative `new Set(['name', 'image'])` above as the live value.
286+
217287
`admin-import-users.ts` replaces its private `UPDATE_ALLOWED_FIELDS` with
218288
`SYS_USER_IMPORT_UPDATE_FIELDS`. The relationship is subset-by-construction
219289
(a spread, not two hand-maintained lists), which is the actual anti-drift property
@@ -324,9 +394,10 @@ name"), that is a permission-set + RLS decision (`sys_user_org_members` is curre
324394
> a shape check is most easily skipped; and `locale` as a better-auth
325395
> `additionalFields` entry, refused on #13881's measurement above.
326396
>
327-
> **Not amended by this:** D1's tier *table* still lists two Tier-1 members. The
328-
> whitelist constant is the enforced one and holds three; reconciling the table
329-
> is tracked separately (#14951).
397+
> **Not amended by this, and since reconciled:** when this amendment was written
398+
> D1's tier *table* still listed two Tier-1 members while the enforced whitelist
399+
> constant held three. #14951 closed that gap on 2026-09-05 — the table now holds
400+
> `{name, image, locale}` too. See the D1 Amendment.
330401
331402
### D6 — Session-cache invalidation companion hook
332403

@@ -336,6 +407,27 @@ changed a Tier-1 field. Implementation detail delegated to the auth manager (it
336407
the storage keys); the hook only reports "user X changed". No-op when secondary
337408
storage isn't wired (single-node memory cache TTLs it out).
338409

410+
> **D6 Amendment note (2026-09-05, #14951).** The mirror set and D1's Tier-1
411+
> whitelist are **no longer the same set**, and the divergence is deliberate rather
412+
> than a deferral. D6 mirrors the columns better-auth itself keeps in its cached
413+
> `{session, user}` snapshots —
414+
> `packages/plugins/plugin-auth/src/identity-write-guard.ts#SESSION_SNAPSHOT_MIRRORED_FIELDS`,
415+
> which holds `{name, image}`. Tier 1 holds `{name, image, locale}`. `locale` is excluded on
416+
> purpose: better-auth carries no such field on its user model, so there is no
417+
> stale cached copy to repair, and merging one would MANUFACTURE an incoherence —
418+
> a `user.locale` key present only on sessions that happen to be cached, appearing
419+
> only after a profile edit and differing per session between two callers of the
420+
> same endpoint. The Context note above — "for the D1 whitelist (`name`, `image`)
421+
> staleness is cosmetic, but 'cosmetic until someone widens the whitelist' is how
422+
> drift ships" — is **answered here rather than rewritten**: the whitelist was
423+
> widened, and D6 correctly did not follow.
424+
>
425+
> ⚠️ Widening Tier 1 again does not widen this set. Add a column to the mirror only
426+
> when better-auth actually carries it on its user model (its own field, or a
427+
> declared `additionalFields` entry), and then only under the name better-auth
428+
> uses — a snake_case ObjectStack column whose better-auth spelling is camelCase
429+
> needs a translation, not an entry.
430+
339431
### Audit (RFC evaluation item 4 — no decision needed)
340432

341433
Nothing to build: plugin-audit already registers engine-wide `beforeUpdate` (previous

docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,10 @@ orgs + `organization_id` columns + authored RLS, without
120120
(`sys_user.manager_id`, `sys_user.primary_business_unit_id`,
121121
`sys_user.ai_access`) and ADR-0092's registry-driven identity write guard
122122
with a per-object update whitelist is the sanctioned mechanism
123-
(`sys_user → {name, image}` is its first entry). Group-mode org/member
124-
metadata needs whitelist entries and a collision guard, not a new mechanism.
123+
(`sys_user → {name, image, locale}` is its first entry — `locale` admitted
124+
2026-09-03 by maintainer ruling on #14787; see the ADR-0092 D1 Amendment).
125+
Group-mode org/member metadata needs whitelist entries and a collision guard,
126+
not a new mechanism.
125127

126128
### Industry convergence (why this shape, and not another)
127129

0 commit comments

Comments
 (0)