Skip to content

feat(spec,hono-server): retire SessionUser.language — /auth/me/localization is the one read face for the user's language (#14788) - #15386

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-14788-retire-session-user-language
Sep 4, 2026
Merged

feat(spec,hono-server): retire SessionUser.language — /auth/me/localization is the one read face for the user's language (#14788)#15386
os-justin merged 5 commits into
mainfrom
claude/issue-14788-retire-session-user-language

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #14788

Implemented-by: session_01H2oQebDDxYKfWZusyd8GXk (domain:spec execution seat, os-dev subagent, CONTRACT_REVIEW_TIER — Clause ② yes: draft, needs:contract-review, in-seat review before ready).

Ruling executed

Maintainer ruling D (comment 5522880441, 2026-09-03, verbatim reply 「同意」): retire SessionUserSchema.language under ADR-0049 enforce-or-remove with an ADR-0087 registration; GET /auth/me/localization keeps locale and becomes the single read face for "this user's language", with the precedence sys_user.locale when set → the request's Accept-Language → the deployment default. No replacement field on the session contract; no dual-spelling window.

Premise re-verified on origin/main c4d1354e3 before editing: git grep -n "language" origin/main -- packages/spec/src/api/auth.zod.ts packages/plugins/plugin-auth/src packages/rest/src hit auth.zod.ts:36 once and otherwise only Accept-Language / prose uses — no producer appeared. Control git grep -n SessionUserSchema origin/main -- packages/spec/src/api/auth.zod.ts → 3 hits.

Spec half (@objectstack/spec, minor + BREAKING)

  • packages/spec/src/api/auth.zod.ts: language is a retiredKey() tombstone (the schema is a non-strict z.object, so a bare delete would strip the key silently — ADR-0104). Writing it is a tsc error and a parse error carrying the prescription, on SessionUserSchema and through both envelopes that embed it. The prescription cites the ADR only — check:doc-authoring forbids issue ids in customer-facing spec text, which the retirement playbook's guidance-string convention 2 still prescribes (see findings).
  • ADR-0087: entries/retired-keys/18.api__SessionUser__language.ts (api/SessionUser:language under major 18) + entries/semantic/18.session-user-language-retired.ts (D3 semantic entry, id session-user-language-retired). A RESPONSE surface, so no D2 conversion — the api/AuthFeaturesConfig:passkeys disposition. gen:migration-registry regenerated registry.ts; check:generated --fix regenerated content/docs/references/api/auth.mdx; the spec build wrote authorable-surface/api.json (api/SessionUser:language [RETIRED]) and authorable-defaults/api.json (the = "en" line gone). Liveness ledger: packages/spec/liveness/api.json carries no SessionUser rows (0 hits), so no ledger edit.
  • Pins in api/auth.test.ts: lines 48/68 now pin the ABSENCE; new describe — prescription on parse (/SessionUser\.language.*removed.*\/auth\/me\/localization/s), absence + no default minted, both envelopes refuse the key, and a packages/spec/src-scoped scan for any reader of .language off a SessionUser (ledger + tombstone + pin excluded with reasons).
  • Declared narrowing: the reader-scan pin is scoped to packages/spec/src, not repo-wide. A repo-wide walk from a spec test needs a declared radius in scripts/cross-package-test-inputs.mjs + turbo.json, and a packages/**/*.ts glob would put spec's suite on every PR — the radius that gate exists to keep narrow. Beyond spec, the tombstone's never input type is the enforcement (writers fail tsc), and objectui was measured at zero readers at dispatch.

Endpoint half (@objectstack/plugin-hono-server, minor) — precedence + pin only

packages/plugins/plugin-hono-server/src/current-user-endpoints.ts (the card's plugin-auth/src/current-user-endpoints.ts path is stale — this file is the handler). New resolveSignedInUserLocale (exported, for hosts composing the endpoints directly):

  1. sys_user.locale — read under a system context by the caller's own id (the tryFind shape resolveUserAuthzGrants already uses for the same row), accepted only when it passes the column's OWN shape rule: the endpoint reads the format rule(s) on field locale off getSchema('sys_user').validations and evaluates new RegExp(rule.regex) exactly as objectql's rule validator does on the write. No second locale parser; the one spelling stays SYS_USER_LOCALE_TAG_PATTERN on the object (pinned byte-equal to service-messaging's LOCALE_TAG_SHAPE). Blank, malformed, or UNVERIFIABLE (no rule declared) falls through — never served.
  2. preferredLocaleFromHeader(Accept-Language) from @objectstack/spec/system — the same parse REST and the runtime dispatcher feed execCtx.locale from (Field validation messages are hardcoded English + API field name — penalty_amount must be ≥ 0 reaches end users verbatim #3957); no third reader.
  3. resolveLocalizationContext({ ql, settings, tenantId, userId }) from @objectstack/core.locale — the localization.locale cascade execCtx.locale already derives from on the dispatcher; floor en-US.

currency / timezone and the unauthenticated answer are untouched; the response shape objectui reads (json?.locale, currency) is pinned unchanged. New pin file current-user-endpoints-localization.test.ts (9 cases): rung 1 over rungs 2+3, rung 2 over 3, rung 3 + floor, malformed column ⇒ next rung (four shapes), the registry's rule governs (narrowed rule moves the answer; a malformed rule is skipped, not bypassed), unverifiable column falls through, failed identity read never fails the answer, unauthenticated unchanged, system-context read asserted.

⚠️ Measured while pinning: before this PR the resolver behind these endpoints (makeExecutionContextResolver) assembled NO localization at all — execCtx.locale / currency / timezone were always undefined here, so the endpoint answered locale: null (and currency: null, timezone: null) for every authenticated caller. locale is now always a string for an authenticated caller; currency / timezone remain as they were (null on this resolver) — out of this card's scope, filed separately (see findings).

Contract text

  • No spec-side describe/response schema exists for this endpoint: git grep -n "localization" origin/main -- packages/spec/src hits only unrelated schemas (execution-context, settings, value-domain, etc.); the endpoint is raw-mounted and unledgered (docs/qa FOLLOW-UPS K5 records that). The contract text lives in the handler and the module header, both rewritten to say locale is the user's stated preference first.
  • content/docs sentences describing /auth/me/localization as the request's / tenant's regional defaults: none foundgit grep -n -i "me/localization|regional default" -- content/docs docs skills outside content/docs/releases, content/docs/references and docs/qa returns 0 hits. docs/qa/platform-checklist/areas/access-security.json:2539,2575 ("the resolved currency/locale/timezone", "localization rides the ExecutionContext without a setup gate") are left as-is: still accurate (locale is still a resolved value; the gate clause is unchanged), and docs/qa is checklist prose, not content/docs.

Consumers

  • objectui (measured by the seat at origin/main 0b24d7f): reads locale (and currency) off this endpoint in apps/console/src/languageSeed.ts:84, LocalizationFetchProvider.tsx, AppContent.tsx:233; zero readers of SessionUser.language. Response shape kept ⇒ no objectui card.
  • cloud / hotcrm NOT MEASURED.

Serial resync (PR #15299)

PR #15299 (#14676) merged into main (squash 13c48c2a5) and regenerated packages/spec/src/migrations/registry.ts. Merged origin/main at 27875fc7e through scripts/pm/os-regen-merge.sh (merge commit c13d17c2e, merge base c4d1354e3; head before the merge 22710c48a, after regeneration 06f8e2268), then gen:migration-registry (152 semantic / 89 retired-key / 97 retired-def), spec build, check:generated --fix (only check:docs stale ⇒ gen:docs), check:migration-registry ✓ current. Both sides present in registry.ts: this card's entries (3 hits) and #14676's connector entries (13 hits).

Verification (all on head 06f8e2268 unless stated; verdict lines quoted from the gates' own output)

  • pnpm --filter @objectstack/spec build (under os-verify-lock.sh): VERDICT command-exit 0 · held the lock 156s.
  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/auth.test.ts src/migrations/migrations.test.ts src/shared/retired-key-migrate-sentence.test.ts src/shared/alias-integrity.test.ts src/type-alias-convention.pin.test.ts src/conversions: Test Files 10 passed (10), Tests 426 passed (426), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/current-user-endpoints-localization.test.ts src/current-user-endpoints-multi-tenant.test.ts src/hono-current-user-endpoints.test.ts src/current-user-endpoints-position-grants.test.ts: Test Files 4 passed (4), Tests 36 passed (36), VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-hono-server typecheck: check:test-typecheck: OK — 0 file(s) / 0 error(s), VERDICT command-exit 0. pnpm --filter @objectstack/spec typecheck: check:test-typecheck: OK, VERDICT command-exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands (derived at 06f8e2268, 89 commands, every one run with its exit code captured by redirect): 84 exit 0 after serial reruns; check:doc-authoring was red once on the tombstone's #14788 and is green after the fix; check:skill-examples, check:doc-formula-expressions, check:doc-security-posture refused until @objectstack/client-react..., @objectstack/lint..., @objectstack/formula... were built, then green (✅ 257 prose examples type-check across 3 surface(s)); check:docs-audit-scope / check-affected-docs went red once while a parallel lane's check:authorable-surface was writing its gitignored tree, and are green on a serial rerun (self-test 568 cases pass; bisected: every changed file applied onto pristine 27875fc7e passes).
  • NOT MEASURED (prerequisite refusals, exit 3, CI owns them): pnpm check:dual-build-cjs-loadsRun pnpm build first. ⛔ This is NOT a pass: nothing was measured (needs every package built); pnpm check:type-check-debt--re-measure cannot run: 28 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk.
  • Reverse verification (both legs from the committed state, mutation proven on disk by anchored grep -c, restore proven by git diff HEAD empty + blob hash equal to HEAD): revert the handler to c4d1354e3 ⇒ 7/7 precedence pins RED (expected null to be 'ja-JP' etc.); revert the tombstone ⇒ 4 spec pins RED (expected … to not have property "language", expected [Function] to throw an error). Direction as predicted in both legs.

Contract flags for the in-seat review

  1. execCtx.locale consumers: none change meaning — the dispatcher's assembly (assemble-execution-context.ts: requestLocale ?? localization.locale) is untouched; this endpoint adds the user column ABOVE that same pair, and it never fed execCtx.locale here anyway (see the measurement above).
  2. Readability of sys_user.locale: read under context: { isSystem: true } by the caller's own id — the same shape as core's tryFind for the identical row and plugin-auth's stored-locale reader; a user-context read would route the caller's own identity row through the org_user_ids RLS wall. Reviewer to confirm that fail direction is the intended one.
  3. Shape-rule vs endpoint acceptance: by construction the endpoint's acceptance is the column's own format rule(s), so no mismatch with the WRITE side. Against service-messaging's READ-side normalizeRecipientLocale there is one deliberate asymmetry: that normalizer additionally refuses the literals undefined / null; null (4 letters) is shape-legal on the write rule and would be served by this endpoint as locale: "null" if a lossy producer stored it (undefined is 9 letters and fails the shape). A stored "null" is a data-at-rest defect the write rule already refuses at the API; flagged, not bridged.
  4. Unverifiable column falls through (registry declares no format rule for locale): chosen fail-closed toward the request/default rather than trusting an unvouched value. Reviewer may prefer the other direction.

Attribution note

Commits on this branch carry no Co-Authored-By trailer: the harness attribution line names a model, and the dispatch forbids model identifiers in commits. Recorded here rather than silently chosen.

🤖 Generated with Claude Code

https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-hono-server, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, packages/spec/src/migrations/entries/retired-keys/18.api__SessionUser__language.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, packages/spec/src/migrations/entries/retired-keys/18.api__SessionUser__language.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: tenantId (6 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7packageMentionDocs.

Which tree this was computed on

This run read content/docs from ce9219371a31e6b389c71bb1dc37950dcaeb616d — the merge of head 06f8e2268a567073a69171138ea828f5ded9af26 into base 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ce9219371a31e6b389c71bb1dc37950dcaeb616d && git checkout ce9219371a31e6b389c71bb1dc37950dcaeb616d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7 06f8e2268a567073a69171138ea828f5ded9af26 && git checkout -B drift-repro 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7 && git merge --no-ff 06f8e2268a567073a69171138ea828f5ded9af26

node scripts/docs-audit/affected-docs.mjs --json 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5b2ad1b41af24a9dd4bf159802dcee3d527b7db7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Landing provenance — domain:spec seat (session session_01H2oQebDDxYKfWZusyd8GXk): Clause ② contract review on the card (#14788, comment 5541680728) — isolated second opinion at tier PASS WITH CONDITIONS, both conditions signed by the seat → ACCEPT at head 06f8e2268; needs:contract-review cleared on the card and on this PR. Flipped ready and auto-merge armed (squash through the merge queue) at 2026-09-04T14:13Z; the queue admits it once CI on this head is green. On MERGED the seat strips pm:dispatched from #14788, probes origin/main, and posts the unlock note on #14762.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants