Skip to content

feat(api)!: keep language out of the domain - #12

Merged
Madeuss merged 1 commit into
mainfrom
feat/language-neutral-domain
Aug 29, 2026
Merged

feat(api)!: keep language out of the domain#12
Madeuss merged 1 commit into
mainfrom
feat/language-neutral-domain

Conversation

@Madeuss

@Madeuss Madeuss commented Aug 29, 2026

Copy link
Copy Markdown
Owner

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

PREPOSICAO = {"adega": "na", "porão": "no", ...}

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

Before After
Fato.descricao = a finished sentence Fact = kind + slots, no prose
"biblioteca" as room name and id "library" id, display name per locale
INTERVALOS = ("21h00", ...) minutes on a clock the case defines
secret text inline in the generator message key resolved by the catalog
grammar in a Python dict grammar in the locale that needs it

Catalogs live in apps/api/src/mansao/i18n/messages/, shipping pt-BR and en
from 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

en     Rodolfo Andrade's body was found in the conservatory, around 9:30 pm.
       culprit: Vitória Belmiro · means: a bronze candlestick

pt-BR  O corpo de Rodolfo Andrade foi encontrado no jardim de inverno, por volta das 21h30.
       culpado: Vitória Belmiro · meio: castiçal de bronze

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 or
a 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.

34 passed · ruff clean · mypy strict: 12 files, no issues

@Madeuss Madeuss self-assigned this Aug 29, 2026
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
Madeuss merged commit aefb513 into main Aug 29, 2026
4 checks passed
@Madeuss
Madeuss deleted the feat/language-neutral-domain branch August 29, 2026 21:52
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.
@Madeuss Madeuss linked an issue Aug 30, 2026 that may be closed by this pull request
@Madeuss Madeuss added this to the Phase 1 — Case generator milestone Aug 30, 2026
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.

E1 — Generate a playable case

1 participant