Feature: Article language, story and per-site language storage Articles gain a full BCP 47 language tag, a story they belong to and a content revision. Each site gets a language table with one default. Existing articles keep their ids, bodies, publication and public API. They are migrated without a guessed language: each gets its own story and an unassigned language until the owner confirms the site default. Background: Given the supported languages are "en", "en-GB", "en-US", "fr", "fr-CA", "pt", "pt-BR", "es", "de", "ar" And a language tag keeps its region, "pt-BR" is never reduced to "pt" And the domain package internal/domain/language imports only the stdlib And persistence is portable across SQLite, MySQL and PostgreSQL Scenario Outline: Language tag normalization When the tag "" is normalized Then the result is "" Examples: | input | result | | en | en | | EN | en | | pt-br | pt-BR | | PT_BR | ErrInvalidLanguage | | fr-ca | fr-CA | | fr | fr | | e | ErrInvalidLanguage | | english | ErrInvalidLanguage | | en-ZZZZ | ErrInvalidLanguage | | | ErrInvalidLanguage | | xx | ErrUnsupportedLanguage | Scenario Outline: Suggestion for a site's default language from Site.Locale Given a site whose Locale is "" When the default language suggestion is computed Then the suggestion is "" and the substitution flag is Examples: | locale | suggested | substituted | | pt-BR | pt-BR | false | | en-GB | en-GB | false | | fr-BE | fr | true | | fr | fr | false | | xx-YY | none | false | | null | none | false | Scenario: Migration of an existing site and its articles Given a database created before this change with site "default" (Locale "fr-CA") and articles 1, 2 and 3 in it, article 3 online When database.Migrate runs Then every article keeps its id, title, body, slug, is_online and timestamps And each article has lang "" and its own distinct story_id And each article has content_revision 1 And site "default" has one site_languages row "fr-CA" with is_default true, enabled true and confirmed false And the migration marker for the article story backfill is recorded Scenario: Site without a usable locale gets no suggestion row Given a site with Locale null When database.Migrate runs Then the site has no site_languages row Scenario: Migration runs twice Given a migrated database When database.Migrate runs again Then no story is created or changed, no article changes and no site_languages row changes And the schema is identical Scenario: Migration is atomic Given a database with 3 existing articles And the backfill write fails while handling the second article When database.Migrate runs Then no article has a story_id and the marker is not recorded And the next Migrate completes the whole backfill Scenario: Soft-deleted articles are migrated Given an article that is soft-deleted before the change When database.Migrate runs Then it has its own story_id and its tombstone slug is unchanged Scenario: Slug is unique per site and language Given site 1 holds an article with slug "bonjour" in language "fr" When another article is created in site 1, language "fr", slug "bonjour" Then the write fails with a unique violation mapped to ErrSlugTaken When an article with slug "bonjour" is created in site 1, language "en" Then it is stored When an article with slug "bonjour" is created in site 2, language "fr" Then it is stored Scenario: Existing unassigned articles keep the current per-site slug rule Given two unassigned articles in site 1 When the second is written with the first one's slug Then ErrSlugTaken is returned Scenario: One version per language in a story Given story 7 holds an article in language "fr" When a second article is stored in story 7, language "fr" Then the write fails with a unique violation When an article is stored in story 7, language "en" Then it is stored Scenario: Unassigned articles never collide on language Given 3 articles each with lang "" and their own story Then all 3 are stored Scenario: Creating an article before the default is confirmed Given site "default" has an unconfirmed suggestion "fr-CA" When a user creates an article with title "Bonjour le monde" Then the article has lang "" And it has a new story_id and content_revision 1 Scenario: Creating an article after the default is confirmed Given site "default" has a confirmed default "fr-CA" When a user creates an article with title "Bonjour le monde" Then the article has lang "fr-CA" And it has a new story_id distinct from every other article Scenario: Create racing a confirmation never strands an article Given site "default" has unassigned articles When the default is confirmed and assigned to unassigned articles in one transaction while articles are created concurrently Then every article of the site ends with the confirmed default And no article has a different language than the confirmed default Scenario: Assigning a language is idempotent Given a site whose unassigned articles were assigned "fr-CA" When the assignment runs again Then no article changes Scenario Outline: Article reads expose lang and story_id Given article 12 with lang "" in story 5 When it is read through "" Then the JSON contains "lang": "" and "story_id": 5 And every key present before this change is present with an unchanged value and type Examples: | lang | read | | fr-CA | GET /v1/article/12 with a valid jwt | | | GET /v1/article/12 with a valid jwt | | fr-CA | GET /v1/article/ list item | | fr-CA | public article read of an online article | Scenario: PATCH ignores lang and story_id When PATCH /v1/article/{id} carries "lang" or "story_id" Then the fields are ignored and the stored values are unchanged And POST /v1/article/ honours lang, story_id, source_article_id and copy only as defined in features/article_translation_create.feature And a POST without them behaves as before Scenario: Existing behaviour is unchanged Then the existing article, pagination, publishing and SEO tests pass without edits And offline articles still read as not found publicly Scenario: Persistence portability Given the repository contract cases above Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL And no raw SQL uses a dialect-only construct