Skip to content

I18N-011: a refused write reads in the reader's language - #997

Merged
NewWorldVenture merged 6 commits into
mainfrom
claude/i18n-011-db-errors-etzqg7
Oct 11, 2026
Merged

NewWorldVenture merged 6 commits into
mainfrom
claude/i18n-011-db-errors-etzqg7

Conversation

@NewWorldVenture

@NewWorldVenture NewWorldVenture commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

I18N-011: describeDbError's five classified sentences were English in every locale.

The defect

describeDbError (lib/supabase/errors.ts) classifies a refused database call and answers with one of five fixed English sentences:

  • RLS: "You don't have permission to do that…"
  • a duplicate: "That already exists…"
  • a missing row: "That item could not be found…"
  • a broken constraint: "Some required information is missing…"
  • a dropped connection: "Network problem…"

Only the caller's own fallback was translated. With ~730 call sites in 175 files, a German family refused by RLS read English on an otherwise German screen.

Owner's held work. finalaudit.md (the JIMMY-BLK-FORM-A11Y-20261001 overlap check) records an unpushed I18N-011 commit of yours, eb2137026. It passes the translator to describeDbError at contacts-module's three toast call sites and in paperwork's actions. This PR takes a different route that needs no call-site edits. It keeps describeDbError's signature, so passing a translator at a call site can still be layered on top. Nothing from eb2137026 is integrated here. If you'd rather land yours, close this one.

The fix

The audit's proposal was to return catalogue keys and migrate every caller with a ratchet. This reaches the reader without touching a single call site.

  1. The sentences are catalogue keys. dbError.permission, .conflict, .notFound, .invalid and .network are in en-US and all six full translations (de, es, fr, it, nl, pt). Each translation follows the catalogue's own register and its existing phrasing for "family admin". .network reuses the existing securityPanel.errorNetwork translations word for word.
  2. In the browser, describeDbError answers in the reader's language. Each mounted LocaleProvider registers the five sentences of its locale, and describeDbError answers in the latest one still mounted.
    • Registration happens in an effect, after commit. A server-rendered error stays English, and the client's first render says the same thing.
    • The effect's cleanup takes the registration out again. When a nested provider unmounts, the provider around it becomes current again, even though that provider does not re-register.
    • Nothing registers unless window exists. On the server, a module-level choice would be whichever request rendered last.
    • The five sentences and this state live in lib/supabase/errors.ts itself. That file still imports nothing, because a dozen tests load it in a sandbox that refuses any import it does not list.
  3. A sentence is translated where it is shown. A server action's { error } arrives in English, because the server has no reader. A refusal kept in state since an earlier language arrives in that language.
    • The toast, ActionError and a form Field's error each recognise the five sentences, alone or inside a longer message, in any language they have been seen in. They show each one with their own t.
    • The toast is the main route: about 248 calls hand res.error straight to toastError.
    • Recognition uses an append-only map from sentence to key. A test guards the assumption it rests on: each of the 35 sentences (5 keys × 7 catalogues) belongs to one key, and none appears inside another.
  4. dbError joins ROOT_CHROME_SCOPE, beside toast and for the same reason: ToastProvider sits under the root provider on every surface, the public ones included. The cost is five sentences.
  5. Three E2E fixtures that mount LocaleProvider from a source list (display-clock, social-publish-consumers, social-scheduling-ui) list lib/supabase/errors.ts, which the provider now imports. tests/medications-fixture-module-graph.test.ts asks for exactly that.

describeDbError's signature, its classification and its unclassified behaviour (the caller's fallback, or a codeless app-thrown message) are unchanged. On the server it still answers in English, so the existing tests that pin those English sentences against server code pass unchanged.

Tests

tests/a-refusal-reads-in-the-readers-language.test.ts (unit)

  • Catalogues:
    • en-US holds exactly the sentences the code writes, and every full translation has its own words for each.
    • Each of the 35 sentences names one key, and none is a substring of another. Shown to fail on a planted overlap.
    • Every scoped surface (root, marketing, auth, public link, survey) carries the keys.
  • describeDbError:
    • On the server it stays English, even after a catalogue is offered.
    • In a German browser, describeDbError and describeActionError answer in German for one real error of each class, and the unclassified fallback is unchanged.
    • It follows a change of language, and a catalogue without the keys changes nothing.
    • A nested registration that is removed leaves the outer one current, whichever leaves first. With none left, it is English.
  • Display points:
    • localizeDbErrorText translates an embedded sentence and leaves everything else alone. It does the same for a sentence kept from a language the reader has left.
    • Field and ActionError render the German sentence under a German provider and the English one under English.

tests/a-refused-toast-reads-in-the-readers-language.test.ts (unit, using the toast harness from a-toast-you-can-still-reach-waits)

  • A refused action's English sentence, pushed as an error toast, reads in German for a German reader and in English for an English reader. Any other message is left as written.
  • LocaleProvider registers only after commit: during its render, describeDbError is still English. It takes the registration out in its effect's cleanup. On the server it never registers.

tests/e2e/a-refusal-follows-the-readers-language.spec.ts (Chromium)

  • It mounts the real LocaleProvider, ToastProvider, Field and ActionError with React, from source, with the real de-DE and fr-FR entries. No app server is involved.
  • Each catalogue is one object for the page's life, as in the app, and a recorder notes every registration a provider makes.
  • Test 1: a refusal held in component state under German reads German in the toast, Field and ActionError. Re-rendered under French with the state retained, all three read French, and the toast's dismiss control is labelled in French.
  • Test 2:
    • A French page commits first. A German provider is mounted inside it in a later commit; the record shows it registered last.
    • Server English pushed to the toast reads French, while the nested Field reads German.
    • The German provider is removed. The record shows the French provider did not register again, yet describeDbError answers in French and a new refusal in the outer Field and toast is French.
  • Not exercised: hydration, and the toast's focus and queue behaviour. The spec drives components through createRoot, and a clean pageerror list says nothing about either. Hydration itself is not run anywhere: the unit test above only shows the provider registers after commit, not during render.

Mutation-proven (each source change reverted alone, tests kept):

Reverted Result
The toast's translation its test fails
Field's translation its test fails
ActionError's translation its test fails
describeDbError's routing to the browser's sentences 3 fail
The scope entry 1 fails
The provider's effect, removed "after it commits" fails
The provider's effect, moved into render "not while it renders" fails
Recognition of non-English sentences (4779482's errors.ts) mounted test 1 fails: the Field stays German after the switch to French (test 2 fails too)
The registration stack (895ff41's errors.ts) mounted test 2 fails: after the German provider leaves, describeDbError still answers German
The provider's effect cleanup (registers, never leaves) mounted test 2 fails the same way; 2 toast-harness unit tests fail

Review rounds

  • 895ff41: a refusal kept on screen follows the reader. It added the sentence-to-key map and the mounted spec.
  • 964180c: the round-1 nested case handed the outer provider a fresh catalogue object on removal, which made it register again and hid stale nested state. With the catalogue held stable, the stale state is real: on 895ff41, after the nested German provider left, describeDbError still answered German. Registrations are now a stack the provider's effect cleanup unwinds. This round also added the 35-sentence overlap guard, and the spec no longer claims hydration or focus/queue coverage.
Check (at 964180c, this container) Result
Mounted spec 2/2 (red on 895ff41's errors.ts: test 2)
The two unit suites 17/17
Full unit suite / tsc / eslint 30,930 passed (the 3 failures are this container's Node 22 against the pinned Node 24, red on main too) / 0 / clean

What this does not reach

  • A sentence written on the server and shown inline. About 44 set…(res.error) sites in 35 client files render a server action's error in their own <p> instead of the toast, ActionError or a Field. Those stay English until the action translates it (64 of the 67 server-action files already call getTranslations()) or the site renders through one of the three. This is the remaining part of I18N-011: this PR narrows the row, it does not close it.
  • Text that goes to the model. lib/ai/tools/* pass the sentence into the model's context, and the model answers in the reader's language anyway.
  • Live browser check. No live run of a real RLS refusal in German was done. The provider, toast and field behaviour is covered only by the tests above.

No migration, configuration or dependency change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

describeDbError's five classified sentences (RLS, duplicate, missing row,
broken constraint, dropped connection) were English literals, so a German
family refused by RLS read English on a German screen. They are catalogue
keys now (dbError.*, en-US and the six full translations), and reach the
reader without touching any of the ~730 call sites:

- in the browser, LocaleProvider remembers the five sentences of its
  locale after it commits (never during render, so hydration matches the
  server's English; never on the server, which serves every reader), and
  describeDbError answers in them;
- a sentence written on the server (a server action's { error }) is put
  into the reader's language where it is shown: the toast, ActionError
  and a form Field's error, with their own t;
- dbError joins ROOT_CHROME_SCOPE, beside toast, so every surface has it.

lib/supabase/errors.ts still imports nothing: a dozen tests load it in a
sandbox that refuses unlisted imports. Three e2e fixtures that mount
LocaleProvider from a source list now list it.

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

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 11, 2026 12:38am UTC

Request Review

Copy link
Copy Markdown
Owner Author

Bounded I18N-011 source/test follow-up at47794826a3ae4fada65fe5b995e09805d5692620: describeDbError now returns localized strings, but localizeDbErrorText recognizes only English literals. A German refusal stored by ToastProvider (for example contacts-module's describeDbError(error) caller) cannot be retranslated after switching to French. English server strings do retranslate. This is a source-derived finding, not a runtime reproduction.

Keep stable error identity through retained UI state, or otherwise make retained refusals follow the receiving locale without relying on a browser-global provider's last registration. Add a real mounted-provider test: create a refusal under German, retain it across a French locale change, and verify the same toast/field message becomes French. Include nested-provider/unmount isolation and preserve toast focus/queue/ARIA behavior. Current direct-registration/fresh-description tests do not cover this transition.

Keep the acknowledged44 inline server-error sites and live German RLS browser proof open; no broad I18N-011 closure. Limit changes to this PR's owned source/tests, and coordinate the disclosed unpublished alternative with its owner before integration rather than duplicating that implementation. Report exact commit/tests and distinguish executed results from source assertions.

A German refusal a toast or a field was still holding stayed German after
the reader switched to French: localizeDbErrorText recognised only the
English sentences. errors.ts now keeps an append-only map of every
language the five sentences have been seen in (English, each catalogue a
provider renders, each translator a display point uses), so a kept
sentence is recognised whatever it was written in and shown with the
receiving component's own t, not the provider that registered last.

A mounted browser spec runs the real LocaleProvider, ToastProvider, Field
and ActionError with React: a refusal written under German turns French
in the same toast, field and ActionError when the reader switches, and a
server's English follows each receiving provider, a nested one included,
across the nested one's unmount. Red on 4779482, green here.

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

Copy link
Copy Markdown
Owner Author

Retained-refusal follow-up, at 895ff41d6.

The gap, confirmed by execution: the new mounted spec fails on 4779482. A refusal written under German stays German in the Field after the reader switches to French ("Dazu fehlt Ihnen die Berechtigung…" where the French sentence was expected).

The change, lib/supabase/errors.ts only (still import-free):

  • Each refusal keeps a stable identity: an append-only map from sentence to key, covering every language the five sentences have been seen in. That is English from the start, then each catalogue a provider renders and each translator a display point uses.
  • localizeDbErrorText(text, t) recognises any known sentence, whatever language it was written in, and shows it with the receiving component's own t. The language shown therefore follows the receiving provider, not the one that registered last. describeDbError's own output still uses the browser's registration; the display points no longer depend on it.
  • The toast, Field and ActionError source is unchanged since 4779482.

Executed (Chromium, local; no app server, every request answered by the fixture): tests/e2e/a-refusal-follows-the-readers-language.spec.ts mounts the real LocaleProvider, ToastProvider, Field and ActionError with React, from source, with the real de-DE and fr-FR catalogue entries.

  1. A component holds a describeDbError refusal in its own state under German. The toast, Field and ActionError read German. The tree is re-rendered under French with the state retained, and all three read French; the toast no longer contains the German. The toast's dismiss control is still labelled, in French.
  2. A French page with a nested German provider: the server's English pushed to the toast reads French, and the nested Field reads German. The nested provider, which registered last, is then removed. A new refusal in the outer Field reads French, then the root unmounts. No page errors.
4779482 895ff41d6
Mounted spec test 1 fails, test 2 passes 2/2
Unit (a-refusal-reads-…, a-refused-toast-…, fixture module graph, voice harness) — 71/71, including a new case for a sentence kept from a language the reader has left
Full unit suite / tsc / eslint — 30,928 passed (the 3 failures are this container's Node 22 against the pinned Node 24, red on main too) / 0 / clean

Toast focus, queue and ARIA behaviour are not changed by this PR, which only maps the message text. The existing toast suites (a-toast-you-can-still-reach-waits, the mobile queue) pass in that full run. The mounted spec checks only the labelled dismiss control, not focus or queue.

Still open, as you said:

  • the ~44 inline set…(res.error) server-error sites;
  • a live German RLS refusal in the real app.

No broad I18N-011 closure.

Your unpublished alternative eb2137026 passes the translator at contacts-module's call sites and in paperwork's actions. This PR does not duplicate it: it changes no call site, and passing a translator at a call site can still be layered on top. Do you want this PR to continue, or should it stand down in favour of yours? I won't integrate anything from eb2137026 until you decide.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Source receipt for895ff41d6796d391750fda9ae05477229ae5aa0b: the retained German-to-French message case is repaired for current catalogues, and the mounted retained-state fixture meaningfully exercises Field/ActionError/toast. Keep work bounded to this candidate's repair; do not integrate or replace the unpublished alternative without its owner's coordination.

One test follow-up for6100838353: the nested/unmount case supplies a fresh outer FR messages object on removal, re-running outer registration and potentially masking stale nested state. Keep that catalogue object stable; mount the nested German provider in a later commit, verify it registered last, then remove it without re-registering the outer provider and assert receiving-French output. Do not claim hydration or focus/queue cleanup from createRoot/pageerror checks. A uniqueness/substring-overlap guard for the35 static sentences would protect the identity-map assumption; current catalogues have no collision found.

No broad I18N-011 closure: inline server-error sites and live RLS proof stay open. Report exact commit and synthetic fixture results; no private-work integration or production access.

…rrent

Review asked the nested-provider test to keep the outer catalogue object
stable, so removing the nested German provider does not make the outer
French one register again. With that, the stale state was real: on
895ff41, after the German provider left, describeDbError still answered
in German, because the browser kept only the last registration.

Registrations are now a stack. Each LocaleProvider's effect pushes its
five sentences and its cleanup takes exactly that entry out again, so
describeDbError answers in the latest provider still mounted, and in
English when none is. errors.ts still imports nothing.

Tests:
- mounted spec: catalogues are one object for the page's life; a
  recorder shows the nested German provider registered last and the
  outer one did not register again on its removal; describeDbError and
  a new refusal are then French. It no longer claims hydration or
  toast focus/queue coverage. Red on 895ff41's errors.ts and with the
  provider's cleanup dropped.
- unit: stack removal in either order and back to English; the toast
  harness runs effect cleanups as unmount instead of re-registering
  English; the 35 catalogue sentences (5 keys x 7 catalogues) each name
  one key and none sits inside another (fails on a planted overlap).

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

Copy link
Copy Markdown
Owner Author

Nested-provider follow-up, at 964180c.

With the outer catalogue held stable, your suspicion was a real defect. On 895ff41, the outer French provider kept the same messages object and so did not register again. After the nested German provider left, describeDbError still answered in German: the browser kept only the last registration. The round-1 test masked this by re-registering French on removal.

The change:

  • errors.ts (still import-free) keeps registrations as a stack. rememberDbErrorText returns a function that removes exactly its own entry.
  • LocaleProvider's effect returns that function as its cleanup. describeDbError answers in the latest provider still mounted, and in English when none is.
  • The display points (toast, Field, ActionError) are unchanged; they already used their own t.

Executed, tests/e2e/a-refusal-follows-the-readers-language.spec.ts (Chromium, local, synthetic fixture, no app server):

  • The de-DE and fr-FR catalogues are one object each for the page's life. A wrapper records every registration a provider makes.
  • Test 2 renders French and commits. It then mounts the nested German provider in a later render; the record shows ['fr-FR', 'de-DE'], so German registered last. Server English pushed to the toast reads French, and the nested Field reads German.
  • The nested provider is then removed with the same French object. The record is still ['fr-FR', 'de-DE'], so French did not register again. Even so, describeDbError returns French, and a new refusal's Field and toast are French, with no toast containing the German sentence.
  • The header comment no longer claims hydration or toast focus/queue coverage. The spec says it drives the components and the registry only.

Executed, unit:

  • A nested registration is removed in either order, and with none left the output is English.
  • The toast harness now runs effect cleanups as unmount, instead of re-registering English.
  • The 35-sentence guard: each of the 5 keys × 7 catalogues names one key and no sentence sits inside another key's. It fails on a planted overlap (de-DE dbError.conflict set to the first sentence of dbError.permission); the catalogue was restored.
At 964180c Result
Mounted spec 2/2
Same spec on 895ff41's errors.ts test 2 fails: describeDbError answered German after the nested provider left
Same spec with the provider's cleanup dropped test 2 fails the same way
Same spec on 4779482's errors.ts both fail
Two I18N-011 unit suites 17/17
Full unit suite / tsc / eslint 30,930 passed (the 3 failures are this container's Node 22 against the pinned Node 24) / 0 / clean

Source assertions only, not executed:

  • hydration;
  • toast focus and queue behaviour (this PR changes neither);
  • a live German RLS refusal.

Still open: the ~44 inline set…(res.error) sites. No broad I18N-011 closure. Nothing from eb2137026 is integrated; that waits on your decision.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Exact-head CI follow-up for964180c356628d6264a8a5a3b5d437057f18d7f9: CI38095082355 Core job114339229505 failed unit step11; DB/Mobile passed and E2E is still running. Failure cause is unknown from the accessible step metadata.

The stable-catalogue/cleanup source repair addresses6101672848; please diagnose and repair this new exact-head unit failure in the same bounded lane without weakening the retained-state, stack-ownership or catalogue collision assertions. Report the failing assertion, repair commit and fresh checks. Prior895 green and local pass claims do not replace current-head evidence. Keep unpublished-work integration and broader I18N closure out of scope.

…mports

CI on 964180c (merged with main) failed tests/medications-fixture-module-
graph.test.ts: tests/e2e/notification-email-preference.spec.ts, added on
main by #989, stubs '@/lib/supabase/errors' with describeDbError and
wroteNoRows only, and components/ui/input.tsx (Field) imports
localizeDbErrorText from it on this branch. The stub now returns the
text unchanged; that fixture is not about language.

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

Copy link
Copy Markdown
Owner Author

Exact-head CI follow-up for 964180c (core job 114339229505). The fix is at 381b489.

The failing assertion (from the job log): tests/medications-fixture-module-graph.test.ts, "notification-email-preference.spec.ts provides every module its fixture will load › mocks every binding the app takes from a module it replaces":

"@/lib/supabase/errors is mocked without localizeDbErrorText  (imported by components/ui/input.tsx)"

Cause: an interaction with main, not a regression in the repair.

  • CI tests the PR merged with main. main (Audit fixes: 16 areas from the security and correctness audit #989) added tests/e2e/notification-email-preference.spec.ts, whose fixture stubs @/lib/supabase/errors with only describeDbError and wroteNoRows.
  • On this branch, Field imports localizeDbErrorText from that module.
  • My earlier local full run was on the branch's older base, which did not have that spec. That is why it passed locally and not in CI, as you said.

The repair:

Fresh checks on 381b489 (merged with 83e2b01), executed here:

Check Result
medications-fixture-module-graph (the failing test) 57/57
All 788 unit suites naming input, toast, action-error, locale-provider, supabase/errors, dbError, refusals, the module graph or the read-only field 785 passed, 3 skipped (18,096 tests)
notification-email-preference.spec.ts and a-refusal-follows-the-readers-language.spec.ts (Chromium, local fixtures) 16 passed, 3 skipped (the authenticated variants)
tsc / eslint on the changed and affected files 0 / clean

Not executed here: the full unit suite on this exact head. CI's core job runs it, and that result is the evidence for this head.


Generated by Claude Code

@NewWorldVenture
NewWorldVenture marked this pull request as ready for review October 11, 2026 01:57
@NewWorldVenture
NewWorldVenture merged commit 14b1deb into main Oct 11, 2026
9 checks passed
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants