feat(api)!: keep language out of the domain - #12
Merged
Conversation
The domain stored finished Portuguese sentences. That is fine for one locale and wrong for two, and the front end is meant to be internationalised. A fact now carries a kind and slots — character, room id, interval index — and prose is produced at the edge from a catalog, shipping pt-BR and en. A second locale is the only way to know the first one is actually separable. Rooms are stable ids, time is minutes on a clock the case defines, and secrets, means and motives are message keys. The clearest symptom of the old design was a PREPOSICAO map inside the generator, deciding that "adega" takes *na* and "porão" takes *no*. That is Portuguese grammar living in the domain model; English contracts nothing and German would want three articles. Grammar now lives in the catalog, where it can differ per language. The solver, the validator and scoring already reasoned over structured fields rather than prose, so they became locale-independent for free — no sentence is parsed anywhere in the deduction path. Canary filtering is unaffected: a canary is a token, not a language. Same commit renames every identifier to English (ADR-0006). The rename rides along because this change already rewrote every model; doing it separately would have meant editing the same files twice. Rule references stay as they are — RN-012 is a code, not a word — and product content stays Portuguese where it is setting: character names, the manor, the pt-BR catalog. ADR-0005 records why locale is a property of the match rather than of a request: mid-match switching would leave statements in two languages, which breaks contradiction detection, degrades pgvector search over mixed-language memory, and reads as if the character had changed personality. It also records the cost nobody budgets for — injection resistance varies by language, so the adversarial golden sets need real per-language cases rather than machine translations. Verified with the same seed in both locales: same culprit, same room, same interval; only the prose and the clock format change.
Madeuss
added a commit
that referenced
this pull request
Aug 29, 2026
Point 3 of the internationalisation plan, after #12. ## Who this is for An engineer doing a technical screen spends five to fifteen minutes: README, directory tree, one or two files. A recruiter spends thirty seconds on the first paragraph. Neither reads Portuguese. Everything behind that first paragraph — the ADRs, the solver, the isolation boundary — may as well not exist. ## What changed - **Leads with the difficulty, not the feature list.** "The chat is the easy part" is the sentence that decides whether someone keeps reading. - **Shows real CLI output**, captured from `make case SEED=42 LOCALE=en`, not a description of it. Six dossiers with six different fact counts demonstrates context isolation better than a paragraph asserting it. - **A "what is hard here" section** that hands the reader the two files worth opening — the solver that takes `Case` and never `CaseWithSolution`, and the domain where the solution is a separate entity — instead of making them guess from the tree. - **A diagram of the boundary**: the `never` edge from `Solution` to NPC context is the whole security argument in one line. - **An honest status line.** Phase 1 of 8, no NPCs yet, not playable. A README that oversells is worse than one in the wrong language. - **Roadmap as a record of what exists**, with a note that LangGraph and Next.js arrive with the phases that need them — they are not in the repository today and the README should not imply they are. Repository description and topics set too, since that is what appears in search and on the profile. ## Not here A terminal GIF. The code block is real output and works everywhere, including in a text-mode reader; a recording can replace it later without changing the surrounding copy. The Portuguese design documents stay Portuguese. They are the documents almost nobody opens, and translating them is two days better spent elsewhere — the ADRs are the ones worth translating, and that is the next PR.
Madeuss
added a commit
that referenced
this pull request
Aug 29, 2026
Point 4, closing the internationalisation sequence started in #12. ## Four, not three ADR-0006 made English the language of decision records from that point forward, which left **four** Portuguese documents behind, not three — 0004 predates the rule by a few hours. They are also the ones a reader opens first. | ADR | | |---|---| | 0001 | One repository for app, infrastructure, evals and docs | | 0002 | Postgres with pgvector instead of a dedicated vector database | | 0003 | Python/FastAPI for the AI core, Next.js as the BFF | | 0004 | Case structure generated by code, veneer generated by a model | Filenames follow the content. Meeting `0003-python-fastapi-no-core-de-ia-e-next-como-bff.md` while browsing a directory of English documents is exactly the half-migrated look ADR-0006 argues against. All references updated. ## Two things found on the way **A broken link.** `docs/02-regras-de-negocio.md` pointed at `05-threat-model.md`, which does not exist yet. It was marked "(a escrever)" in the text, but it was still a 404 for anyone who clicked it. **So there is now a test for that.** `test_docs_links.py` walks every Markdown file in the repository and asserts every relative link resolves. It lives in the API suite deliberately: that is the only runner CI has, so it rides the existing `test` check instead of adding another required one. It also asserts the glob found documents at all — a glob that silently matches nothing makes every other assertion pass. In a repository where the documentation is part of what is being judged, a dead link is small and reads as carelessness. ## One content change, not just translation ADR-0002 gained a consequence discovered when the stack first came up: the container image ships pgvector **0.8.6** while the managed instance is on **0.8.2**. A new index feature has to be checked against production before it is used. ``` 48 passed · ruff clean · mypy strict: 12 files, no issues ``` ## What stays in Portuguese The design documents — plan, domain, business rules. They are the ones almost nobody opens, the glossary maps both vocabularies, and the two days are better spent on the game.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up promised in #11, before the LLM veneer and before any case is
persisted — the two events that would have made this expensive.
Two ADRs, one commit, because they touch the same lines:
ADR-0005 (structure, not
prose) and ADR-0006
(English identifiers).
The symptom
Portuguese grammar living inside the case generator. English contracts nothing;
German would want three articles. That map was the proof the domain had a
language baked in.
What changed
Fato.descricao= a finished sentenceFact= kind + slots, no prose"biblioteca"as room name and id"library"id, display name per localeINTERVALOS = ("21h00", ...)Catalogs live in
apps/api/src/mansao/i18n/messages/, shipping pt-BR and enfrom day one — a second locale is the only way to prove the first one is
separable.
What it bought for free
The solver, the validator and the scoring path already reasoned over structured
fields instead of prose (RN-021, RN-022). No sentence is parsed anywhere in the
deduction path, so all of it became locale-independent at zero cost. Had any
of them read text, every new language would have multiplied that work.
Canary filtering is untouched: a canary is a token, not a language (RN-012).
Same seed, two locales
Same culprit, same room, same interval. Only the prose and the clock change —
and the name stays a name, because a Brazilian manor keeps Brazilian names in
every locale.
The test that will actually save someone
test_every_key_the_generator_emits_exists_in_every_catalog— adding a room ora secret and forgetting the translation is the normal way i18n rots. It now
fails in CI rather than in front of a player.
The cost recorded in ADR-0005
Injection resistance varies by language. A jailbreak that fails in
Portuguese can succeed in English, because models are unevenly trained across
languages. The adversarial golden sets (RN-040) will need real per-language
cases, not machine translations of the Portuguese set, and every locale
multiplies eval cost. Plan: blocking gate on the primary locale per PR, full
matrix nightly.
Breaking
Every domain identifier changed. Nothing consumes the API yet, so the blast
radius is this repository.