Skip to content

A cloud-connection bind failure reads the locale whichever clock noticed it (#5054) - #5659

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-5054-bind-failure-i18n
Aug 22, 2026
Merged

A cloud-connection bind failure reads the locale whichever clock noticed it (#5054)#5659
os-sales merged 2 commits into
mainfrom
claude/issue-5054-bind-failure-i18n

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #5054

One abandoned device approval can be noticed by either of two clocks, and the Cloud Connection panel had a different answer for each. This makes both answers the same string, in the user's language.

⚠️ Dispatch fence conflict — please read first

The dispatch fenced packages/app-shell/** as OUT (held by #5344 / #5025) and, in the same claim comment, granted this card "the cloud-connection bind-failure message sites plus the locale packs they resolve through". Those two sentences cannot both be honoured literally: the panel that renders a bind failure is packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx, and there is no other renderer — a fence read literally makes this card unimplementable rather than merely narrower.

I verified the fence's own premise rather than assuming it:

holder its file surface overlaps mine?
#5344 packages/app-shell/src/views/metadata-admin/inspectors/ViewColumnInspector.tsx no
#5025 packages/app-shell/src/views/ObjectView.tsx no

Both are under src/views/**; this PR touches only src/console/cloud-connection/** plus packages/i18n/src/locales/**. Disjoint at file level, so no git conflict is possible with either holder.

I implemented on the reading that makes both sentences satisfiable (the fence excludes the held regions, the grant names my region) — but I am flagging rather than deciding. This is a draft PR: if the PM meant the fence literally, hold it and I will re-run once #5344 / #5025 land. Nothing here is merged.

What each side rendered BEFORE the fix

Measured on 7e811687a, both paths, before any edit:

clock that noticed code path rendered
the server's /bind/poll → HTTP 400 → getJson throws → catch renders err.message Device authorization failed: expired_token — English, on all ten locales
the panel's own expires_in deadline poll()'s tick guard cloudConnection.errors.expired — translated, on all ten locales

The SYMMETRY case states it in one diff, from the pre-fix run:

  {
    "clientDetected": "cloudConnection.errors.expired",
-   "serverDetected": "cloudConnection.errors.expired",
+   "serverDetected": "Device authorization failed: expired_token",
  }

Producer confirmed at source, not from the card: objectstack packages/cloud-connection/src/cloud-connection-plugin.ts serves the terminal exit as 400 with { code: 'DEVICE_CODE_FAILED', declaredCode: <RFC 8628 spelling>, message: 'Device authorization failed: <spelling>' }. code is the same for both outcomes — the discriminating spelling is only in declaredCode.

The fix

Adjudicated disposition (Option A restricted + B fallback), implemented as ruled:

  • getJson now carries the envelope's declaredCode and code across its throw. A bare Error dropped them, so the message was the only thing that survived — that is the whole mechanism of the asymmetry. Attached rather than subclassed (extending Error is brittle under a downlevel target).
  • One closed map, declaredCode first then code: expired_token → the existing cloudConnection.errors.expired, access_denied → a new cloudConnection.errors.accessDenied added to all ten packs. The t() arguments are string literals so check:i18n-keys resolves them; a Record<code, key> read as t(key) is a dynamic key and that gate goes blind to it.
  • The same map runs first in poll()'s terminal 2xx branch too, so the asymmetry cannot simply move to a third reader.
  • All four catch sites share one failureText, so no future call site can reintroduce the split.
  • Unchanged: every other code — invalid_grant, anything upstream invents next — still renders the wire message.

Neither key takes interpolation, so interpolation parity is vacuous here and check:i18n-keys confirms it (0 with an unreadable option set).

Both sides get a case, and each case gets a mutation leg

CloudConnectionPanel.bindErrorLocale.test.tsx (new, 6 cases). Directions were written into the file header before running. Pre-fix: SERVER expired RED · SERVER denied RED · SYMMETRY RED · CLIENT GREEN · CONTROL GREEN — the two green ones green on purpose (the side already correct, and the B-fallback), which is why each has its own leg below rather than being counted as silent evidence.

All four legs were re-measured against the shipped commit 01576cb96; each mutation was proved on disk in both directions before its reading was accepted (a pristine-count assertion, then a post-mutation assertion that the anchor text left and the replacement arrived), and each ran under an EXIT INT TERM trap that restores the tree. git status --porcelain was empty after every leg.

leg mutation predicted measured RED
server side translateFailureCode(tRef.current, e?.declaredCode, e?.code)(…, undefined, undefined) SERVER expired, SERVER denied, SYMMETRY, #5028 CONTROL exactly those 4 — 4 failed | 6 passed
client side the panel's own deadline t('cloudConnection.errors.expired') → a hard-coded English literal CLIENT, SYMMETRY exactly those 2 — 2 failed | 8 passed
map over-reach (the CONTROL's own leg) access_deniedinvalid_grant in the map CONTROL, SERVER denied exactly those 2 — 2 failed | 8 passed
route (the ROUTE case's own leg) getJson's non-2xx guard disabled, so a 400 reaches poll()'s terminal branch ROUTE only exactly that 1 — 1 failed | 9 passed

The route leg is the informative one for the design: with the 400 delivered to the other reader, SERVER expired and SERVER denied stay green, which is the direct evidence that the map sits at both readers rather than at one.

One leg was attempted and did not run: the first map-overreach attempt passed a two-line replacement, and grep -F treats an embedded newline as multiple patterns, so the pristine-count precondition read 1 instead of 0 and the runner aborted with ABORT: anchor not pristine — this leg did NOT run. It was re-done single-line. Recording it because a no-op ablation that is quietly retried until something lands is the same defect one level up.

Fixture triage on the sibling suite

CloudConnectionPanel.bindError.test.tsx's CONTROL case asserted the pre-fix wire English — the exact symptom this card retires. It was not deleted and its fixture was not touched (it is still the producer's verbatim envelope): the assertion moved to the post-fix key, and the DEVICE_CODE_FAILED negative stays, since "no machine code reaches a human" is that file's own subject.

What that case lost is real and is called out in its comment: the wire English used to discriminate the two readers, and post-fix both readers answer expired_token identically. The measurable discriminator is replaced by the new ROUTE case, which is the one fixture the two readers still answer differently — a 400 carrying no error object at all renders HTTP 400 from getJson's last arm, where poll()'s terminal branch would render cloudConnection.errors.bindFailed. Its own mutation leg above measures it failing.

Clause ② — nothing reachable from a published entry was added or widened

Measured from the built entries, not from a grep of a barrel (export * propagates without naming).

@object-ui/app-shellexports map has exactly two entries (., ./styles.css), unchanged. dist/ was cleared together with tsconfig.tsbuildinfo before each build (composite tsc skips emit if the build info survives), then the whole emitted type surface was hashed base vs HEAD:

  • all 431 .d.ts files byte-identical (sha256 manifest diff, empty);
  • the . entry dist/index.d.ts8c886251f0e086ab56a99236ce5f899c41b8ad33b027d453531c11136a10dbc3 on both sides;
  • the panel appears in that entry only as a side-effect line, import './console/cloud-connection/CloudConnectionPanel.js'; — no named export, unchanged from main. translateFailureCode, ApiFailure and failureText are module-local and appear nowhere in it.

@object-ui/i18n — two published entries. The . entry dist/index.d.ts is byte-identical. The ./locales/* subpath entries changed by exactly the key that was added and nothing else:

file added removed differing lines not about accessDenied
locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.d.ts +1 each 0 0 each
locales/index.d.ts +10 0 0

No export added, no signature widened, no resolver changed what it accepts. That is the "adding a locale key" case, which the dispatch states is not a widening — and it is stated here as a measurement rather than as a claim.

Verification

Union re-run after the final commit, at 01576cb96. Each gate's own verdict line, never a bare $? (exit status captured from a redirect, before any pipe):

  • check:i18n-keys → EXIT=0 — "Every in-scope call-site key resolves against the en pack (2919 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for, and no call site carries a literal fallback beside itself."
  • check:i18n-drift → EXIT=0 — "No en value changed in this range." (0 en value(s) changed (1 key(s) added, 0 removed …)) — an addition, not a drift.
  • check:control-bytes → EXIT=0 — "✅ check-control-bytes: OK (scanned 4705 tracked text file(s); skipped 85 binary)."
  • check-changeset-presence.mjs → EXIT=0 — "✅ 13 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)."
  • check-changeset-no-major.mjs → EXIT=0 — "✅ No changeset declares a major bump."
  • check-changeset-fixed.mjs → EXIT=0 — "✅ All workspace packages are in the changeset fixed group."
  • type-check (@object-ui/app-shell, @object-ui/i18n) → EXIT=0, both packages echoing tsc --noEmit && tsc -p tsconfig.test.json and reporting Done — the echo is the guard against a zero-match --filter exiting 0 without running anything (this repo spells the script type-check, with the hyphen).
  • vitest from the repo root, pnpm exec vitest run packages/app-shell/src/console/cloud-connection/ packages/i18n/src → EXIT=0, 52 files / 888 tests passed, including all 6 new cases, the 4 re-triaged sibling cases and cloudConnection-locale-parity (identical key set in all ten packs, non-empty translated leaf at every path).
  • check:i18n-dead-keys (report-only, wired into no workflow) → the new key does not appear in the candidate list.

Lint: a declared narrowing, with its three measurements

turbo run lint covers 47 of 47 workspace packages. I ran 2 of 47 in full — the two this diff touches — and the narrowing is a measurement, not an omission:

  1. Population read from eslint's own config, not from my guess about which files count: each package's own lint script is eslint ., run unmodified.
  2. File counts read from --format json: @object-ui/app-shell = 922 files linted (0 errors), @object-ui/i18n = 75 files linted (0 errors).
  3. Invariance for untouched files: eslint.config.js is the single config in the repo (no per-package eslint.config.* exists) and it declares neither projectService nor parserOptions.project — type-aware linting is not enabled, so every file's verdict is computed from that file plus the config alone. A diff confined to app-shell and i18n sources therefore cannot move the verdict of any file in the other 45 packages.

Net lint findings on the changed file are identical to main, measured by rule:

BASE (origin/main): {"@typescript-eslint/no-explicit-any":5,"react-hooks/set-state-in-effect":1}
HEAD (01576cb96)  : {"@typescript-eslint/no-explicit-any":5,"react-hooks/set-state-in-effect":1}

Both new/changed test files: 0 errors, 0 warnings. Getting there took a correction worth naming — the first shape wrote tRef.current = t in the render body, which react-hooks/refs flags, and typed the helper (err: any), which added a sixth no-explicit-any. Both are fixed in 01576cb96: the ref syncs in an effect, and the helper takes unknown. Runtime is unchanged (message when it is a string, String(err) otherwise, exactly as before) and all four legs were re-measured against that final shape.

One implementation hazard, recorded

Routing t into failureText's useCallback dependency list made refreshStatus unstable, which re-ran the mount useEffect on every state update — an infinite render loop that timed out all nine cases in this directory at 15s. Several translation providers hand back a fresh t each render, so t must not reach a dependency list the mount effect transitively depends on. The latest-ref (synced in an effect) keeps the helper's identity stable. The mechanism is written into the code at the ref, not just here.

Not touched

content/docs/releases/ (the changeset is this PR's release-notes input) · no skills/** path is in this diff, so the published-skills line-count readings do not apply · no .claude/**, AGENTS.md, CLAUDE.md or docs/adr/** · no producer-side change was asked of objectstack (Option C stays recorded as a future maintainer call only).

CI has not converged at the time of writing — per the dispatch contract the report is delivered at draft-PR time, and gate status is whatever CI says.


Generated by Claude Code

…the wire English (#5054)

One device-approval expiry could be noticed by the panel's own `expires_in`
clock (rendering `cloudConnection.errors.expired`, translated in ten packs) or
by the server (HTTP 400, rendering the producer's English sentence verbatim).
One condition, two languages, decided by which clock got there first.

`getJson` now carries `declaredCode`/`code` across its throw, and a closed map
turns the two user-causable RFC 8628 spellings into console copy —
`expired_token` -> the existing key, `access_denied` -> a new key in all ten
packs. Unrecognized codes keep rendering the wire `message`.
`tRef.current = t` in the render body is what `react-hooks/refs` is for, and
it was a lint finding this branch introduced. The effect form keeps the helper's
identity stable (which is what stops `refreshStatus` re-running the mount effect
on every state update) without writing a ref during render.

`failureText` takes `unknown` rather than `any` for the same reason: the branch
should not add a warning to a file it is fixing. Runtime is unchanged --
`message` when it is a string, `String(err)` otherwise, exactly as before.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3912.2 KB 3990.2 KB
Main entry chunk (gzip) 151.8 KB 350 KB
Entry file index-COiY2hQz.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 11.22KB 3.78KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.21KB 113.58KB
core (index.js) 4.51KB 1.80KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 160.38KB 44.54KB
fields (index.js) 238.85KB 60.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.65KB 18.32KB
plugin-chatbot (index.js) 181.41KB 43.22KB
plugin-dashboard (index.js) 128.33KB 32.93KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 242.16KB 60.90KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.07KB 30.43KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.74KB 27.18KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.54KB 20.69KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 43.66KB 14.77KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 22, 2026 07:30
@os-sales
os-sales added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit 3a58149 Aug 22, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5054-bind-failure-i18n branch August 22, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant