You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rescoped:#45 is the API half of i18n infrastructure. The SPA half is #182 and lands after this — it depends on GET /me (the user's language) and the errorCodes extension. Ships English-only; first language pack is Phase 1.5 (epic #15).
Part of epic #14 (Phase 1.1). Spec: §4.5 "UI language vs farm locale", §5.2, §24, §6 (item 13), UC-012; tech spec §3.2.
Plan hardened after a codex (repo-aware) + pi review. Corrections from that pass are marked ⓡ.
Decisions
users.language = nullable BCP-47 primary-language subtag on ApplicationUser, stored lowercase. NOT a full locale — regional variants stay a farms.locale formatting concern (§4.5).
Every authenticated user — Read-only included — may update their own language (self-service, not admin-gated, not farm-scoped).
ⓡ Update verb is PUT /api/v1/me/language, not PATCH /me. PATCH on a nullable field has an absent-vs-null ambiguity (a missing nullable JSON property binds as null → silent clear). PUT of one absolute preference is unambiguous.
Error codes are additive and explicit-only (see below).
Technical approach
1. users.language column
Nullable varchar on ApplicationUser (ASP.NET Identity). EF migration.
ⓡ Validation grammar (FluentValidation): null OR 2–8 ASCII letters (^[A-Za-z]{2,8}$, the RFC 5646 primary-subtag width), case-insensitive input, trimmed and lowercased on store. Edge cases pinned: null clears; absent property is a 400 (PUT requires the field); empty/whitespace is invalid (not another spelling of null); en-US is rejected (regional variant, not a primary subtag).
2. GET /api/v1/me
New user-scoped group (MeEndpoints), identity from the token — distinct from farm-scoped /account.
Returns { id, email, role, language }. ⓡ Audit what the SPA actually displays about the user (does anything show a display name?) and add name/displayName if so, rather than making the SPA parse the JWT for it. Role echoed for convenience; JWT stays authoritative.
3. PUT /api/v1/me/language
Body { language: string | null }, validated as §1. Open to every role incl. Read-only — the write-authorization gate must explicitly permit it.
ⓡ Idempotency namespace bug — fix required.IdempotencyMiddleware keys by (AccountId, method+path, key) with no user id (IdempotencyMiddleware.cs:43-56). Two users in the same account sending the same key to /me/language → the second replays the first's stored 2xx and skips their own update. This endpoint's idempotency scope must include the current user id. Ship with a cross-user/same-key integration test. (Writes still require an Idempotency-Key per the app-wide middleware; the SPA mints a fresh UUID per call, so key reuse across different bodies does not arise in practice — the user-scope fix closes the cross-user hole regardless.)
4. Per-field validation errorCodes (additive)
400 ValidationProblem keeps errors: { field: ["English message"] } unchanged; gains a parallel errorCodes: { field: ["Code"] }. Message = English fallback; code = the stable contract. Backward-compatible (old SPA ignores the extra member).
ⓡ Emit codes only where an EXPLICIT code was assigned. FluentValidation's default codes (NotEmptyValidator, …) are framework internals and must NOT be exposed as stable contract. A field/rule without an intentional code emits noerrorCodes entry (its English message still appears in errors).
ⓡ Code convention <Feature>.<Field>.<Rule> (e.g. Me.Language.Format) — a bare Field.Rule collides across endpoints.
ⓡ Preserve per-field array-index alignment between errors[field][i] (message) and errorCodes[field][i] (code) so the client can pair a code with its English message.
ⓡ The real surface is 31 validator classes / 35 ValidationProblem call sites, 4 hand-built (non-FluentValidation) validation responses, and malformed-JSON/model-binding 400s that bypass all of them — not "~15." Scope decisions:
This slice ships: the shared errorCodes response helper; swaps the 35 FV call sites to it (additive, errors unchanged); wires the 4 manual responses through the same shape; and assigns explicit codes to the new /me/language validator as the worked example.
Malformed JSON / type-binding 400s are explicitly OUT of the coded contract (they are malformed requests, not field validation) — documented, not silently uncovered.
Retrofitting explicit codes onto the other 30 validators is incremental and tracked (checklist below), NOT a big-bang — but until a validator has explicit codes, it emits none (no default-code leakage).
ⓡ Interpolation limitation, decided now: codes carry no arguments, so a translated message cannot reconstruct "maximum 256 characters." Phase 1.1 keeps the English message as the fallback for such cases; if Phase 1.5 needs interpolated translations, add a structured errorArgs (or error-item) extension THEN. Recorded so it is a conscious deferral, not a surprise.
GET /me — identity + language (+ name if the SPA displays one)
PUT /me/language — self-service (all roles incl. Read-only), user-scoped idempotency
Language validation (2–8 letters or null; lowercased; absent/empty/en-US handled)
Shared additive errorCodes helper; swap 35 FV call sites; route the 4 manual responses through it; explicit codes on the /me/language validator
Fix IdempotencyMiddleware to include user id for user-scoped write endpoints (or a per-endpoint override) + cross-user test
Docs: GLOSSARY (UI language vs farm locale) + Help (a "your language" note) + specs §3.2/§4.5
Tests: column round-trip, null↔value round-trip, GET /me shape incl. language, PUT /me/language for every role incl. Read-only, validation rejects (absent/empty/en-US/too-long), cross-user same-key idempotency, errorCodes present+aligned on a coded 400, no default-code leakage on an uncoded field
Incremental (tracked, before epic #14 closes) — explicit codes on the remaining 30 validators
Enumerate and assign <Feature>.<Field>.<Rule> codes. Can be split into its own migration issue when scheduled; listed here so the surface is KNOWN, not discovered later.
Rescoped: #45 is the API half of i18n infrastructure. The SPA half is #182 and lands after this — it depends on
GET /me(the user's language) and theerrorCodesextension. Ships English-only; first language pack is Phase 1.5 (epic #15).Part of epic #14 (Phase 1.1). Spec: §4.5 "UI language vs farm locale", §5.2, §24, §6 (item 13), UC-012; tech spec §3.2.
Decisions
users.language= nullable BCP-47 primary-language subtag onApplicationUser, stored lowercase. NOT a full locale — regional variants stay afarms.localeformatting concern (§4.5).GET /me, not the JWT. Role stays JWT-derived (re-read on refresh, F19: Admin-gate corrective/destructive actions — stepping stone to full RBAC (#73) #78) — unchanged. A change takes effect on the next catalog load.PUT /api/v1/me/language, notPATCH /me. PATCH on a nullable field has an absent-vs-nullambiguity (a missing nullable JSON property binds asnull→ silent clear). PUT of one absolute preference is unambiguous.Technical approach
1.
users.languagecolumnvarcharonApplicationUser(ASP.NET Identity). EF migration.nullOR 2–8 ASCII letters (^[A-Za-z]{2,8}$, the RFC 5646 primary-subtag width), case-insensitive input, trimmed and lowercased on store. Edge cases pinned:nullclears; absent property is a 400 (PUT requires the field); empty/whitespace is invalid (not another spelling of null);en-USis rejected (regional variant, not a primary subtag).2.
GET /api/v1/meMeEndpoints), identity from the token — distinct from farm-scoped/account.{ id, email, role, language }. ⓡ Audit what the SPA actually displays about the user (does anything show a display name?) and addname/displayNameif so, rather than making the SPA parse the JWT for it. Role echoed for convenience; JWT stays authoritative.3.
PUT /api/v1/me/language{ language: string | null }, validated as §1. Open to every role incl. Read-only — the write-authorization gate must explicitly permit it.IdempotencyMiddlewarekeys by(AccountId, method+path, key)with no user id (IdempotencyMiddleware.cs:43-56). Two users in the same account sending the same key to/me/language→ the second replays the first's stored 2xx and skips their own update. This endpoint's idempotency scope must include the current user id. Ship with a cross-user/same-key integration test. (Writes still require anIdempotency-Keyper the app-wide middleware; the SPA mints a fresh UUID per call, so key reuse across different bodies does not arise in practice — the user-scope fix closes the cross-user hole regardless.)4. Per-field validation
errorCodes(additive)ValidationProblemkeepserrors: { field: ["English message"] }unchanged; gains a parallelerrorCodes: { field: ["Code"] }. Message = English fallback; code = the stable contract. Backward-compatible (old SPA ignores the extra member).NotEmptyValidator, …) are framework internals and must NOT be exposed as stable contract. A field/rule without an intentional code emits noerrorCodesentry (its English message still appears inerrors).<Feature>.<Field>.<Rule>(e.g.Me.Language.Format) — a bareField.Rulecollides across endpoints.errors[field][i](message) anderrorCodes[field][i](code) so the client can pair a code with its English message.ValidationProblemcall sites, 4 hand-built (non-FluentValidation) validation responses, and malformed-JSON/model-binding 400s that bypass all of them — not "~15." Scope decisions:errorCodesresponse helper; swaps the 35 FV call sites to it (additive,errorsunchanged); wires the 4 manual responses through the same shape; and assigns explicit codes to the new/me/languagevalidator as the worked example.errorArgs(or error-item) extension THEN. Recorded so it is a conscious deferral, not a surprise.Scope
users.languagecolumn (nullable varchar) + migrationGET /me— identity + language (+ name if the SPA displays one)PUT /me/language— self-service (all roles incl. Read-only), user-scoped idempotencyen-UShandled)errorCodeshelper; swap 35 FV call sites; route the 4 manual responses through it; explicit codes on the/me/languagevalidatorIdempotencyMiddlewareto include user id for user-scoped write endpoints (or a per-endpoint override) + cross-user testGET /meshape incl. language,PUT /me/languagefor every role incl. Read-only, validation rejects (absent/empty/en-US/too-long), cross-user same-key idempotency,errorCodespresent+aligned on a coded 400, no default-code leakage on an uncoded fieldIncremental (tracked, before epic #14 closes) — explicit codes on the remaining 30 validators
Enumerate and assign
<Feature>.<Field>.<Rule>codes. Can be split into its own migration issue when scheduled; listed here so the surface is KNOWN, not discovered later.Out of scope
Sequencing
Lands first; #182 depends on
GET /me+ theerrorCodesextension.