feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.locale + per-recipient notification locale (#13881) - #14775
feat(spec,platform-objects,service-messaging,plugin-auth): sys_user.locale + per-recipient notification locale (#13881)#14775os-sam wants to merge 5 commits into
Conversation
…ranslations, docs regen, retry-path fix
📓 Docs Drift CheckThis PR changes 5 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c04118bb15768f1fb9ba900c7a06e7604da699e6 && git checkout c04118bb15768f1fb9ba900c7a06e7604da699e6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 89a156af88e1f33610bfeb1676cd1cfdad360010 c4f538e5bad77ed080302358f383adb303cd2ece && git checkout -B drift-repro 89a156af88e1f33610bfeb1676cd1cfdad360010 && git merge --no-ff c4f538e5bad77ed080302358f383adb303cd2ece
node scripts/docs-audit/affected-docs.mjs --json 89a156af88e1f33610bfeb1676cd1cfdad360010
|
|
CI reading (
Generated by Claude Code |
|
Contract review — FAIL (patch round 1) — Items 2 (dead-letter), 3 (better-auth adjacency), 6 (all seven flags) and semver passed; the FAIL is items 4 and 7 — two Required patches before landing (empty if PASS):
Dev: apply exactly these four on the same branch ( Generated by Claude Code |
…cks, second behaviour change + TEMPLATE_* asymmetry in the changeset, wording
|
Landing ( Pre-landing checks (contract-review.md, 2026-08-31 ruling): ① in-seat contract review PASS on Generated by Claude Code |
|
Re-enqueue receipt (
Generated by Claude Code |
|
Second kick — re-queueing stops here (
Generated by Claude Code |
Fixes #13881
Clause ② —
needs:contract-review. Draft on purpose; thedomain:specseat flips it after review, never this seat.What this lands
Maintainer ruling 2026-09-01 (comment 5494464459), executed as ruled:
sys_user.locale— a first-class column (packages/platform-objects/src/identity/sys-user.object.ts): BCP-47 tag, optional, Profile group,readonly(ADR-0092 D4 — the self-service whitelist is{name, image}and this column is not on it, so the form must not advertise a write the runtime strips). Owned by objectql likeai_access; better-auth stays oblivious.email-channel.tsreserved: one read point,packages/services/service-messaging/src/recipient-locale.ts(resolveRecipientLocale). Email and SMS read it off the SAMEsys_userrow they already fetch for the address — no second query there; the inbox channel, which never read the row before, makes one read for it on the template path only.sys_user.locale→ deployment default (II18nService.getDefaultLocale(), probed lazily). Absent / empty / whitespace / non-string / malformed / the literal"undefined"or"null"all fall back; nothing named anywhere reaches the ladders as an absent key (their documenteden-USfloor). A locale read that throws is retried address-only and falls back — the delivery still goes out.TEMPLATE_*interaction — below.needs:contract-review; changesetsminorfor@objectstack/spec,@objectstack/platform-objects,@objectstack/service-messaging;patchfor@objectstack/plugin-auth,@objectstack/service-automation.templatedoc/description promises per-recipient locale resolution that the delivery path deliberately does not do (deployment default, ruled 2026-08-13) #12178 was already closed; the sentences it named are among those rewritten.Same chain on all three channels that pick a localized row (email — both arms, inbox — template path, SMS), so one notification cannot arrive in two languages across channels. #14641 (invitation rung in plugin-auth) is NOT implemented here; the two plugin-auth comment sites that said "until a per-user language exists" now point at the column and at #14641.
Ruling item 4 — better-auth adjacent surface (measured before any edit)
AUTH_USER_CONFIG(auth-schema-config.ts) mapsmodelName: 'sys_user'+ three camel→snake renames. The user model has noadditionalFields; the oneadditionalFieldsseam in that file is on the invitation model (D8 placement intent, client-suppliable by design).auth-manager.tsuser: { ...AUTH_USER_CONFIG }carries the measured reason an objectql-owned column must NOT be a better-authadditionalFieldsentry (written forai_access): better-auth SELECTs explicit columns, so declaring it would makegetSessionquery a column an env that has not run schema-sync lacks → broken auth.localefollowsai_access/source/manager_idexactly.MANAGED_EXTENSION_FIELDS.sys_user(packages/plugins/plugin-auth/src/managed-extension-fields.ts) gains'locale'; its guard derives better-auth's real user field surface fromgetAuthTables()with the auth manager's full plugin set and fails on any overlap. Result: no collision at the pinned version (test run below).protection.lock: 'full'is metadata protection (_lockenvelope, author overlays), not DDL: the SQL driver'sinitObjectssync is additive (creates missing columns), which is howai_access/sourcereached existing environments. No hand migration.MANAGED_EXTENSION_EDITABLE_FIELDS) and not inSYS_USER_PROFILE_EDIT_FIELDS— see open question 1 (filed by the seat as [Decision] May a user set their ownsys_user.locale? — the ADR-0092 D2 self-service whitelist stays{name, image}after #13881 (column landsreadonly, system-context writes only) #14787).Ruling item 5 — interaction with the
TEMPLATE_*permanent-failure classemail-channel.classifyErroris untouched:TEMPLATE_NOT_FOUND/TEMPLATE_INACTIVE/MISSING_VARIABLES/TEMPLATE_UNSUPPORTEDstill gradepermanent(dead immediately). What changes is which locale reachessendTemplate:sendTemplate's ladder (plugin-emailtemplate-loader.ts) for a NAMED locale is exact row →en-US→TEMPLATE_NOT_FOUND; with NO locale named it has a third, any-row rung. Under the old single value, the named locale was the deployment default (or nothing); now it is the recipient's own tag, else the deployment default. So a recipient locale can dead-letter a delivery only against a bundle that has neither the requested row nor anen-USrow — off the documented contract (en-USis the ladder's floor).en-USrow — old delivered, new fails for a recipient whose own tag is a third language; (b) there is NO deployment default (i18n service absent orgetDefaultLocaleunimplemented, a shapemessaging-service-plugin.tsexplicitly declares) — old calledsendTemplatewith no locale and the any-row rung delivered, new names the recipient's tag, the any-row rung is skipped, and a tag the bundle lacks isTEMPLATE_NOT_FOUND(permanent). In both the fix is the bundle, not a third rung — recorded, not patched.payload.localeretirement): on thesys_notification_templatearm (email topic path, SMS) the deployment default (II18nService.getDefaultLocale()) is now the second rung; before, that arm fell straight frompayload.localeto the staticenand never consulted it. A deployment whoselocalization.localeis e.g.zh-CNwith a topic bundle holdingenandzhrows renderszhthere now for recipients without a column. SMS is newly handed the deployment-default probe. Recorded in the service-messaging changeset.normalizeRecipientLocalelets through only a BCP-47-shaped string, so the hotcrm shape ("undefined"→TEMPLATE_NOT_FOUNDfor every user without a row) is refused at the seam and pinned red in three suites.sys_notification_templatearm (email / SMS): the store's own ladder (zh-CN→zh→en) is unchanged; an unshipped tag renders theenrow, never a failure.H3 finding —
SessionUserSchema.language(STOPPED on this branch, not touched)packages/spec/src/api/auth.zod.ts:36declareslanguage: z.string().default('en')on the published session-user contract. Measured on7a17f3bf1: no producer and no consumer anywhere in this repo except its own spec test (auth.test.ts:48/68) — grep oflanguageacross plugin-auth / rest / core / spec-api finds only that line and unrelated uses (Accept-Language, error prose). So the "second spelling" the ruling set out to prevent already exists on the contract side, unpopulated. This PR does not addlocaletoSessionUserSchemaand does not renamelanguage; the options are in the report'sopen_questions(filed by the seat as #14788).payload.localedispositionThe ruled chain has two rungs and
payload.localeis not one of them. Measured: it was never a declared key of the notify node (only the genericpayloadpassthrough carried it), and no in-repo producer writes it (auth phone SMS loads its templates directly with its own locale; digests set none). It is retired: the channels no longer read it, the contract text says "not consulted", and a pin in each channel suite asserts a producer-set value does not pick the row. Recorded in the service-messaging changeset as the first of two behaviour changes (the second is the deployment-default rung on thesys_notification_templatearm, § Ruling item 5).Pin sweep (old single-value stance → new chain; every flipped pin asserts the new substance)
packages/spec/src/automation/io-node-config.zod.ts— docblock,template.describe(), both superRefine messages;io-node-config.test.ts— pins now requireper recipient,`sys_user.locale`,deployment default,`payload.locale` is not consulted,2026-09-01, and refusenot one per recipient/one value for the whole notification.packages/services/service-automation/src/builtin/notify-node.ts— descriptor description + two comments;notify-node.test.ts— same inversion.packages/services/service-messaging/src/email-channel.ts(:86-99 option docblock, :117-130 function docblock, both arms),inbox-channel.ts,sms-channel.ts,messaging-service-plugin.ts(comment + both registration log lines).packages/plugins/plugin-auth/src/auth-manager.ts:3022-3033 (invitation rung comment — the rung itself is finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641's),setDefaultEmailLocaledocblock, and (patch round 1) thesendChangeEmailNoticeandsetDefaultSmsLocaledocblocks;auth-email-locale.test.tsheader;phone-sms-texts.tsheader (auth OTP SMS still reads the deployment default — filed as plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale —sys_user.localeexists now and is not read #14762).content/docs/automation/email-templates.mdx;content/docs/references/automation/io-node-config.mdx(generated from the describe).plugin-auth/CHANGELOG.md,plugin-email/CHANGELOG.md(history), and the plugin-authAccept-Language/ deployment-rung text from fix(plugin-auth): bind the auth email locale to the workspace language, not the build-time default #14591 / feat(plugin-auth): auth mail follows the caller's Accept-Language, deployment default second #14600 (still true — auth mail does not read the column; finding(plugin-auth): 邀请邮件的语言仍取部署默认——等用户级语言列落地后给邀请单独一梯级(#14319 裁 A-now/C-later 的追踪) #14641 and plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale —sys_user.localeexists now and is not read #14762).Verification
Head
aa2994e(branchclaude/issue-13881-sys-user-locale). Builds:@objectstack/specbuild, the four changed packages plus their dependency closures (turbo build --filter='PKG^...'and the packages themselves), and thecheck:i18nprerequisite closure — all exit 0 underscripts/pm/os-verify-lock.sh.Tests (vitest,
--maxWorkers=2, real output lines):Test Files 32 passed (32)·Tests 348 passed (348)Test Files 100 passed (100)·Tests 1182 passed (1182)src/automation+src/api/auth.test.tsTest Files 20 passed (20)·Tests 503 passed (503)managed-extension-fields·better-auth-schema-parity·auth-email-locale·identity-write-guardTest Files 4 passed (4)·Tests 100 passed (100)— D7 collision guard green withlocaledeclared: better-auth's user schema owns nolocaleat the pinned versionTest Files 33 passed (33)·Tests 518 passed (518)Typecheck:
pnpm --filter PKG typecheckexit 0 forservice-messaging,plugin-auth(incl.check:test-typecheck),platform-objects,spec.service-automationdeclares notypecheckscript (DEBT ledger) — NOT MEASURED.Red → green on the way: the first run of the new "locale read fails ⇒ retry address-only" pins went red (2 failures) because the retried row still surfaced the column; fixed in the channels (a retried read never consults the locale key), re-run green.
Gates —
node scripts/pm/dispatch-gates.mjs --commandsderived 73 commands from the real change set (23 paths at0504ec4, unchanged shape ataa2994e):aa2994e: 20 green;check-dev-prereqsexit 1 andcheck-test-completenessexit 3 are both PREREQUISITE NOT MET locally (37 unbuilt packages / noturbo run testlog) — NOT MEASURED, not red.11d02ef(the head before the 3-file hash-companion commit): 47 green, includingcheck:api-surface,check:authorable-surface,check:docs,check:i18n-stale-fill,check:cross-package-test-inputs,check:type-check-coverage,check:changeset-gate-self-tests,check:engine-double-contract. The 4 non-green are all PREREQUISITE NOT MET (unbuilt workspace):check:skill-examples(client-react dist),check:dual-build-cjs-loads,check:type-check-debt(--re-measurerefuses without the full closure), andcheck:i18n— which was then measured for real after building its stated closure:check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)ataa2994e.aa2994e:check:i18nOK,check:i18n-stale-fillOK (10 bundle sets, 0 stale fills),pnpm --filter @objectstack/spec check:generated→All 15 generated artifacts are up to date,check:nul-bytesOK,check-system-context-censusOK (no anchor moved).check:i18n-coverage: COULD NOT MEASURE (showcase config needs@objectstack/connector-mcpdist) — NOT MEASURED; the new field's three translated locales are hand-written, not fills.Not run locally (CI owns them):
pnpm lint(repo-wide eslint), the fullpnpm testfarm, Dogfood, Temporal conformance.Patch round 1 (contract review 5518923117 / 5518926733) — head
c4f538eApplied exactly the four required patches: (1)(2) the two
auth-manager.tsdocblocks (sendChangeEmailNotice,setDefaultSmsLocale) now saysys_user.localeexists since #13881 and auth mail / auth SMS do not read it yet (#14762; invitations #14641); (3) the second behaviour change (deployment default newly the second rung on thesys_notification_templatearm — email topic path and SMS; SMS newly handed the probe) and the secondTEMPLATE_*asymmetry (no deployment default + recipient tag absent from a bundle with noen-USrow) recorded in the service-messaging changeset and in § Ruling item 5 above, with "only where the deployment default would have too" corrected; (4) "no second lookup" qualified to email/SMS inrecipient-locale.tsand the changeset (inbox makes one read on the template path), and the platform-objects changeset now says "written only by system-context callers (no admin surface writes it today)". No code behaviour changed in this round (comments, changesets, PR body). #14787 / #14788 not implemented here.Re-verified at
c4f538e: plugin-authmanaged-extension-fields·better-auth-schema-parity·auth-email-locale·identity-write-guard→Test Files 4 passed (4)·Tests 100 passed (100);pnpm --filter @objectstack/service-messaging test→Test Files 32 passed (32)·Tests 348 passed (348);pnpm --filter @objectstack/spec check:generated→All 15 generated artifacts are up to date;check:nul-bytesOK;check-empty-changeset/check-adr-0087-registrationOK (2 declaring changesets, none breaking).Out of scope, filed
sys_user.localeexists now and is not read #14762 — plugin-auth: auth SMS (OTP / invite texts) and request-less auth mail keep the deployment locale;sys_user.localeexists now and is not read (Blocked-by: [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881).Generated by Claude Code