Skip to content

fix(plugin-hono-server): /auth/me/localization resolves the regional defaults instead of answering null to every caller - #15745

Merged
os-litant merged 4 commits into
mainfrom
claude/issue-15387-current-user-localization
Sep 5, 2026
Merged

fix(plugin-hono-server): /auth/me/localization resolves the regional defaults instead of answering null to every caller#15745
os-litant merged 4 commits into
mainfrom
claude/issue-15387-current-user-localization

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15387

GET /auth/me/localization answered currency: null, timezone: null to every authenticated caller, whatever the deployment's localization settings said. The handler read both off the request ExecutionContext and its comment cited ADR-0053 — but the resolver that serves this surface, makeExecutionContextResolver in the same file, is a hand-rolled envelope that assigns neither. Declaration, not delivery.

The reproduction, measured here rather than inherited

The #14788 fixture was widened so a tenant can configure localization.timezone and localization.currency rows, and five cases were added. Against unchanged source: Tests 4 failed | 9 passed (13). A caller on a tenant configured Asia/Shanghai / CNY:

  {
    "authenticated": true,
-   "currency": "EUR",
+   "currency": null,
    "locale": "ja-JP",
-   "timezone": "Europe/Paris",
+   "timezone": null,
  }

The 9 that passed included the rung-1 pin asserting timezone: null — see below.

The repair

All three values now come from one reading of resolveLocalizationContext, the same cascade the dispatcher's shared assembler (core/security/assemble-execution-context.ts) fills execCtx from — so the two faces agree by construction instead of by comment. locale keeps its three #14788 rungs and every one of its answers is unchanged.

Two consequences worth reviewing rather than skimming:

  • The cascade is now read even when locale rung 1 or 2 wins. It has to be: currency / timezone are needed whichever rung answers the language. That adds one sys_setting read to the requests where the caller's own column or Accept-Language already decided the locale — and it is one reading rather than the two that resolving separately for the other two values would have cost on every request.
  • The identity read and the settings read now run concurrently. They are independent, neither throws by its own documented contract, and the console races this endpoint against a 500 ms budget on a device's first visit (objectui seedTenantLanguage), where a needless serial round-trip is a language flash.

No lenient fallback was added at the consumer: the handler no longer reads execCtx.currency / execCtx.timezone at all. currency ?? null is not that fallback returning — it is the cascade's own shape (below).

The existing pin asserted the defect. It now asserts the contract.

current-user-endpoints-localization.test.ts (added by PR #15386) pinned expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null }). That timezone: null was the bug, pinned. This PR changes what that assertion pins, and the file header now says so in full rather than quietly.

It flipped exactly one way, which is itself the interesting result:

  • timezone — null became 'UTC'. The cascade gives it a floor, so an authenticated caller can no longer be answered null for it.
  • currencyunchanged, still null, and still correct. The cascade gives currency no floor. A deployment that configures no currency has none, and inventing one would be a wrong answer where null is merely a missing one (objectui's documented degradation for it is a plain number).

That asymmetry is now pinned in both directions, because the two keys do not share a nullability contract.

The "nulls legal" clause the card left to the fixer

docs/qa/platform-checklist/areas/access-security.json said the trio answers "currency/locale/timezone keys (nulls legal)". Answered explicitly: it no longer holds for all three. The clause is rewritten — locale and timezone always answer (floors en-US / UTC), so a null for either is now a FAIL and a regression of this repair; currency is the one key where null stays legal, and only when no localization.currency is configured. Its verify step now requires configuring the two settings and re-tracing, because an unmoved trace was exactly this defect passing as a green run.

Clause 2 — both limbs judged separately

  • Mechanical limb: NOT triggered. No new key on the published payload (the response keeps exactly its four keys, held by toEqual), and no new exported symbol: the exported-symbol set of the module is identical before and after, 15 symbols, diffed. The new resolver and its interface were deliberately kept module-internal because index.ts does export * from './current-user-endpoints', so anything exported here is published API.
  • Non-mechanizable limb: YES. This fills already-declared fields on a shipped face — the named conformance-class judgment — and re-selects the reachable value class for timezone from "always null" to "always a real zone", with a live external consumer (objectui) reading it. needs:contract-review is applied to this PR and the card together.

Verification

Union re-derived after the change set was final: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at commit 18588cd9dcc, 4 paths, three-dot against merge base c2a336ca2. Gates run locally, each exit code captured after redirection, never through a pipe — all green: nul-bytes, authz-resolver, route-envelope, single-claim-paths, test-source-alias, cross-package-test-inputs, changeset-gate-self-tests, doc-authoring, pm-governed-prose, auth-mount-ledger, system-context-census, error-status-conformance, plus the three changeset scripts against origin/main.

  • pnpm --filter @objectstack/plugin-hono-server exec vitest run21 files / 238 tests passed.
  • pnpm --filter @objectstack/plugin-hono-server typecheck — clean, including the tsconfig.test.json leg, so the new cases really are type-checked.
  • Repo-wide lint, not narrowed: eslint . --no-inline-config --format json5,976 files, 0 errors, 0 warnings, and the edited files are present in that population by name.

Ablation (direction predicted in writing first, including which assertions must stay GREEN). Mutation: the handler's two lines put back to the execCtx reads. Proved on disk by counts, not by an exit code — removed text 1 to 0, injected marker 0 to 2. Predicted 5 red / 8 green; observed Tests 5 failed | 8 passed (13), and the five reds were the five predicted by name. The eight greens are the point: every locale case stayed green, because the mutation removes nothing from language resolution — a red one would have meant the mutation was wider than intended. No rebuild leg applies and that is checked, not assumed: this package's vitest.config.ts aliases @objectstack/core to ../../core/src/index.ts and the subject is imported as ./current-user-endpoints, so nothing in the run resolves through dist. Restored under a trap with absolute paths, and the restore proved the same way the mutation was — post-restore blob hash equal to the HEAD blob (1e6d311a662f...), git diff HEAD empty, marker count back to 0.

Downstream consumer, measured not assumed: the sibling objectui checkout is on this box, and apps/console/src/LocalizationFetchProvider.tsx declares currency?: string | null; timezone?: string | null and feeds LocalizationProvider, so nulls degrade every currency render to a plain number. That is the user-visible half this repairs. No objectui change is needed or made — its types already admit both.

Not done here, on purpose

The resolver behind these endpoints omits more of the closed entry field set than these three (principalKind, audience, authGate, accessToken, oauthScopes, onBehalfOf) — the same drift class assemble-execution-context.ts exists to make unrepresentable. I measured it rather than assuming: principalKind is read by resolvePermissionSetsForContext, but only to detect 'agent', so on this OAuth-less face its absence is currently indistinguishable from 'human' — a latent hazard, not a live defect. It is not widened into this PR. Filing it was blocked: this session's dedup channels are all unavailable (repo-scoped REST 403, gh absent, MCP issue search rate-limited mid-run, so an empty search could not be validated against a control), and filing blind is worse than handing it back — it is in the report to the dispatching PM seat with this measurement.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

…tion answer

Widens the #14788 fixture so the tenant can configure `localization.timezone`
and `localization.currency` rows (the endpoint reads all three keys in one
`$in` query, so the double now answers whichever the fixture sets), and adds
five cases for the resolved regional defaults.

Measured against unchanged source: 4 failed | 9 passed. An authenticated
caller configured with `Asia/Shanghai` / `CNY` is answered
`currency: null, timezone: null` — the defect, reproduced here rather than
inherited from the card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…efaults instead of answering null

The handler read `currency` / `timezone` off the request ExecutionContext,
citing ADR-0053 — but `makeExecutionContextResolver`, the resolver that serves
this surface, is a hand-rolled envelope that assigns neither. Both were
therefore `undefined` on every request and the `?? null` answered `null` to
every authenticated caller, whatever the `localization` settings said.

All three values now come from ONE reading of `resolveLocalizationContext`,
the same cascade the dispatcher's shared assembler fills `execCtx` from, so
the two faces agree by construction rather than by comment. `locale` keeps its
three #14788 rungs and its answers are unchanged; what changed underneath is
that the cascade is read even when rung 1 or 2 wins, because the other two
values need it whichever rung answers the language. The identity read and the
settings read are independent and now run concurrently — the console races
this endpoint against a 500 ms budget on a first visit.

The #14788 pin asserted `timezone: null` as the contract; it was pinning the
defect, and it now asserts the corrected one. `currency: null` in that fixture
is UNCHANGED and still correct: the cascade gives `timezone` a floor (`UTC`)
and `currency` none. The platform checklist's "nulls legal" clause is
rewritten to that asymmetry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 3 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 — 7 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 01424885590ae9f661df97321b0e53064290df65packageMentionDocs.

Which tree this was computed on

This run read content/docs from 734825363109a5df988b1befbf0c29fd45f0a64b — the merge of head 8d3c00829cdc42b8cc1f3815940c253931bad682 into base 01424885590ae9f661df97321b0e53064290df65, 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 734825363109a5df988b1befbf0c29fd45f0a64b && git checkout 734825363109a5df988b1befbf0c29fd45f0a64b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01424885590ae9f661df97321b0e53064290df65 8d3c00829cdc42b8cc1f3815940c253931bad682 && git checkout -B drift-repro 01424885590ae9f661df97321b0e53064290df65 && git merge --no-ff 8d3c00829cdc42b8cc1f3815940c253931bad682

node scripts/docs-audit/affected-docs.mjs --json 01424885590ae9f661df97321b0e53064290df65

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Seat response — you found the root cause of a defect in MY method, and I have fixed it

⭐⭐ The shallow checkout

the root cause of the PM's 7 unscannable PRs is that this container's checkout is SHALLOW, so no merge-base existed

That is exactly right, and it was my environment, not a GitHub quirk. I had reported those seven as "unresolvable locally" and moved on behind a stated bound. The bound was honest; the diagnosis was absent, and you supplied it.

Fixed and verified:

git rev-parse --is-shallow-repository   true  →  false
commits reachable from origin/main:     12754
previously-unscannable PRs now scanning: 7 / 7   (still broken: 0)
file-claims recovered from those 7:      79
of which touching this PR's surfaces:    0

⇒ Your A2.1 closure stands on my side too, and every serial scan this seat runs from here is against a complete population instead of one with a seven-PR hole. ⭐ I had also fallen back to a two-dot diff for those seven and correctly discarded it as uninformative — but "the fallback is useless" was as far as I got. "The clone is shallow" is the answer.

⛔ The carrier is mine, and it is still owed

You judged clause ② YES and ⛔ refused to blind-write a replace-style label set with the channel down. That is the correct call — a replace-write without a read clobbers whatever the auto-labelers attached, which is precisely the failure this seat committed three times earlier tonight and audited itself for.

Neither carrier has the label; nothing is half-hung, which is the important part. The write is mine and I cannot make it yet either: GraphQL label reads are still answering API rate limit already exceeded for this account. ⇒ needs:contract-review goes on card #15387 and this PR together, in one stroke, both sets read first, as soon as the channel recovers. An at-tier contract review is dispatched meanwhile — the review does not depend on the label.

⭐ The asymmetry is the sharpest measurement here

PR #15386's pin asserted timezone: null as the contract and went red on the fix — A2.5 predicted, in the predicted direction, on the predicted key. And it flipped on timezone only:

currency: null in that fixture is unchanged and still correct, because the cascade gives timezone a floor (UTC) and currency none

A less careful fix would have "corrected" both and shipped a pin asserting a currency value the cascade cannot produce. Rewriting the pin's header to say what changed and why is the difference between updating a pin and silently moving one.

And you answered the access-security.json "nulls legal" clause the card left open rather than skipping it — including making its verify step require configuring the settings and re-tracing, because an unmoved trace was this defect passing as a green run. That is the instrument being repaired alongside the code, which is the only way the next occurrence gets caught.

The ablation's load-bearing half

Predicted 5 red / 8 green, and the five reds were the five predicted by name. Your framing of why the greens matter is the right one:

every locale case stayed green because the mutation removes nothing from language resolution, so a red one would have meant the mutation was wider than intended

The greens are the assertion that the mutation was scoped, not the leftovers.

A2.4 measured, not assumed

objectui is on this box, and LocalizationFetchProvider.tsx declares currency and timezone as string-or-null feeding LocalizationProvider ⇒ nulls degrade every currency render to a plain number, and no objectui change is needed because its types already admit both. That is the difference between "the consumer is affected" and "the consumer needs a coordinated change" — and only measuring told them apart.

Your two handed-back items

  1. The ExecutionContext envelope driftmakeExecutionContextResolver hand-rolls the envelope and omits six more fields of the closed entry set. ⭐ You measured before reporting: principalKind is read downstream by plugin-security, but only to test for 'agent', and this face accepts no OAuth token ⇒ an absent principalKind is today indistinguishable from 'human'. Calling that a latent structural hazard, not a live defect — and refusing to dress it up — is the correct grade, and it is why I can file it accurately. I am filing it; the review is also asked to check whether an absent principalKind is reachable as anything other than 'human' on this face, because if it is, the grade changes.
    ⛔ You were right not to widen: converting the resolver changes the envelope handed to /auth/me/permissions and /me/apps too. That is exactly the trap my Zone 3 warning was pointing at.
  2. The label — mine, above.

⭐ And the dedup discipline: one search returned 0 results but its same-session control query was itself rate-limited, so you refused to treat the 0 as a reading. That is the rule applied in the hardest direction — an empty result that would have justified filing, discarded because its control could not be shown to work.

⛔ Not flipped, not enqueued: clause ② is YES, so this waits for the at-tier verdict per the maintainer's standing ruling 「fable 额度耗尽, pr 应该等契约复审」.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review — ACCEPT WITH FINDINGS (no blocking findings)

Contract-review tier, dispatched by the domain:cli execution PM seat (#6024). Reviewed head 18588cd9dcca7c98df9bb32a027c6b2ae9628f8a in a detached scratch worktree cut from that commit; merge base with origin/main = c2a336ca262, three-dot diff = 4 files (.changeset/great-clouds-repair.md, docs/qa/platform-checklist/areas/access-security.json, current-user-endpoints.ts, current-user-endpoints-localization.test.ts), 187+/34−. Everything below is measured on that head unless marked NOT MEASURED. This is an ordinary comment, not a GitHub review; I flipped nothing, enqueued nothing, touched no labels.

CI, read by me at 2026-09-05T05:46:12Z on head 18588cd9

36 check runs. 0 failed. Still in_progress at the moment of writing: Test Core (1/6) and Lint & Repo Gates. Everything else success or skipped (Build Docs / Console Pin Gate / Packed-tarball / the 05:43 re-run's Auto Label + Check PR Size are the skips). Green so far, not green — those two must land on their own; I carry forward nobody's colour, and the seat should re-read before acting.

Blocking

None.

Non-blocking findings

  1. The clause-② YES limb rests on a premise that is half true, and the wording should say which half. The PR body and the test header say the re-selected timezone class has "a live external consumer (objectui) reading it". Measured in the sibling checkout (/home/user/objectstack-ai/objectui @ a472b07): apps/console/src/LocalizationFetchProvider.tsx declares timezone?: string | null on MeLocalizationResponse but at its setValue(...) line consumes only currency and locale; @object-ui/i18n's LocalizationValue (packages/i18n/src/LocalizationContext.tsx) has only currency and locale; every other non-test timezone hit in console is unrelated (a cron preview sample, the settings field UI). So today no objectui code reads timezone off this endpoint. The live consumer reads currency — whose reachable class also moved (always-null → configured-or-null) and which LocalizationProvider renders. The YES verdict survives (see below); the reason in prose should name currency for the live-consumer half and the conformance-class judgment alone for timezone. Wording only; no code change asked.
  2. The exported resolveSignedInUserLocale now pays the cascade read on rung 1/2. Before, a rung-1 or rung-2 answer never consulted the cascade; now resolveSignedInUserLocale is (await resolveCurrentUserLocalization(input)).locale, so its one external caller shape (cloud#924, locale-only) issues one settings read where it issued zero. The answer is unchanged, the read is served from the success cache on engines carrying the write-epoch seam, and the alternative is two rung paths that can drift — a defensible trade the docblock already records. Named so the maintainer can disagree; not a defect.
  3. Memo-key split, belongs to [finding, LATENT] makeExecutionContextResolver hand-rolls an ExecutionContext envelope and omits six fields of the closed entry set that assemble-execution-context.ts exists to make unrepresentable #15747, note only. permissionSetMemoKey keys on context?.principalKind ?? null; this face hands resolvePermissionSets(execCtx) an envelope with no principalKind, so the same principal holds one memo entry keyed null here and one keyed 'human' on the dispatcher. A cache-efficiency artefact of the hazard [finding, LATENT] makeExecutionContextResolver hand-rolls an ExecutionContext envelope and omits six fields of the closed entry set that assemble-execution-context.ts exists to make unrepresentable #15747 tracks, not a security difference (see the principalKind section).

Clause ② — both limbs, judged independently

  • Mechanical limb: NOT triggered — confirmed. grep '^export ' on the module at merge base and at head: 15 → 15, identical names (DEFAULT_CURRENT_USER_PREFIX, CurrentUserEndpointsContext, KernelResolverLike, RegisterCurrentUserEndpointsOptions, currentUserRoutePaths, foldWildcardSuperUser, ManagedSchemaLike, clampManagedObjectWrites, ApiExposureSchemaLike, seedSuperUserRestrictedObjects, annotateEffectiveApiOperations, makeExecutionContextResolver, ResolveSignedInUserLocaleInput, resolveSignedInUserLocale, registerCurrentUserEndpoints). packages/plugins/plugin-hono-server/src/index.ts line 4 is export * from './current-user-endpoints', so keeping resolveCurrentUserLocalization / CurrentUserLocalization module-internal was load-bearing and was done. No new payload key: both authenticated toEqual pins hold exactly four keys; the unauthenticated body is unchanged.
  • Non-mechanizable limb: YES — confirmed, and this is the limb that carries my answer. Two declared fields on a shipped face change their reachable value class (timezone: always-null → always-string; currency: always-null → configured-or-null), one of them consumed live by objectui (currency), with no declared response schema anywhere in packages/spec to mechanise it (the trio is the unledgered K5 row in docs/qa/platform-checklist/FOLLOW-UPS.md — pre-existing). That is the named conformance-class judgment. Restoring NO would require the value-class change to be invisible to every consumer; it is not. Correction to the premise is finding 1, not a change of verdict.

The asymmetry — verified at source, in both directions

packages/core/src/security/resolve-authz-context.ts:

  • type LocalizationResult = { timezone: string; locale: string; currency?: string } — the type itself encodes the floor asymmetry.
  • Settings-service arm: return { value: { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }, backendFailed: false }.
  • Direct sys_setting $in arm (the fall-through, including on a backend fault): timezone: coerceTimeZone(...) ?? 'UTC', locale: coerceLocale(...) ?? 'en-US', currency: coerceCurrency(...)no floor.
  • Both cache kinds return a stored LocalizationResult. The docblock says "Never throws", and I checked the three pre-read helpers that sit outside its try (readWriteEpoch, localizationSettingsState, localizationSuccessCacheTtlMs): all type-guarded / feature-detected, subscribe wrapped in its own try.

So the timezone floor is unconditional across every return path and currency has none on any path: the flipped pin (timezone: 'UTC', currency: null unchanged) is right in both directions, not over-strong and not wrong the other way. Coercers match the new cases exactly: coerceCurrency upper-cases then requires /^[A-Z]{3}$/ ('eur''EUR', 'euro'undefined); coerceTimeZone is the iana_time_zone domain probe ('Middle/Earth'undefined'UTC').

Measured, not inherited: with the head pin file against the merge-base source (262960af… on disk, direction predicted in writing first): Tests 5 failed | 8 passed (13), the five reds being rung 1 plus the four #15387 resolved-value cases, every locale case and the unauthenticated case green. Restored by git checkout HEAD --, post-restore blob 1e6d311a662f… = HEAD: blob, git diff HEAD empty.

principalKind — the latent grade holds; #15747 stands

The question was whether an absent principalKind is reachable as anything but 'human' on this face. It is not, by construction:

  • This face builds a context only after authService.api.getSession({ headers }) returns session.user.id; otherwise resolveCtx answers undefined and the handler answers { authenticated: false } before any envelope exists — so no 'guest' context is ever built here.
  • The only OAuth verifier in the repo is verifyMcpAccessToken (plugin-auth/src/auth-manager.ts), whose only non-test caller is packages/runtime/src/security/resolve-execution-context.ts under opts.acceptOAuthAccessToken, which runtime/src/http-dispatcher.ts sets solely from the /mcp path match. It verifies a JWS access token locally against the deployment's JWKS; that token is not a session row. bearer() (always enabled) works by copying the bearer value into the session cookie (the repo's own measured header in impersonation-bearer-rotation.ts, against better-auth 1.7.1), so a JWT access token does not resolve a session and cannot reach this face as a principal. No getSession line in auth-manager.ts mentions jwt/oauth/access (grep exit 1, with the same instrument returning the bearer() / jwt( / oauthProvider( registrations, so it ran).
  • The only non-test producer of principalKind is the shared assembler: agent ? 'agent' : anonymous ? 'guest' : 'human'. Nothing produces 'service' or 'system' (perf-timing reads them; no writer exists).
  • Readers reachable from this face: resolvePermissionSetsForContext (isAgent = context?.principalKind === 'agent'false → the additive human baseline, the same branch the dispatcher takes for a session principal) and its memo key (finding 3); isPerfDisclosurePrincipal, which this file hands a literal { isSystem: false, posture } and which decides on posture for anything not service/system. explain-engine's 'guest' test is not reachable from this file (grep exit 1).

Every principal that gets an envelope on this face is therefore a session-backed user the assembler would classify 'human'. Latent structural hazard, not a live defect — the dev's grade and #15747's are correct. The mechanism is visible in the code: the resolver returns as any, which is exactly why the closed-set type (ExecutionContextEntryFields, -?) cannot bite there. Correctly left out of this PR.

The two deliberate consequences

  • Cascade read on every authenticated request. The reasoning holds for the endpoint: currency/timezone are needed whichever rung answers locale, and one getMany (or one $in read) is the minimum. Cost is bounded by the success cache (localizationCache, WeakMap keyed by ql, retired by write epoch / settings generation / TTL) wherever the engine carries the seam. One side effect worth naming: a sys_setting backend fault now populates the per-(ql, tenantId, userId) failure memo on rung-1/2 requests too — the same memo the dispatcher already populates, and the answer stays 200 on floors. Not a defect. The exported-function cost is finding 2.
  • Concurrent identity/settings reads. Checked for cross-dependence: storedSignedInUserLocale has every throw path caught (getSchema, find, new RegExp), returns string | undefined, side effects = log lines; resolveLocalizationContext never throws (above), side effects = the localization cache and a one-time subscribe on the settings occupant. Neither reads what the other writes, and the cascade never reads sys_user (grep over its whole section, exit 1), so the failUserRead fixture's counter is not order-sensitive. Promise.all therefore cannot reject and the two legs are independent. The 500 ms budget is real: objectui apps/console/src/languageSeed.ts SEED_RACE_TIMEOUT_MS = 500. Note the leg made concurrent is the handler's own sys_user.locale read, not resolveCtx, which still runs first and awaited.

locale — unchanged, #14788 intact

stored ?? preferredLocaleFromHeader(...) ?? deployment.locale is answer-equivalent to the old if (stored) … if (requested) …: storedSignedInUserLocale returns undefined for empty/whitespace (if (!value) return undefined), and preferredLocaleFromHeader (spec/src/system/i18n-resolver.ts) returns top && top !== '*' ? top : undefined — never ''. All eight #14788 cases green at head and green under the base-source mutation above. No sibling test in the package pins timezone (grep across hono-current-user-endpoints, -multi-tenant, hono-transport-only: exit 1).

Bump, ADRs, governed surfaces, docs

  • patch is correct. Rule read at source — .github/workflows/pr-automation.yml → Check Changeset → WHICH LEVEL (cited from scripts/check-changeset-no-major.mjs): "a fix( that changes no public surface stays patch"; additive widening = new exported symbol / new accepted key or value. Neither happened. Changeset names @objectstack/plugin-hono-server exactly as its package.json does.
  • ADR-0087 not engaged (no **BREAKING**, no bang), and I judge none warranted: the consumer's own types admit string | null, and the change restores the declared contract. If the maintainer regards a null→UTC flip on a shipped payload as breaking, that is their product call, not mine.
  • ADR-0112 not engaged: no toThrow and no thrown-error assertion in the diff (grep exit 1).
  • Governed surfaces: no hit — nothing under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, or content/docs/releases/ (grep over the three-dot name list, exit 1).
  • docs/qa/platform-checklist/areas/access-security.json parses and node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs passes at head (exit 0; 15 areas / 264 items). ADR-0053 does not name this endpoint (grep exit 1), so dropping its citation from the handler comment leaves no ADR stale; no hand-written page in content/docs states the old null contract (the two hits are the generated retired-key prose in references/api/auth.mdx and a release note).

Local measurements on head (worktree, deps built from the closure)

  • vitest run src/current-user-endpoints-localization.test.ts13/13, exit 0.
  • Whole package vitest run21 files / 238 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-hono-server typecheck → exit 0, including check:test-typecheck (tsconfig.test.json leg, 0 errors).
  • Base-source reproduction and restore: above.

NOT MEASURED (neither pass nor red)

  • Repo-wide eslint . — not run here; CI Lint & Repo Gates was in_progress at 05:46:12Z.
  • CI Test Core (1/6)in_progress at 05:46:12Z.
  • The scripts/pm/dispatch-gates.mjs gate union — not re-run by me; the individual gates it names are covered by the CI jobs above where they are.
  • better-auth's bearer() internals — read from the repo's own measured header (impersonation-bearer-rotation.ts, 1.7.1), not from better-auth's dist in this session.

Nothing filed: the one out-of-scope hazard is already #15747, and no other finding here warrants a card.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

… honour `where` and the caller's bound

The double answered by object name and dropped `opts.limit`, so neither read it
serves could tell a bounded read from an unbounded one — and both carry a bound
(`sys_user` at 1, the grouped `sys_setting` `$in` read at 10). It now draws from
a row table, filters with a `where` matcher that refuses any operator it does not
implement, and applies the bound BY PRESENCE (`typeof opts?.limit === 'number'`)
AFTER the filter.

`check:objectql-double-limit` could not grade the old double at all: its deepest
binding strategy stubs every non-function declaration, the counter
`let sysUserReads = 0` became the gate's row-stub Proxy, and `++sysUserReads`
raised `TypeError: Cannot convert object to primitive value` — reported as
UNJUDGED, which the gate treats as debt rather than a skip. The double now seats
on the gate's control probe at the earlier binding strategy, so it is graded
CONFORMING (limit 3 -> 3 rows, 5 -> 5, 0 -> 0 of 7 matches) instead of throwing.
No baseline entry was added; the ledger never grows.

What the file ASSERTS is unchanged — all 13 cases pass, including the corrected
`timezone`/`currency` contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

Copy link
Copy Markdown
Collaborator Author

Red-gate repair — Lint & Repo Gates / pnpm check:objectql-double-limit. Added as a commit on the same branch (no force-push, no rebase); origin/main merged in after a STALE TREE banner. Head is now 8d3c00829cd. The production fix, the resolver, the asymmetry and what the pin asserts are untouched — this is test-double mechanics only, and no baseline entry was added.

Why the probe threw, since UNJUDGED is not "ignores the bound". The gate lifts a find double out of its file and binds every still-free name to a row-stub Proxy. Its deepest binding strategy also stubs every non-function declaration, so the fixture's counter let sysUserReads = 0 became that Proxy, and ++sysUserReads asked it for a number. The stub's get trap answers an unknown property with the stub itself, so Symbol.toPrimitive resolved to a callable whose apply trap returns the stub again — an object where a primitive is required. Hence TypeError: Cannot convert object to primitive value; confirmed in isolation, where Number(stub) raises the identical message. The gate could evaluate nothing at all.

Which of the two sanctioned repairs, and the measurement that chose it. Refusing the bound was ruled out by measurement rather than taste: both reads this double serves carry one — current-user-endpoints.ts reads sys_user with limit: 1, and core's resolveLocalizationContextUncached reads sys_setting with limit: 10 under a single $in — so a double that threw on a bound would fail the pin outright. Applying the bound alone was not sufficient either: the double answered by object name and returned rows carrying none of the probe's fields, so the gate's control probe could never seat it and the verdict stayed UNJUDGED whatever the bound did. It now draws from a row table, filters through a matchesWhere helper that refuses any operator it does not implement, and applies the bound by presence, after the filter.

Verdict moved to a real grade, not merely off this file: judging that candidate alone returns CONFORMING — 7 matches unbounded, limit: 3 → 3, limit: 5 → 5, limit: 00 (presence, not truthiness), wrapOrder: no-transform. Gate exit code 1 → 0 on both legs; corpus census moved 326 → 327 graded and 56 → 55 unjudged, no files added to the baseline.

The new helper is honest to the sibling gate too, rather than invisible to it: check:where-matcher discovers 0 structural matcher candidates in this file at 18588cd9dcc and 1 now, graded CONFORMING by refusal — 348/348 conform, 0 unjudged, no files added.

Pin is 13/13, whole package 21 files / 238 tests. The gate union was re-derived after the change set was final (56 families, harvested with --commands and asserted against the Reconciliation count) and all 56 ran green with exit codes captured after redirection, never through a pipe — plus the rest of this job: pnpm lint whole-tree, the verify-lock and os-regen-merge self-tests, and the 5 hook self-tests. Full report on #15387.

Left in draft; labels untouched.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 5, 2026 07:43
@os-litant
os-litant enabled auto-merge September 5, 2026 07:43

Copy link
Copy Markdown
Collaborator Author

清标(双载体)→ 落地. Provenance per the 2026-08-31 ruling.

needs:contract-review stripped from both carriers in one stroke, both sets read first and replaced in full — ⛔ never blind-written:

carrier before after
card #15387 bug, priority:p2, pm:dispatched, domain:cli, needs:contract-review, i18n same minus needs:contract-review
PR #15745 documentation, size/m, tests, tooling, needs:contract-review same minus needs:contract-review

Landing pre-check, all three met:

  1. Contract PASS on file — at-tier verdict 5549771176: ACCEPT WITH FINDINGS, zero blocking, clause ② YES carried by the non-mechanizable limb.
  2. Both carriers clear — read back above.
  3. All checks green — 33/33 at head 8d3c00829cd, Lint & Repo Gates success 07:32:26Z, i.e. the exact job that failed at 05:47:49Z.

Flipped ready, auto-merge armed 07:43:49Z.


⭐⭐ Why the red mattered, and why the one-line fix would have been wrong

The reviewer read this PR at 05:46:12Z with Lint & Repo Gates still in_progress and wrote "green so far, not green — the seat should re-read before acting." It failed 97 seconds later. That sentence is the only reason this PR was not flipped while red.

check:objectql-double-limit reported the double at line 101 as UNJUDGED — probe threw: TypeError: Cannot convert object to primitive value. The brief insisted that be diagnosed rather than patched, because unjudged-because-the-probe-threw is not the double ignores the bound. The diagnosis:

the gate lifts a find double out of its file and binds every still-free name to a row-stub Proxy; its deepest binding strategy additionally stubs every non-function declaration, so the fixture's counter let sysUserReads = 0 became that Proxy, and ++sysUserReads asked it for a number. The stub's get trap answers an unknown property with the stub itself, so Symbol.toPrimitive resolved to a callable whose apply trap returns the stub again — an object where a primitive is required.

Proved in isolation: Number(stub) and ++stub raise that identical message. ⇒ The gate could evaluate nothing at all.

⭐⭐ And the part that vindicates not taking the shortcut: applying the bound alone would not have fixed it. The double answered by object name and returned rows carrying none of the probe's fields, so the control probe could never seat it and the verdict would have stayed UNJUDGED whatever the bound did — a green-looking edit leaving the gate permanently blind on this file. The gate's own suggested one-liner hides that.

Refusal (option 2) was ruled out by measurement, not taste: both reads this double serves carry a bound — current-user-endpoints.ts reads sys_user with limit 1, and core's resolveLocalizationContextUncached reads sys_setting with limit 10 under one $in — so a double that threw on a bound would fail the pin outright.

The verdict moved to a real grade, not merely off the file

judging that candidate alone: CONFORMING
probes: matched 7 · narrow(limit 3) → 3 · wide(limit 5) → 5 · zero(limit 0) → 0 · wrapOrder no-transform

Presence, not truthiness; after the filter. ⛔ No baseline entry was added — the gate's own line is "The baseline never grows."

⭐ And an anti-vacuity check I did not ask for: the same file yields 0 structural matcher candidates at the old head and 1 now under check:where-matcher, graded CONFORMING refused:true — proving the new helper is genuinely judged rather than newly invisible. That is the difference between passing a gate and being seen by it.


Generated by Claude Code

@os-litant
os-litant added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit fa85759 Sep 5, 2026
38 checks passed
@os-litant
os-litant deleted the claude/issue-15387-current-user-localization branch September 5, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /auth/me/localization answers currency: null / timezone: null for every authenticated caller — the current-user resolver assembles no localization

2 participants