fix(plugin-hono-server): /auth/me/localization resolves the regional defaults instead of answering null to every caller - #15745
Conversation
…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
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
Coarse fallback — 7 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 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 |
Seat response — you found the root cause of a defect in MY method, and I have fixed it⭐⭐ The shallow checkout
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: ⇒ 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 owedYou 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 ⭐ The asymmetry is the sharpest measurement herePR #15386's pin asserted
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 The ablation's load-bearing halfPredicted 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:
The greens are the assertion that the mutation was scoped, not the leftovers. A2.4 measured, not assumed
Your two handed-back items
⭐ 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 |
Contract review — ACCEPT WITH FINDINGS (no blocking findings)Contract-review tier, dispatched by the CI, read by me at 2026-09-05T05:46:12Z on head
|
… 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
…rrent-user-localization
|
Red-gate repair — Why the probe threw, since UNJUDGED is not "ignores the bound". The gate lifts a 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 — Verdict moved to a real grade, not merely off this file: judging that candidate alone returns The new helper is honest to the sibling gate too, rather than invisible to it: 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 Left in draft; labels untouched. Generated by Claude Code |
清标(双载体)→ 落地. Provenance per the 2026-08-31 ruling.
Landing pre-check, all three met:
Flipped ready, auto-merge armed ⭐⭐ Why the red mattered, and why the one-line fix would have been wrongThe reviewer read this PR at 05:46:12Z with
Proved in isolation: ⭐⭐ 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 — The verdict moved to a real grade, not merely off the filePresence, 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 Generated by Claude Code |
Fixes #15387
GET /auth/me/localizationansweredcurrency: null, timezone: nullto every authenticated caller, whatever the deployment'slocalizationsettings said. The handler read both off the requestExecutionContextand its comment cited ADR-0053 — but the resolver that serves this surface,makeExecutionContextResolverin 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.timezoneandlocalization.currencyrows, and five cases were added. Against unchanged source:Tests 4 failed | 9 passed (13). A caller on a tenant configuredAsia/Shanghai/CNY: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) fillsexecCtxfrom — so the two faces agree by construction instead of by comment.localekeeps its three #14788 rungs and every one of its answers is unchanged.Two consequences worth reviewing rather than skimming:
currency/timezoneare needed whichever rung answers the language. That adds onesys_settingread to the requests where the caller's own column orAccept-Languagealready 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.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.timezoneat all.currency ?? nullis 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) pinnedexpect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null }). Thattimezone: nullwas 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.currency— unchanged, stillnull, 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.jsonsaid the trio answers "currency/locale/timezone keys (nulls legal)". Answered explicitly: it no longer holds for all three. The clause is rewritten —localeandtimezonealways answer (floorsen-US/UTC), so a null for either is now a FAIL and a regression of this repair;currencyis the one key where null stays legal, and only when nolocalization.currencyis configured. Itsverifystep 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
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 becauseindex.tsdoesexport * from './current-user-endpoints', so anything exported here is published API.timezonefrom "always null" to "always a real zone", with a live external consumer (objectui) reading it.needs:contract-reviewis 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/objectstackat commit18588cd9dcc, 4 paths, three-dot against merge basec2a336ca2. 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 againstorigin/main.pnpm --filter @objectstack/plugin-hono-server exec vitest run— 21 files / 238 tests passed.pnpm --filter @objectstack/plugin-hono-server typecheck— clean, including thetsconfig.test.jsonleg, so the new cases really are type-checked.eslint . --no-inline-config --format json— 5,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
execCtxreads. 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; observedTests 5 failed | 8 passed (13), and the five reds were the five predicted by name. The eight greens are the point: everylocalecase 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'svitest.config.tsaliases@objectstack/coreto../../core/src/index.tsand the subject is imported as./current-user-endpoints, so nothing in the run resolves throughdist. 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 HEADempty, marker count back to 0.Downstream consumer, measured not assumed: the sibling
objectuicheckout is on this box, andapps/console/src/LocalizationFetchProvider.tsxdeclarescurrency?: string | null; timezone?: string | nulland feedsLocalizationProvider, so nulls degrade every currency render to a plain number. That is the user-visible half this repairs. Noobjectuichange 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 classassemble-execution-context.tsexists to make unrepresentable. I measured it rather than assuming:principalKindis read byresolvePermissionSetsForContext, 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,ghabsent, 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