Repository navigation
fix(mobile): explain an expired organization plan instead of a server error - #8790
Conversation
… error An organization whose trial passed its enforcement window is refused by every organization mutation. The app turned that rejection into the generic internal line when a session started, and rendered the raw English sentence on the member daily limit sheet, so a German reader saw either an internal error or untranslated text. One shared module now holds the server sentences the app can explain and the localized copy for each. The cloud-prepare classification and every organization surface that showed a rejection (member limits, invite, org rename, member and invitation mutations) read it, and a rejection the app cannot explain falls back to the localized generic line, so no server sentence reaches a screen. Adds the expired-plan, invite-conflict and own-role sentences with translations in every catalog.
|
bot: German-language run on the Android dev build against the local stack, with a fixture organization whose trial is hard-expired. Each capture was taken together with a Starting a session with a repository:
Saving a member daily limit (value 12,5):
The server logged |
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe only change since the previous review at Assumptions: Files Reviewed (105 files)
Previous Review Summaries (4 snapshots, latest commit 138f36b)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 138f36b)Status: No Issues Found | Recommendation: Merge Executive SummaryThe new head Assumptions: Files Reviewed (15 files)
Previous review (commit 7de6917)Status: No Issues Found | Recommendation: Merge Executive SummaryThe incremental commit Assumptions: Files Reviewed (2 files)
Previous review (commit 19f7131)Status: No Issues Found | Recommendation: Merge Executive SummaryThe follow-up commit resolves all four prior findings: the owner-authority and resend-invite refusals now map to localized copy, the rename dialog preserves the localized 15s deadline message, and the Hindi Assumptions: Files Reviewed (92 files)
Previous review (commit 01f2ed2)Status: 4 Issues Found | Recommendation: Address before merge Executive SummaryThe expired-plan copy fix is correct and well covered, but routing every organization rejection through the exact-sentence map drops specific, reachable server reasons on the invite and resend-invite surfaces, most notably an admin inviting an owner. Assumptions: Overview
Issue Details (click to expand)WARNING
SUGGESTION
Verified clean
Files Reviewed (99 files)
Reviewed by deepseek-flash-latest · Input: 102.1K · Output: 13.3K · Cached: 1.9M Review guidance: REVIEW.md from base branch |
Review follow-up. Organization surfaces still lost three kinds of detail after they stopped echoing server text: an admin touching an owner, and an invitation that expired, was already accepted, or can no longer be resent, all fell to the generic line. Those sentences now have their own keys and translations, and a missing invitation reads as the existing not-found copy. The rename dialog's deadline is the app's own localized message, so it is marked and kept instead of being rewritten by a caller's mapper. Corrects one Hindi entry that was missing a word space.
|
bot: Re-ran both German device scenarios on the follow-up head, same fixture organization, each capture taken together with a
Both requests answered 403 from the local stack. Fixture data only. |
The review of the previous commit flagged the shape of the new conflict test and a nested ternary in the rename dialog's rejection branch; both now follow the repository style rules without changing behaviour.
|
bot: Final head run of both German device scenarios, captures taken together with a
Requests answered 403 from the local stack; fixture data only. |
…tion-error-copy # Conflicts: # apps/mobile/src/components/organization/invite-member-sheet.tsx # apps/mobile/src/components/organization/invited-member-row-state.ts # apps/mobile/src/components/organization/member-limit-sheet.tsx # apps/mobile/src/i18n/locales/af.json # apps/mobile/src/i18n/locales/am.json # apps/mobile/src/i18n/locales/ar.json # apps/mobile/src/i18n/locales/az.json # apps/mobile/src/i18n/locales/be.json # apps/mobile/src/i18n/locales/bg.json # apps/mobile/src/i18n/locales/bn.json # apps/mobile/src/i18n/locales/bs.json # apps/mobile/src/i18n/locales/ca.json # apps/mobile/src/i18n/locales/ckb.json # apps/mobile/src/i18n/locales/cs.json # apps/mobile/src/i18n/locales/cy.json # apps/mobile/src/i18n/locales/da.json # apps/mobile/src/i18n/locales/de.json # apps/mobile/src/i18n/locales/el.json # apps/mobile/src/i18n/locales/en.json # apps/mobile/src/i18n/locales/es.json # apps/mobile/src/i18n/locales/et.json # apps/mobile/src/i18n/locales/eu.json # apps/mobile/src/i18n/locales/fa.json # apps/mobile/src/i18n/locales/fi.json # apps/mobile/src/i18n/locales/fil.json # apps/mobile/src/i18n/locales/fr.json # apps/mobile/src/i18n/locales/ga.json # apps/mobile/src/i18n/locales/gl.json # apps/mobile/src/i18n/locales/gu.json # apps/mobile/src/i18n/locales/ha.json # apps/mobile/src/i18n/locales/he.json # apps/mobile/src/i18n/locales/hi.json # apps/mobile/src/i18n/locales/hr.json # apps/mobile/src/i18n/locales/ht.json # apps/mobile/src/i18n/locales/hu.json # apps/mobile/src/i18n/locales/hy.json # apps/mobile/src/i18n/locales/id.json # apps/mobile/src/i18n/locales/ig.json # apps/mobile/src/i18n/locales/is.json # apps/mobile/src/i18n/locales/it.json # apps/mobile/src/i18n/locales/ja.json # apps/mobile/src/i18n/locales/ka.json # apps/mobile/src/i18n/locales/kk.json # apps/mobile/src/i18n/locales/km.json # apps/mobile/src/i18n/locales/kn.json # apps/mobile/src/i18n/locales/ko.json # apps/mobile/src/i18n/locales/lo.json # apps/mobile/src/i18n/locales/lt.json # apps/mobile/src/i18n/locales/lv.json # apps/mobile/src/i18n/locales/mg.json # apps/mobile/src/i18n/locales/mi.json # apps/mobile/src/i18n/locales/mk.json # apps/mobile/src/i18n/locales/ml.json # apps/mobile/src/i18n/locales/mn.json # apps/mobile/src/i18n/locales/mr.json # apps/mobile/src/i18n/locales/ms.json # apps/mobile/src/i18n/locales/mt.json # apps/mobile/src/i18n/locales/my.json # apps/mobile/src/i18n/locales/nb.json # apps/mobile/src/i18n/locales/ne.json # apps/mobile/src/i18n/locales/nl.json # apps/mobile/src/i18n/locales/om.json # apps/mobile/src/i18n/locales/or.json # apps/mobile/src/i18n/locales/pa.json # apps/mobile/src/i18n/locales/pl.json # apps/mobile/src/i18n/locales/ps.json # apps/mobile/src/i18n/locales/pt-BR.json # apps/mobile/src/i18n/locales/pt.json # apps/mobile/src/i18n/locales/ro.json # apps/mobile/src/i18n/locales/ru.json # apps/mobile/src/i18n/locales/si.json # apps/mobile/src/i18n/locales/sk.json # apps/mobile/src/i18n/locales/sl.json # apps/mobile/src/i18n/locales/so.json # apps/mobile/src/i18n/locales/sq.json # apps/mobile/src/i18n/locales/sr.json # apps/mobile/src/i18n/locales/sv.json # apps/mobile/src/i18n/locales/sw.json # apps/mobile/src/i18n/locales/ta.json # apps/mobile/src/i18n/locales/te.json # apps/mobile/src/i18n/locales/th.json # apps/mobile/src/i18n/locales/tr.json # apps/mobile/src/i18n/locales/uk.json # apps/mobile/src/i18n/locales/ur.json # apps/mobile/src/i18n/locales/uz.json # apps/mobile/src/i18n/locales/vi.json # apps/mobile/src/i18n/locales/yo.json # apps/mobile/src/i18n/locales/zh-Hans.json # apps/mobile/src/i18n/locales/zh-Hant.json # apps/mobile/src/i18n/locales/zu.json
…tion-error-copy # Conflicts: # apps/mobile/src/i18n/locales/af.json # apps/mobile/src/i18n/locales/am.json # apps/mobile/src/i18n/locales/ar.json # apps/mobile/src/i18n/locales/az.json # apps/mobile/src/i18n/locales/be.json # apps/mobile/src/i18n/locales/bg.json # apps/mobile/src/i18n/locales/bn.json # apps/mobile/src/i18n/locales/bs.json # apps/mobile/src/i18n/locales/ca.json # apps/mobile/src/i18n/locales/ckb.json # apps/mobile/src/i18n/locales/cs.json # apps/mobile/src/i18n/locales/cy.json # apps/mobile/src/i18n/locales/da.json # apps/mobile/src/i18n/locales/de.json # apps/mobile/src/i18n/locales/el.json # apps/mobile/src/i18n/locales/en.json # apps/mobile/src/i18n/locales/es.json # apps/mobile/src/i18n/locales/et.json # apps/mobile/src/i18n/locales/eu.json # apps/mobile/src/i18n/locales/fa.json # apps/mobile/src/i18n/locales/fi.json # apps/mobile/src/i18n/locales/fil.json # apps/mobile/src/i18n/locales/fr.json # apps/mobile/src/i18n/locales/ga.json # apps/mobile/src/i18n/locales/gl.json # apps/mobile/src/i18n/locales/gu.json # apps/mobile/src/i18n/locales/ha.json # apps/mobile/src/i18n/locales/he.json # apps/mobile/src/i18n/locales/hi.json # apps/mobile/src/i18n/locales/hr.json # apps/mobile/src/i18n/locales/ht.json # apps/mobile/src/i18n/locales/hu.json # apps/mobile/src/i18n/locales/hy.json # apps/mobile/src/i18n/locales/id.json # apps/mobile/src/i18n/locales/ig.json # apps/mobile/src/i18n/locales/is.json # apps/mobile/src/i18n/locales/it.json # apps/mobile/src/i18n/locales/ja.json # apps/mobile/src/i18n/locales/ka.json # apps/mobile/src/i18n/locales/kk.json # apps/mobile/src/i18n/locales/km.json # apps/mobile/src/i18n/locales/kn.json # apps/mobile/src/i18n/locales/ko.json # apps/mobile/src/i18n/locales/lo.json # apps/mobile/src/i18n/locales/lt.json # apps/mobile/src/i18n/locales/lv.json # apps/mobile/src/i18n/locales/mg.json # apps/mobile/src/i18n/locales/mi.json # apps/mobile/src/i18n/locales/mk.json # apps/mobile/src/i18n/locales/ml.json # apps/mobile/src/i18n/locales/mn.json # apps/mobile/src/i18n/locales/mr.json # apps/mobile/src/i18n/locales/ms.json # apps/mobile/src/i18n/locales/mt.json # apps/mobile/src/i18n/locales/my.json # apps/mobile/src/i18n/locales/nb.json # apps/mobile/src/i18n/locales/ne.json # apps/mobile/src/i18n/locales/nl.json # apps/mobile/src/i18n/locales/om.json # apps/mobile/src/i18n/locales/or.json # apps/mobile/src/i18n/locales/pa.json # apps/mobile/src/i18n/locales/pl.json # apps/mobile/src/i18n/locales/ps.json # apps/mobile/src/i18n/locales/pt-BR.json # apps/mobile/src/i18n/locales/pt.json # apps/mobile/src/i18n/locales/ro.json # apps/mobile/src/i18n/locales/ru.json # apps/mobile/src/i18n/locales/si.json # apps/mobile/src/i18n/locales/sk.json # apps/mobile/src/i18n/locales/sl.json # apps/mobile/src/i18n/locales/so.json # apps/mobile/src/i18n/locales/sq.json # apps/mobile/src/i18n/locales/sr.json # apps/mobile/src/i18n/locales/sv.json # apps/mobile/src/i18n/locales/sw.json # apps/mobile/src/i18n/locales/ta.json # apps/mobile/src/i18n/locales/te.json # apps/mobile/src/i18n/locales/th.json # apps/mobile/src/i18n/locales/tr.json # apps/mobile/src/i18n/locales/uk.json # apps/mobile/src/i18n/locales/ur.json # apps/mobile/src/i18n/locales/uz.json # apps/mobile/src/i18n/locales/vi.json # apps/mobile/src/i18n/locales/yo.json # apps/mobile/src/i18n/locales/zh-Hans.json # apps/mobile/src/i18n/locales/zh-Hant.json # apps/mobile/src/i18n/locales/zu.json
|
bot: German-language run at the merged head on the Android dev build against the local dev stack, with a fixture organization whose trial is expired. Each capture was taken in the same command as the
Both surfaces resolve the rejection through one shared map, so their copy cannot drift apart. |










Evidence
Evidence: demonstrated — on the local dev stack with a fixture organization whose trial is hard-expired (no seat purchase,
require_seatson), app language German, the Android dev build showed the defect and then the fix: starting a session with a repository rendered the generic internal line before the change and the localized plan-ended copy after it, and saving a member daily limit rendered the raw English server sentence before and the German copy after. The server answered403 FORBIDDENfororganizations.cloudAgentNext.prepareSessionandorganizations.members.updatein both runs (dev/logs/nextjs.log). Both scenarios were re-run unchanged on the head after mergingmain.What changed
An organization whose trial passed its enforcement window is refused by
requireActiveSubscriptionOrTrial(packages/web-shared/src/lib/organizations/trial-middleware.ts) on every organization mutation. The app did not recognize that rejection:cloudPrepareFailureMessagereturnedqueryError.serverDescription);mutations.updateMember.error.messageas is, so a German reader sawOrganization trial has expired.— an untranslated server sentence.Both are the same class: an organization surface showing server text instead of localized copy.
apps/mobile/src/lib/server-error-copy.tsowns the exact server sentences the app can explain and the localized copy for each, and moves the two constantscloud-prepare-error-display.tsalready held.knownServerErrorMessagemaps those sentences;organizationErrorMessageadds the localized generic line for anything else.cloudPrepareFailureMessageconsults the map first, so a lapsed plan keeps its actionable copy.isCloudPrepareRetryableErroralready treatsFORBIDDENas terminal, so the composer shows no retry control for it (covered bymobile-session-manager.test.ts).useOrganizationMutationstoast, the invitation resend toast, the low balance alert save, and the organization rename dialog (RenameModalgained an optionalerrorCopyprop; the org hub passes the organization mapper, the session/chat callers keep the current behaviour).saveErrorCopy,inviteErrorCopy); each consults the shared sentence map first, so a rejection the app can name keeps its own line and only an unnamed one falls back to the sheet's generic line. The resend toast does the same, with the missing-invitation sentence resolving to the existing not-found copy.organization.members.ownerAuthority), the three invitation resend refusals (inviteExpired,inviteAccepted,inviteNotResendable), a missing invitation (the existing not-found copy), an address that is already a member, an outstanding pending invitation, and a self role change.withUiDeadlinenow marks its own rejection, so the rename dialog keeps the app's localized deadline line instead of running it through the caller's server-error mapper.common.organizationTrialEnded("The organization's trial has ended and it has no active plan. An organization owner can update billing at kilo.ai, then try again."),organization.inviteMember.alreadyMember,organization.inviteMember.pendingInvitation,organization.members.ownRoleChange,organization.members.ownerAuthority,organization.members.inviteExpired,organization.members.inviteAccepted,organization.members.inviteNotResendable. All 86 non-English catalogs carry the same keys translated (one batch per locale, sibling strings passed as context), verified bycheck:i18nandcheck:plurals.The message is text only: the app has no in-app organization billing or plan screen, and the organization hub already links out to kilo.ai for billing.
Verification
pnpm --filter kilo-app typecheck,lint,check:i18n,check:plurals— clean on the merged head.server-error-copy,cloud-prepare-error-display,invite-member-validators,member-limit-validators,member-limit-sheet.mounted,invited-member-row-state,use-organization-mutations,low-balance-alert-validators,ui-deadline) — 83 tests, all passing.server-error-copy.test.tsasserts the trial gate, the three resend refusals, owner authority, the invite sentences and the missing-invitation fallback;ui-deadline.test.tsasserts the deadline rejection is marked.omp36), local dev stack on this VM, app language German, dev build from the native-build artifact, fixture organization withfree_trial_end_at10 days in the past:Die Sitzung konnte nicht erstellt werden./Bei uns ist ein Fehler aufgetreten. Bitte versuche es erneut.After:Die Testphase der Organisation ist beendet und sie hat keinen aktiven Plan. Ein Inhaber der Organisation kann die Abrechnung unter kilo.ai aktualisieren und es dann erneut versuchen.No retry control for that error in either run.12,5, Speichern. Before:Organization trial has expired.After: the same German sentence.git statuswas clean for those paths afterwards.ae8c4d2f2d: same German copy on both surfaces, both requests answered 403 from the local stack, and the screenshots above were captured together with theuiautomatordumps that assert them.kilo-app CI,CI, and the remaining workflows green;mobile-native-buildre-ran after its concurrency cancellation.Notes
organization.members.ownRoleChangeis mapped even though the member list offers role changes only for other members, so the app cannot currently produce it.Proxy answers
The owner's proxy gave these answers in the owner's place.
Answer: Text only, names owner + where to fix
Confidence: 0.55 · Precedent: Speed and least work: do the least work with no drop in quality (session 7f3470e0ba6f, "do the least amount of work possible, with no drop in quality"). The app has no in-app billing screen, and one message works for both owners and members without new action plumbing.
Answer: Map every known user-facing server sentence to its own localized key: trial expired, already a member, pending invitation, and cannot change own role. Keep each server sentence as a shared constant next to ORGANIZATION_ACCESS_MESSAGE. Any unknown error gets the localized generic fallback. The class fix must not leave English text on an organization surface, and members keep the specific reason.
Confidence: 0.50 · Precedent: UX and design quality: localize every string in the mobile app (session dcb3f9170164, "the mobile app always must respect i18n"). Autonomy: fix the class (session 00eb2a1d3e4b).