<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Vadym Arnaut</title>
    <description>The latest articles on Forem by Vadym Arnaut (@arvavit).</description>
    <link>https://forem.com/arvavit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896990%2Ff229c67f-46a1-4ecf-b3f5-71e2dd14f1bc.jpg</url>
      <title>Forem: Vadym Arnaut</title>
      <link>https://forem.com/arvavit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/arvavit"/>
    <language>en</language>
    <item>
      <title>I wrote 'these fields are translatable' in five different files. Then I stopped.</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sun, 10 May 2026 15:33:29 +0000</pubDate>
      <link>https://forem.com/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7</link>
      <guid>https://forem.com/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7</guid>
      <description>&lt;h2&gt;
  
  
  The same fact, written in five places
&lt;/h2&gt;

&lt;p&gt;I'm building an LMS where teachers write courses in one language and students read them in another. So content i18n. Standard problem.&lt;/p&gt;

&lt;p&gt;What surprised me was how many places "this field gets translated" had to live.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;        &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(...))&lt;/span&gt;
&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;schemas&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;          &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;"course"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;           &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[...]]&lt;/span&gt;
&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;walker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nv"&gt;"course"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;announcements&lt;/span&gt;   &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="n"&gt;reconcile_entity&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of these is its own commit, its own PR, its own author. They drift independently. You add a new translatable entity in March, you forget one of the five, and a Russian student reads English in their dashboard until somebody reports it.&lt;/p&gt;

&lt;p&gt;I shipped that bug twice in two months before I got tired of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bug looks like in practice
&lt;/h2&gt;

&lt;p&gt;Concretely: I added an &lt;code&gt;Announcement&lt;/code&gt; table that has translatable &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;. Things I had to update:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Postgres &lt;code&gt;CHECK&lt;/code&gt; constraint to add &lt;code&gt;'announcement'&lt;/code&gt; to the allowed &lt;code&gt;entity_type&lt;/code&gt; values. &lt;em&gt;Forgot this one.&lt;/em&gt; All inserts started failing with &lt;code&gt;IntegrityError&lt;/code&gt;. Took two days to notice because the route swallowed the exception and just logged.&lt;/li&gt;
&lt;li&gt;The Pydantic &lt;code&gt;EntityType = Literal[...]&lt;/code&gt; so the API contract types match.&lt;/li&gt;
&lt;li&gt;The SQLAlchemy &lt;code&gt;Mapped[Literal[...]]&lt;/code&gt; on the &lt;code&gt;ContentTranslation&lt;/code&gt; row.&lt;/li&gt;
&lt;li&gt;The tree walker that, when a course is published, decides what to translate. Had to add a new branch for announcements.&lt;/li&gt;
&lt;li&gt;The per-entity hook on the announcement-create route. Every other write route already called &lt;code&gt;reconcile_entity()&lt;/code&gt; after commit. This one didn't, so edits silently never propagated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five places. Each one is a single line or a single function call. None of them is hard. The problem is that &lt;strong&gt;the knowledge of "this entity is translatable" is duplicated&lt;/strong&gt;. Every duplicate is a chance to drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  One registry, five readers
&lt;/h2&gt;

&lt;p&gt;Collapse the duplication into a declarative registry. Every layer reads from it. Adding a new entity is one entry plus one migration, end of story.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/app/services/translation/registry.py
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                     &lt;span class="c1"&gt;# logical name in our API
&lt;/span&gt;    &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                   &lt;span class="c1"&gt;# column in DB
&lt;/span&gt;    &lt;span class="n"&gt;model_attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="c1"&gt;# ORM attr if it differs
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
    &lt;span class="n"&gt;resolve_course&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;Course&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;build_context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Course&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;resolve_course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;build_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;_co&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Course title for «&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;»&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;announcement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;resolve_course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_resolve_course_via_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;build_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;ann&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Announcement on course «&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;»&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# ... seven more entries
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;all_entity_types&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same five layers now read from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pydantic schema&lt;/strong&gt; declares the &lt;code&gt;Literal&lt;/code&gt; once, by hand, in the module that other layers import from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/app/schemas/protocol.py
&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;module&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chapter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chapter_block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz_question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz_option&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assignment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;announcement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cohort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the only manual list. A static test asserts it stays in lockstep with the registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_pydantic_literal_matches_registry&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_args&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;all_entity_types&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SQLAlchemy model&lt;/strong&gt; imports the same &lt;code&gt;EntityType&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Walker:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;collect_translatable_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entities_for_course&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Per-entity hook:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reconcile_entity_if_course_published&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;course&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve_course&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;run_translation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Migration:&lt;/strong&gt; still by hand, but it's the only place that doesn't auto-update. So we test it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_postgres_check_constraint_matches_registry&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_declared_entity_types_from_migration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;all_entity_types&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Migration CHECK constraint and registry are out of sync. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing in migration: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stale in migration: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now adding a new translatable entity is &lt;strong&gt;one new &lt;code&gt;EntityRegistration&lt;/code&gt; plus one migration that adds the value to the CHECK&lt;/strong&gt;. Both visible side by side in the same PR. The other three layers fix themselves at import time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI guard that makes drift impossible
&lt;/h2&gt;

&lt;p&gt;Even with the registry, one vulnerability remains. A new write route that mutates a registered entity but never calls the reconcile hook. Routes are individual files. There's nothing structural to stop the omission.&lt;/p&gt;

&lt;p&gt;The fix is a static test that introspects every FastAPI route at import time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TRANSLATION_HOOK_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reconcile_entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reconcile_entity_if_course_published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_course_translation_pipeline_if_published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_writes_on_translatable_entity_call_a_hook&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every POST/PUT/PATCH whose path or body schema mentions a
    translatable entity must reference one of the canonical reconcile
    hooks somewhere in its source file.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;APIRoute&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_route_touches_translatable_entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmodule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TRANSLATION_HOOK_NAMES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;These write endpoints touch a translatable entity but never call &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a reconcile hook:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inverse rule for read endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_reads_returning_translatable_schemas_accept_language&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every GET that returns a translatable response schema must
    declare an Accept-Language parameter so the locale overlay applies.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are plain pytest. No real database, no live HTTP, no test client. They use FastAPI's &lt;code&gt;app.routes&lt;/code&gt; plus &lt;code&gt;inspect.getsource&lt;/code&gt;. They fail the build in under one second.&lt;/p&gt;

&lt;p&gt;The first time I ran them they immediately surfaced two real holes: the announcement-create route and the course-event-create route. Both had been merged for weeks. No user had hit them yet because both routes were teacher-only and had low traffic, but they would have failed the moment a teacher published an announcement on an EN-locale course with RU students enrolled.&lt;/p&gt;

&lt;p&gt;I added a &lt;code&gt;KNOWN_VIOLATIONS&lt;/code&gt; set to the test (cite the follow-up PR or issue, no silent skipping), fixed both routes the same hour, and emptied the set. The set has stayed empty since.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pattern actually buys
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Adding a translatable entity is now a five-line PR.&lt;/strong&gt; One registry entry, one migration line. CI catches anything you forgot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures are loud, not silent.&lt;/strong&gt; The CI guard names the offending route. There is no "huh, why doesn't translation work for this endpoint" debugging session anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The registry doubles as documentation.&lt;/strong&gt; Want to know what gets translated? Read one file. There is no &lt;code&gt;git grep&lt;/code&gt; archaeology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern generalizes.&lt;/strong&gt; Anywhere a single fact about your domain has to be repeated across multiple layers, the same shape applies. Audit logging. RLS. Soft delete. Encryption. Each one is a registry plus a static test that introspects routes or models and demands they reference the canonical hook.&lt;/p&gt;

&lt;p&gt;That last bit is what made me write this post. The translation pipeline was the prompt, but the pattern is not about translation. It is about &lt;strong&gt;rejecting the idea that drift between layers is something you live with&lt;/strong&gt;. You don't have to. Static introspection of your own code is dirt cheap and almost nobody does it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;p&gt;The full implementation is in a small open-source LMS at &lt;a href="https://github.com/ArVaViT/biblie-school" rel="noopener noreferrer"&gt;github.com/ArVaViT/biblie-school&lt;/a&gt;. Registry is &lt;code&gt;backend/app/services/translation/registry.py&lt;/code&gt;. CI guard is &lt;code&gt;backend/tests/test_translation_coverage.py&lt;/code&gt;. MIT-licensed, contributors welcome if any of this resonates.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>python</category>
      <category>fastapi</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Don't trust the LLM with scripture: a canonical-text substitution layer for Bible quotes</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sun, 10 May 2026 15:12:31 +0000</pubDate>
      <link>https://forem.com/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg</link>
      <guid>https://forem.com/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I'm building &lt;a href="https://github.com/ArVaViT/biblie-school" rel="noopener noreferrer"&gt;biblie-school&lt;/a&gt;, an open-source LMS for Bible schools. The product is bilingual (Russian and English) and most teacher-authored content goes through a Gemini-backed translation pipeline.&lt;/p&gt;

&lt;p&gt;For 95% of the content this is fine. Course titles, chapter prose, quiz questions, announcements, the LLM does an honest job and the worst that happens is a slightly clumsy turn of phrase.&lt;/p&gt;

&lt;p&gt;For Bible quotes it isn't fine. At all.&lt;/p&gt;

&lt;p&gt;A teacher writes a Russian course quoting Acts 1:8 from the Synodal translation, the canonical Russian-language text since 1876. An English-locale student reads it. The expected behaviour is that they see Acts 1:8 in the King James Version, the canonical English-language text since 1769. &lt;strong&gt;Not Gemini's interpretation of the Synodal text.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This isn't a stylistic preference. KJV and Synodal are the texts these communities cite, memorize, and study from. A model paraphrase, even a "good" one, breaks the contract: students need to read the same wording their pastor will quote on Sunday. And every LLM, including the strongest ones, paraphrases scripture. Sometimes subtly, sometimes egregiously.&lt;/p&gt;

&lt;p&gt;The naive fix is a prompt rule: "Bible verses must be preserved verbatim." It does not work. The model still paraphrases, especially across languages where there is no direct passthrough. So we built something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint
&lt;/h2&gt;

&lt;p&gt;Every quote we care about is one of two public-domain corpora:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;King James Version (1769)&lt;/strong&gt; for English. ~31,103 verses, ~4.5 MB as flat JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synodal (1876)&lt;/strong&gt; for Russian. ~30,111 verses, ~6.1 MB as flat JSON.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both bundle into the backend. They never change. They are the source of truth for any rendered Bible quote in either locale.&lt;/p&gt;

&lt;p&gt;The only problem is figuring out, for any given chunk of teacher-authored HTML, which substrings are quotes that need this canonical-text treatment, and what verse exactly each one is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The pipeline runs in three steps around each Gemini call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTML in source locale
        |
        v
  pre_substitute(html, source_locale)
   - find &amp;lt;blockquote&amp;gt; + reference pairs
   - confirm canonical via similarity match
   - swap verse text for VERSE_&amp;lt;hex&amp;gt; marker
        |
        v
   markered HTML  -----&amp;gt;  Gemini translate  -----&amp;gt;  translated HTML
                                                            |
                                                            v
                                              post_substitute(html, subs, target_locale)
                                               - replace each marker with
                                                 the canonical target-locale verse
                                               - localize the (Acts 1:8) reference too
                                                            |
                                                            v
                                                  HTML in target locale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. Detect
&lt;/h3&gt;

&lt;p&gt;We walk every &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; in the HTML. Two layouts in real-world content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- A: reference inside the blockquote (most Synodal-style citations) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;...verse text... (Деян. 20:28).&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- B: reference outside, immediately after &amp;lt;/blockquote&amp;gt; --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;...verse text...&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt; (Acts 1:8).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We try the inside layout first, fall back to a 120-character lookahead window after the closing tag. The reference parser is a regex built from a 66-book canonical alias list (Matthew / Matt. / Mt. / Матфей / Мф. / Матф. / etc.) so a sloppy book pattern doesn't accidentally swallow surrounding prose like "See Acts" as a book name.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Confirm
&lt;/h3&gt;

&lt;p&gt;Detection alone isn't enough. The author might have paraphrased the verse themselves, or written commentary, or quoted only part of the verse. We don't want to "correct" intentional paraphrases.&lt;/p&gt;

&lt;p&gt;So for every detected blockquote+reference pair, we look up the canonical text in the source locale (Synodal for &lt;code&gt;ru&lt;/code&gt;, KJV for &lt;code&gt;en&lt;/code&gt;) and compare it to the author's text using &lt;code&gt;difflib.SequenceMatcher&lt;/code&gt;. If similarity is at least 0.80, this is a real canonical quote and gets the substitution treatment. Below 0.80, we leave it alone and the LLM handles it under the existing "leave verses untouched" prompt rule (a fallback, not the main mechanism).&lt;/p&gt;

&lt;p&gt;We tested the threshold empirically on real course content. Author copy-pastes of Synodal hit 0.95 and above. Paraphrases land below 0.6. The 0.80 threshold tolerates minor punctuation differences (em-dashes, smart quotes, ё vs е normalization) without false-matching a paraphrase.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Substitute
&lt;/h3&gt;

&lt;p&gt;When we accept a quote, we replace the verse text with a marker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VERSE_a1b2c3d4e5f6g7h8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Constraints this marker satisfies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain ASCII.&lt;/strong&gt; An earlier version used Unicode Private-Use Area characters as fences. They were invisible in the editor and silently broke ASCII assertions in tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres-TEXT-safe.&lt;/strong&gt; The first prototype used NUL-byte fences (&lt;code&gt;\x00...\x00&lt;/code&gt;). Postgres &lt;code&gt;TEXT&lt;/code&gt; rejects NUL. The translated marker came back stripped, leaving raw &lt;code&gt;VERSE_&amp;lt;hex&amp;gt;&lt;/code&gt; substrings visible to students. Took an embarrassing prod inspection to catch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identifier-shaped.&lt;/strong&gt; This matters because Gemini's "preserve placeholders verbatim" prompt rule applies to identifier-shaped tokens. &lt;code&gt;VERSE_a1b2c3d4e5f6g7h8&lt;/code&gt; reads as a placeholder. &lt;code&gt;≪V≫&lt;/code&gt; does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Random hex suffix.&lt;/strong&gt; Multiple verses in one document each get a unique marker so substitutions round-trip independently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also extend the marker leftwards through the opening parenthesis of the reference if there is one, so we don't leave a stray &lt;code&gt;(&lt;/code&gt; inside the marker-replaced verse text. The reference notation itself, &lt;code&gt;(Деян. 20:28).&lt;/code&gt;, is preserved as-is in the markered HTML and survives translation untouched (parens-with-digits looks like data to the model).&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Restore
&lt;/h3&gt;

&lt;p&gt;After Gemini returns the translated HTML, &lt;code&gt;post_substitute&lt;/code&gt; walks the substitution list and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replaces each marker with the canonical target-locale verse (&lt;code&gt;canonical_target = lookup(ref, target_locale)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Falls back to the original source-locale text when the target lookup misses (e.g. a verse the bundled JSON happens to lack), better than leaving a marker visible.&lt;/li&gt;
&lt;li&gt;Rewrites the surviving reference notation to the target locale's conventional short form. &lt;code&gt;(Acts 1:8)&lt;/code&gt; becomes &lt;code&gt;(Деян. 1:8)&lt;/code&gt;. &lt;code&gt;(Матф. 28:19)&lt;/code&gt; becomes &lt;code&gt;(Matt. 28:19)&lt;/code&gt;. The book-name display table is keyed by the same canonical slug as the alias parser, so adding a new book is one row in two places.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it looks like in production
&lt;/h2&gt;

&lt;p&gt;Russian-locale teacher writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Завершающее повеление Иисуса ученикам:&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;«Итак идите, научите все народы, крестя их во имя
Отца и Сына и Святаго Духа» (Матф. 28:19).&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;English-locale student reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;The final command of Jesus to His disciples:&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;Go ye therefore, and teach all nations, baptizing them in the
name of the Father, and of the Son, and of the Holy Ghost: (Matt. 28:19).&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The blockquote's verse text is the canonical KJV verbatim, not Gemini's interpretation of the Synodal.&lt;/li&gt;
&lt;li&gt;The reference is &lt;code&gt;(Matt. 28:19)&lt;/code&gt;, not &lt;code&gt;(Матф. 28:19)&lt;/code&gt;. Gemini didn't translate the book name. Our display table did.&lt;/li&gt;
&lt;li&gt;The surrounding prose ("The final command of Jesus to His disciples:") is the LLM doing its job on the parts the LLM should be doing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Edge cases that bit us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Synodal short forms missing from the alias map.&lt;/strong&gt; First version had &lt;code&gt;матфей&lt;/code&gt;/&lt;code&gt;мф&lt;/code&gt;/&lt;code&gt;от матфея&lt;/code&gt; for Matthew but missed &lt;code&gt;Матф.&lt;/code&gt;, the most common abbreviation in actual Synodal-printed Bibles. Russian-authored content silently failed substitution and the verse leaked through Gemini paraphrased. Caught in production via Datadog RUM. Fixed by expanding aliases for every Gospel and Pauline epistle (&lt;code&gt;мар&lt;/code&gt;, &lt;code&gt;лук&lt;/code&gt;, &lt;code&gt;иоан&lt;/code&gt;, &lt;code&gt;фил&lt;/code&gt;, &lt;code&gt;1 фесс&lt;/code&gt;, &lt;code&gt;1 иоан&lt;/code&gt;, etc.) plus a contract test that asserts every canonical slug has an alias entry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reference outside the blockquote.&lt;/strong&gt; Some content puts the citation after &lt;code&gt;&amp;lt;/blockquote&amp;gt;&lt;/code&gt; instead of inside it. The first version captured the verse correctly but didn't track the outside reference, so a Russian student saw a Synodal verse next to a stray English &lt;code&gt;(Acts 1:8)&lt;/code&gt;. Fixed by storing the outside ref text on the substitution record and running the same locale rewrite on it during post.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Marker spacing.&lt;/strong&gt; The marker swallowed the trailing whitespace and closing curly quote of the blockquote, so the post-substituted output read &lt;code&gt;…canonical text.(Matt. 28:19).&lt;/code&gt; with no space. Re-introduced a single ASCII space when the tail starts with &lt;code&gt;(&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verse range references.&lt;/strong&gt; &lt;code&gt;(Acts 1:8-10)&lt;/code&gt; localizes correctly to &lt;code&gt;(Деян. 1:8-10)&lt;/code&gt; because the display formatter respects the &lt;code&gt;verse_end&lt;/code&gt; field on the ref struct. The corresponding canonical lookup joins the verses with a single space and falls back to None if any verse in the range is missing.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's interesting about this approach
&lt;/h2&gt;

&lt;p&gt;The Bible substitution layer doesn't compete with the LLM. It uses the LLM for what it's good at (translating prose, preserving HTML structure, transliterating proper nouns) and replaces the LLM where the LLM is wrong (touching canonical text). Each layer has a clean job.&lt;/p&gt;

&lt;p&gt;The same pattern applies anywhere you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small, public-domain or licensed corpus of canonical text&lt;/li&gt;
&lt;li&gt;A larger surface that needs LLM translation&lt;/li&gt;
&lt;li&gt;A reliable way to detect quotes from the corpus inside the surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples I can think of: legal contracts citing statute, scientific writing citing equations or named constants, classical literature quoting older works in their established translations. The shape is the same. Detect the canonical chunk, swap it for a placeholder, let the LLM handle the surrounding prose, restore the canonical chunk in the target locale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;All of this lives in &lt;code&gt;backend/app/services/bible/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;books.py&lt;/code&gt;: 66-book canon, alias map, per-locale display names&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;references.py&lt;/code&gt;: regex parser built from the alias list&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;store.py&lt;/code&gt;: bundled JSON loader (KJV / Synodal)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;substitution.py&lt;/code&gt;: pre/post substitute, similarity threshold, marker tokens&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data/&lt;/code&gt;: kjv-en.json (4.5 MB) + synodal-ru.json (6.1 MB), both public-domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;39 unit tests cover the alias map, the reference parser, the locale store, full round-trips for both directions, the verse-range case, and the spacing regression. The pipeline integrates into the broader translation registry which also handles non-Bible content.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ArVaViT/biblie-school" rel="noopener noreferrer"&gt;github.com/ArVaViT/biblie-school&lt;/a&gt; (MIT)&lt;/p&gt;

&lt;h2&gt;
  
  
  How you can help
&lt;/h2&gt;

&lt;p&gt;The pipeline above is one corner of a small open-source LMS for Bible schools and volunteer-run training programs. If you've worked on translation pipelines, LLM I/O hardening, or just like the idea of an LMS that respects scripture as a source of truth, the issues tab is open. Star the repo if you want to follow along.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Two weeks of building Bible School LMS in public: first contributor, bilingual content, real production bug</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sun, 10 May 2026 02:13:11 +0000</pubDate>
      <link>https://forem.com/arvavit/two-weeks-of-building-bible-school-lms-in-public-first-contributor-bilingual-content-real-11am</link>
      <guid>https://forem.com/arvavit/two-weeks-of-building-bible-school-lms-in-public-first-contributor-bilingual-content-real-11am</guid>
      <description>&lt;h2&gt;
  
  
  Two weeks ago I posted &lt;a href="https://dev.to/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod"&gt;"Open Source Bible School LMS, we need your help"&lt;/a&gt; and asked for contributors.
&lt;/h2&gt;

&lt;p&gt;Today the first community PR landed. Plus a stack of changes shipped that I think are worth sharing, both because some of them are genuinely interesting, and because it gives a concrete picture of where help would land.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ArVaViT/biblie-school" rel="noopener noreferrer"&gt;github.com/ArVaViT/biblie-school&lt;/a&gt; (MIT, looking for stars and PRs)&lt;br&gt;
Live: &lt;a href="https://biblie-school-frontend.vercel.app" rel="noopener noreferrer"&gt;biblie-school-frontend.vercel.app&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The first community PR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kushalbg-06" rel="noopener noreferrer"&gt;Kushal&lt;/a&gt; picked up a &lt;code&gt;good first issue&lt;/code&gt; (a floating scroll-to-top button), opened a PR, took a code review without taking it personally, pushed clean follow-up commits, and got merged the same day. That sounds small. For a tiny open-source repo it is the most important kind of small.&lt;/p&gt;

&lt;p&gt;If you are reading this and have never opened a PR on someone else's project, this is exactly the type of issue we keep around for that reason. There are more on the board.&lt;/p&gt;
&lt;h2&gt;
  
  
  What shipped: bilingual content (RU and EN)
&lt;/h2&gt;

&lt;p&gt;The biggest piece of work in these two weeks was the translation pipeline. Goal: a teacher writes a course in their own language, students read it in theirs, no UI dropdown, no "main language", no humans needed in the loop.&lt;/p&gt;

&lt;p&gt;How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Every teacher-authored field (course title, module, chapter, rich-text block, quiz, assignment, announcement, calendar event, cohort) is registered once in &lt;code&gt;backend/app/services/translation/registry.py&lt;/code&gt;. The registry is the single source of truth for what gets translated and how. Adding a new translatable entity is one entry plus a Postgres &lt;code&gt;CHECK&lt;/code&gt; constraint update.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On publish (and on per-entity edits to a published course) a hook walks the registry, hashes the source text, and calls Gemini for any field whose hash changed. Result is cached in &lt;code&gt;public.content_translations&lt;/code&gt; keyed by &lt;code&gt;(entity_type, entity_id, field, locale)&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The student's &lt;code&gt;Accept-Language&lt;/code&gt; header drives an overlay layer at read time. The catalog, the chapter view, the certificate, all of it returns the locale the user asked for, falling back to the source.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A static CI guard introspects every FastAPI route. If a GET that returns a translatable schema is missing &lt;code&gt;Accept-Language&lt;/code&gt;, or a POST/PUT/PATCH on a translatable entity does not reference one of the canonical translation hooks, the build fails. That sounds aggressive but it caught two real regressions during development.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The interesting part: Bible quotes do not go through the LLM
&lt;/h2&gt;

&lt;p&gt;Letting an LLM "translate" scripture is a bad idea. Even the best models paraphrase. KJV and Synodal are public-domain canonical texts and students need to read the canonical wording, not a model's interpretation.&lt;/p&gt;

&lt;p&gt;So the pipeline pre-substitutes around the LLM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The translator detects &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; plus a parenthesised reference like &lt;code&gt;(Acts 1:8)&lt;/code&gt; or &lt;code&gt;(Деян. 20:28)&lt;/code&gt;, in the inside-the-quote layout and the outside-the-quote layout.&lt;/li&gt;
&lt;li&gt;It compares the author's quoted text to the bundled canonical source-locale verse using &lt;code&gt;SequenceMatcher&lt;/code&gt;. If similarity is at least 0.80, it is a real canonical quote and gets replaced with an ASCII marker like &lt;code&gt;VERSE_a1b2c3d4e5f6g7h8&lt;/code&gt; before the request goes to Gemini.&lt;/li&gt;
&lt;li&gt;After translation, the marker is replaced with the canonical target-locale verse from a 4.5 MB KJV (1769) JSON or a 6.1 MB Synodal (1876) JSON, both bundled.&lt;/li&gt;
&lt;li&gt;The reference itself, &lt;code&gt;(Acts 1:8)&lt;/code&gt;, is also rewritten in the target locale's conventional short form, &lt;code&gt;(Деян. 1:8)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Below the 0.80 similarity threshold the substitution skips and the LLM handles the quote with a "leave verses untouched" prompt rule as a fallback.&lt;/p&gt;

&lt;p&gt;There are 66 books in the canonical Protestant canon, each with a list of recognised aliases (including Synodal abbreviations like &lt;code&gt;Матф.&lt;/code&gt; and &lt;code&gt;Деян.&lt;/code&gt; that the first version of the parser quietly missed). All 66 are in regression tests.&lt;/p&gt;
&lt;h2&gt;
  
  
  What observability caught the day we plugged it in
&lt;/h2&gt;

&lt;p&gt;Datadog RUM had been wired into the frontend for a while but I was not actively looking at it. The day I finally hooked up the API, the read endpoint immediately surfaced something useful: 10 errors across 4 sessions in 24 hours, all the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Failed to fetch dynamically imported module:
    .../assets/ChapterView-DYS-mrkM.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the classic Vite SPA stale-chunk failure. After a deploy, every open tab is still holding the old &lt;code&gt;index.html&lt;/code&gt; whose &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag references chunk hashes the new build no longer publishes. The next lazy-route navigation throws this error. The previous behaviour was to show a generic "Something went wrong" page with a manual "Refresh" button. Most users would close the tab and never come back.&lt;/p&gt;

&lt;p&gt;Fix is a few lines in the &lt;code&gt;ErrorBoundary&lt;/code&gt;: detect the chunk-load signature (Vite's, webpack's, and the named &lt;code&gt;ChunkLoadError&lt;/code&gt; all have different messages), do a single &lt;code&gt;window.location.reload()&lt;/code&gt;, guard against loops with a 60 second cooldown in &lt;code&gt;sessionStorage&lt;/code&gt;. Reloading fetches the fresh &lt;code&gt;index.html&lt;/code&gt; and the user is back where they were.&lt;/p&gt;

&lt;p&gt;The point is not the bug. The point is that I would not have known about this without the telemetry. CI was green. Everything looked healthy. Real users were silently churning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else moved (compressed list)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Auth callback page is fully translated now. Russian users no longer see English during email confirmation or password reset.&lt;/li&gt;
&lt;li&gt;Course detail page is fully translated, including the admin "manage course" flow that previously hid the enroll button.&lt;/li&gt;
&lt;li&gt;OpenGraph and Twitter cards added so links to courses unfurl properly when pasted into Slack, Telegram, X, LinkedIn.&lt;/li&gt;
&lt;li&gt;Backend favicon is now a real dark-variant of the brand glyph instead of an empty 204.&lt;/li&gt;
&lt;li&gt;Internal cleanups: 30+ hardcoded English strings routed through i18n, repo stripped of editor-specific tooling references so it reads neutral about how a contributor authors code, security advisor warnings cleared.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where contributors fit in
&lt;/h2&gt;

&lt;p&gt;Same answer as two weeks ago, with sharper edges:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you like...&lt;/th&gt;
&lt;th&gt;Look at...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React + TypeScript + Tailwind&lt;/td&gt;
&lt;td&gt;the issues tagged &lt;code&gt;frontend&lt;/code&gt; and &lt;code&gt;good first issue&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python + FastAPI + Pydantic&lt;/td&gt;
&lt;td&gt;the issues tagged &lt;code&gt;backend&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accessibility&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prefers-reduced-motion&lt;/code&gt; is missing in a few animated bits, focus management in modals could be tighter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n&lt;/td&gt;
&lt;td&gt;adding a third locale would mean one new entry in the registry, one new &lt;code&gt;_translation&lt;/code&gt; JSON, one new bundled Bible (or none if the language can fall back to a sibling). The pipeline is built to scale here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;"I tried to set this up on macOS / Linux and these were the snags" stories help more than you would think&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;Playwright E2E for the student happy path is on the roadmap and unstarted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bar to contribute is not "do not break anything". The bar is "open a draft PR with a question, talk it through, push some commits, get reviewed". Kushal did exactly that in a few hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line again
&lt;/h2&gt;

&lt;p&gt;Free LMS, small scale, real classrooms, with a translation pipeline that does not paraphrase scripture. Star the repo, pick an issue, ping me anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ArVaViT/biblie-school" rel="noopener noreferrer"&gt;github.com/ArVaViT/biblie-school&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading. See you in the issues tab.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Open Source Bible School LMS — we need your help (React, FastAPI, Supabase)</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sat, 25 Apr 2026 05:01:11 +0000</pubDate>
      <link>https://forem.com/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod</link>
      <guid>https://forem.com/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod</guid>
      <description>&lt;h2&gt;
  
  
  Why I’m writing this
&lt;/h2&gt;

&lt;p&gt;Small Bible schools, home groups, and volunteer-run training programs still run classes on &lt;strong&gt;paper, messengers, and spreadsheets&lt;/strong&gt;. Big LMS products are either &lt;strong&gt;too expensive&lt;/strong&gt; or &lt;strong&gt;too heavy&lt;/strong&gt; for a team that has no DevOps and no budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bible School LMS&lt;/strong&gt; is a &lt;strong&gt;free, open-source&lt;/strong&gt; learning platform built for that reality: &lt;strong&gt;tens to low hundreds of users&lt;/strong&gt;, not “enterprise 10k seats”.&lt;/p&gt;

&lt;p&gt;We’re on &lt;strong&gt;&lt;a href="https://github.com/ArVaViT/biblie-school" rel="noopener noreferrer"&gt;GitHub (MIT)&lt;/a&gt;&lt;/strong&gt; and we’d love &lt;strong&gt;contributors&lt;/strong&gt; — code, UI, docs, accessibility, and ideas.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it does today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Courses&lt;/strong&gt; → modules → chapters with a &lt;strong&gt;TipTap&lt;/strong&gt; rich editor (text, images, YouTube, callouts, audio).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quizzes&lt;/strong&gt; — multiple choice, true/false, short answer, essay; teacher grading and extra attempts when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assignments&lt;/strong&gt; + &lt;strong&gt;gradebook&lt;/strong&gt;, &lt;strong&gt;progress&lt;/strong&gt;, &lt;strong&gt;certificates&lt;/strong&gt; (with approval flow).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teacher &amp;amp; admin&lt;/strong&gt; tools: cohorts, calendar, announcements, analytics, soft-delete / restore, CSV export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt; via &lt;strong&gt;Supabase&lt;/strong&gt; (email/password + Google).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: &lt;strong&gt;Vercel&lt;/strong&gt; (static frontend + serverless &lt;strong&gt;FastAPI&lt;/strong&gt; backend), &lt;strong&gt;Postgres&lt;/strong&gt; on Supabase with &lt;strong&gt;RLS&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://biblie-school-frontend.vercel.app" rel="noopener noreferrer"&gt;biblie-school-frontend&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Roadmap:&lt;/strong&gt; in the repo → &lt;code&gt;ROADMAP.md&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;How to contribute:&lt;/strong&gt; &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Stack (if you like concrete tech)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;React 18, TypeScript, Vite, Tailwind, shadcn/ui, TipTap, Radix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Python 3.12, FastAPI, SQLAlchemy 2, Pydantic 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;PostgreSQL (Supabase), migrations in &lt;code&gt;supabase/migrations/*.sql&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;GitHub Actions — lint, typecheck, tests (backend + frontend)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We keep &lt;strong&gt;conventional commits&lt;/strong&gt;, &lt;strong&gt;no Docker&lt;/strong&gt; in the project by design, and we care about &lt;strong&gt;types&lt;/strong&gt; (mypy + strict TypeScript) and a &lt;strong&gt;clean&lt;/strong&gt; codebase.&lt;/p&gt;




&lt;h2&gt;
  
  
  How you can help (no need to be a “10×” developer)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good first issues&lt;/strong&gt; — filter by &lt;code&gt;good first issue&lt;/code&gt; on GitHub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI/UX&lt;/strong&gt; — especially accessibility and a calmer, “editorial” reading experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs&lt;/strong&gt; — setup stories from real machines (Windows / macOS / Linux) help a lot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n&lt;/strong&gt; — the product UI is largely &lt;strong&gt;Russian&lt;/strong&gt; today; if you care about &lt;strong&gt;internationalization&lt;/strong&gt;, that’s a meaningful roadmap area.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open a &lt;strong&gt;draft PR&lt;/strong&gt;, ask in an &lt;strong&gt;issue&lt;/strong&gt;, or &lt;strong&gt;fork&lt;/strong&gt; and experiment — we’re building in public and want this to stay &lt;strong&gt;approachable&lt;/strong&gt; for nonprofits and volunteers.&lt;/p&gt;




&lt;h2&gt;
  
  
  One line pitch
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free LMS, small scale, real classrooms — if that resonates, star the repo and pick an issue.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Thanks for reading — hope to see you in the issues tab 🤝&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
  </channel>
</rss>
