API problem documents: detail is mostly untranslated and never in the account's language; instance ignores the request id #393

Closed
opened 2026-10-01 10:46:22 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading every HttpError and Refused raise in api/, api/problems.py and the middleware against the wiki's What a refusal looks like.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at api/api.py:620 (the translated capture refusal, cited below as :618) and api/api.py:500-506 (the comment about capture notifications, cited below as :498-504); every other location has not moved.

Related to #296 (closed), which made refusals RFC 9457 problem documents, said "detail stays translated", and left instance as the path "if request ids arrive later"; and to #233 (closed), which added those request ids. This issue adds what #296 left untrue: most detail sentences never pass through gettext, none is in the account's language on a token call, and instance still ignores the id that now exists.

The code and the wiki describe a contract for the refusal envelope that the code keeps only in part. A client author is told to show detail to people because it is in their language, and gets a translated sentence from a few calls and English from the rest. An operator handed a problem document has an address but nothing that leads to the log lines.

1. Most detail sentences are English literals

  • Where: the promise is in api/problems.py:24-26 (detail "goes through gettext like every other message here") and in the detail row of the member table in the wiki, The-capture-API.md:495 ("Translated into the account's language, because a person debugging reads it"). The 23 raises that pass a literal or an f-string:
    • api/auth.py:92-104, ScopedAuth.authenticate: both insufficient-scope sentences, including the one the wiki shows as its example (The-capture-API.md:484).
    • api/routers/common.py:22, :53, :58, :67 (choice_or_422, the referral and agency checks, the priority check).
    • api/routers/applications.py:144-146.
    • api/routers/documents.py:127, :163.
    • api/routers/interviews.py:59, :105, :113, :116, :147, :166.
    • api/routers/listings.py:86, :123, :150, :155, :160, :162.
    • api/routers/reminders.py:44, :81.
    • api/problems.py:244-261, _validation_sentence, is English on purpose and says so in its own docstring ("Untranslated, like the field names it quotes"), which contradicts the module docstring above it.
  • What happens: the refusals that are translated are few: api/api.py:618, api/idempotency.py:35-36, the too-large refusal at api/problems.py:202, api/routers/applications.py:168 and the rate limit. The browser extension (src/lib/api.js:104 and :117-118 in postulo-chromium) and the MCP server (src/postulo_mcp/client.py:125 in postulo-mcp) pass detail on to the person. "Nothing resembling a job posting was found there." arrives translated; "No such application.", "'ends_at' must be after 'starts_at'." and the scope refusal arrive in English, depending only on which line refused.

2. The language is the request's, not the account's

  • Where: core/middleware.py:175-181, UserPreferencesMiddleware._profile, returns None unless request.user is authenticated. A bearer-token call has no signed-in user (api/auth.py:64: "Nothing here logs the caller in"), and the token is checked inside the view, after the middleware has run. The owner's profile language is therefore never activated, and LocaleMiddleware's choice from Accept-Language stands. The comment at api/api.py:498-504 says the same about capture notifications.
  • What happens: for the refusals that are translated, a client of an account set to Portuguese receives whatever language its HTTP library's Accept-Language names; a script or the MCP server usually sends none, so it receives English. The wiki's "the account's language" is true of no refusal.
  • tests/test_api_problems.py:281-295, test_the_title_does_not_move_with_the_language, says in its docstring "Only one is translated" but asserts only title. The refusal it provokes is the insufficient-scope f-string, so the test cannot notice either gap.

3. instance ignores the request id that now exists

  • Where:
    • api/problems.py:31-35: "Nothing in Postulo mints one. … when request ids arrive, this is the line that changes." document() puts request.path in instance (api/problems.py:141-142).
    • The-capture-API.md:496: "Not a unique identifier for this occurrence — Postulo does not mint one".
    • core/middleware.py:184-210, RequestIDMiddleware (#233): sets request.request_id, scopes every log line to it and returns it in X-Request-ID. It is second in MIDDLEWARE (config/settings/base.py:90), so it has run before any API view.
  • What happens: the condition problems.py gave for changing instance has been met since #233, and both texts still say it has not. The id that would find the matching log lines is only in a response header, which a person reporting a refusal rarely copies; the body they do copy carries only the path.

Consequence and fix

  • Why it matters: the docstring and the wiki describe a contract the code keeps for roughly one refusal in five, so a client that relies on it shows mixed languages, and a maintainer adding a refusal has no single rule to follow. The statement about instance hides a debugging aid the instance already has.
  • Fix: decide what detail is, then make code, docstring and wiki agree. Do not leave a mixture.
    • If detail is for people (what both texts say today): wrap every literal listed in section 1 in gettext, with placeholders for the values (_("'%(field)s' must be one of %(choices)s; got %(value)r.")), keeping field names and enum values literal inside the sentence, and settle _validation_sentence one way or the other. For the language, either activate the token owner's profile language once the token is authenticated (in ScopedAuth.authenticate, with the same "still offered" check UserPreferencesMiddleware applies to a session), or correct the wiki to say detail follows the request's Accept-Language.
    • If detail is for developers: say so in api/problems.py and the wiki, and stop translating the few that are, so a client sees one language.
    • Put the request id in every problem document from problems.document, either as instance (for example urn:postulo:request:<id>, keeping the path in a separate member) or as a request_id extension member beside the path. Update the module docstring, the Problem schema (api/problems.py:100) and the wiki's member table.

Done when

  • tests/test_api_problems.py has a test asserting that a refusal's body carries the same id as its X-Request-ID header. It fails today.
  • tests/test_api_problems.py has a test that provokes two router refusals (an interview whose ends_at precedes starts_at, and a token lacking the write scope) with Accept-Language: fr and asserts detail is not the English sentence; or, if detail is declared English, asserts that every refusal's detail is the same whatever Accept-Language says. Either form fails today.
  • tests/test_api_problems.py has a test that sends a token whose owner's profile language is pt-PT with Accept-Language: en and pins which of the two languages detail is in.
  • api/problems.py and The capture API on the wiki describe the behaviour those tests pin, including which language a token-authenticated call gets and what instance holds.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading every `HttpError` and `Refused` raise in `api/`, `api/problems.py` and the middleware against the wiki's *What a refusal looks like*. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `api/api.py:620` (the translated capture refusal, cited below as `:618`) and `api/api.py:500-506` (the comment about capture notifications, cited below as `:498-504`); every other location has not moved. Related to #296 (closed), which made refusals RFC 9457 problem documents, said "`detail` stays translated", and left `instance` as the path "if request ids arrive later"; and to #233 (closed), which added those request ids. This issue adds what #296 left untrue: most `detail` sentences never pass through `gettext`, none is in the account's language on a token call, and `instance` still ignores the id that now exists. The code and the wiki describe a contract for the refusal envelope that the code keeps only in part. A client author is told to show `detail` to people because it is in their language, and gets a translated sentence from a few calls and English from the rest. An operator handed a problem document has an address but nothing that leads to the log lines. ## 1. Most `detail` sentences are English literals - **Where:** the promise is in `api/problems.py:24-26` (`detail` "goes through ``gettext`` like every other message here") and in the `detail` row of the member table in the wiki, `The-capture-API.md:495` ("Translated into the account's language, because a person debugging reads it"). The 23 raises that pass a literal or an f-string: - `api/auth.py:92-104`, `ScopedAuth.authenticate`: both insufficient-scope sentences, including the one the wiki shows as its example (`The-capture-API.md:484`). - `api/routers/common.py:22`, `:53`, `:58`, `:67` (`choice_or_422`, the referral and agency checks, the priority check). - `api/routers/applications.py:144-146`. - `api/routers/documents.py:127`, `:163`. - `api/routers/interviews.py:59`, `:105`, `:113`, `:116`, `:147`, `:166`. - `api/routers/listings.py:86`, `:123`, `:150`, `:155`, `:160`, `:162`. - `api/routers/reminders.py:44`, `:81`. - `api/problems.py:244-261`, `_validation_sentence`, is English on purpose and says so in its own docstring ("Untranslated, like the field names it quotes"), which contradicts the module docstring above it. - **What happens:** the refusals that are translated are few: `api/api.py:618`, `api/idempotency.py:35-36`, the too-large refusal at `api/problems.py:202`, `api/routers/applications.py:168` and the rate limit. The browser extension (`src/lib/api.js:104` and `:117-118` in postulo-chromium) and the MCP server (`src/postulo_mcp/client.py:125` in postulo-mcp) pass `detail` on to the person. "Nothing resembling a job posting was found there." arrives translated; "No such application.", "'ends_at' must be after 'starts_at'." and the scope refusal arrive in English, depending only on which line refused. ## 2. The language is the request's, not the account's - **Where:** `core/middleware.py:175-181`, `UserPreferencesMiddleware._profile`, returns `None` unless `request.user` is authenticated. A bearer-token call has no signed-in user (`api/auth.py:64`: "Nothing here logs the caller in"), and the token is checked inside the view, after the middleware has run. The owner's profile language is therefore never activated, and `LocaleMiddleware`'s choice from `Accept-Language` stands. The comment at `api/api.py:498-504` says the same about capture notifications. - **What happens:** for the refusals that are translated, a client of an account set to Portuguese receives whatever language its HTTP library's `Accept-Language` names; a script or the MCP server usually sends none, so it receives English. The wiki's "the account's language" is true of no refusal. - `tests/test_api_problems.py:281-295`, `test_the_title_does_not_move_with_the_language`, says in its docstring "Only one is translated" but asserts only `title`. The refusal it provokes is the insufficient-scope f-string, so the test cannot notice either gap. ## 3. `instance` ignores the request id that now exists - **Where:** - `api/problems.py:31-35`: "Nothing in Postulo mints one. … when request ids arrive, this is the line that changes." `document()` puts `request.path` in `instance` (`api/problems.py:141-142`). - `The-capture-API.md:496`: "Not a unique identifier for this occurrence — Postulo does not mint one". - `core/middleware.py:184-210`, `RequestIDMiddleware` (#233): sets `request.request_id`, scopes every log line to it and returns it in `X-Request-ID`. It is second in `MIDDLEWARE` (`config/settings/base.py:90`), so it has run before any API view. - **What happens:** the condition `problems.py` gave for changing `instance` has been met since #233, and both texts still say it has not. The id that would find the matching log lines is only in a response header, which a person reporting a refusal rarely copies; the body they do copy carries only the path. ## Consequence and fix - **Why it matters:** the docstring and the wiki describe a contract the code keeps for roughly one refusal in five, so a client that relies on it shows mixed languages, and a maintainer adding a refusal has no single rule to follow. The statement about `instance` hides a debugging aid the instance already has. - **Fix:** decide what `detail` is, then make code, docstring and wiki agree. Do not leave a mixture. - If `detail` is for people (what both texts say today): wrap every literal listed in section 1 in `gettext`, with placeholders for the values (`_("'%(field)s' must be one of %(choices)s; got %(value)r.")`), keeping field names and enum values literal inside the sentence, and settle `_validation_sentence` one way or the other. For the language, either activate the token owner's profile language once the token is authenticated (in `ScopedAuth.authenticate`, with the same "still offered" check `UserPreferencesMiddleware` applies to a session), or correct the wiki to say `detail` follows the request's `Accept-Language`. - If `detail` is for developers: say so in `api/problems.py` and the wiki, and stop translating the few that are, so a client sees one language. - Put the request id in every problem document from `problems.document`, either as `instance` (for example `urn:postulo:request:<id>`, keeping the path in a separate member) or as a `request_id` extension member beside the path. Update the module docstring, the `Problem` schema (`api/problems.py:100`) and the wiki's member table. ## Done when - `tests/test_api_problems.py` has a test asserting that a refusal's body carries the same id as its `X-Request-ID` header. It fails today. - `tests/test_api_problems.py` has a test that provokes two router refusals (an interview whose `ends_at` precedes `starts_at`, and a token lacking the `write` scope) with `Accept-Language: fr` and asserts `detail` is not the English sentence; or, if `detail` is declared English, asserts that every refusal's `detail` is the same whatever `Accept-Language` says. Either form fails today. - `tests/test_api_problems.py` has a test that sends a token whose owner's profile language is `pt-PT` with `Accept-Language: en` and pins which of the two languages `detail` is in. - `api/problems.py` and *The capture API* on the wiki describe the behaviour those tests pin, including which language a token-authenticated call gets and what `instance` holds.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:46:22 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#393
No description provided.