<?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>DEV Community: Vadym Arnaut</title>
    <description>The latest articles on DEV Community by Vadym Arnaut (@arvavit).</description>
    <link>https://dev.to/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>DEV Community: Vadym Arnaut</title>
      <link>https://dev.to/arvavit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arvavit"/>
    <language>en</language>
    <item>
      <title>There is no source language: a manifesto for symmetric multilingual content</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Thu, 28 May 2026 14:50:42 +0000</pubDate>
      <link>https://dev.to/arvavit/there-is-no-source-language-a-manifesto-for-symmetric-multilingual-content-6o7</link>
      <guid>https://dev.to/arvavit/there-is-no-source-language-a-manifesto-for-symmetric-multilingual-content-6o7</guid>
      <description>&lt;p&gt;Three weeks ago I shipped a bilingual LMS. The architecture modeled one language as source and the rest as overlay: a &lt;code&gt;source_locale&lt;/code&gt; column on every translatable entity, an MT pipeline reading source rows and writing overlay rows. It worked. Students used it. Certificates issued. Three published courses, fourteen real users, end-to-end.&lt;/p&gt;

&lt;p&gt;Last week it broke in a way that did not fit a bug fix.&lt;/p&gt;

&lt;p&gt;A teacher whose interface was set to English wrote a course entirely in Russian. The system saw &lt;code&gt;teacher.preferred_locale = 'en'&lt;/code&gt;, stamped &lt;code&gt;source_locale = 'en'&lt;/code&gt; on every course field, and then served Russian students a course labelled "Russian translation of an English source" — when no English source existed, ever. The fallback path showed &lt;code&gt;[translation missing]&lt;/code&gt; placeholders on a course that was, in fact, perfectly written in the language being requested.&lt;/p&gt;

&lt;p&gt;The first fix attempt was a heuristic: detect the actual character set of the content and derive &lt;code&gt;source_locale&lt;/code&gt; from that, not from the teacher's UI. Better. Still wrong, because per-entity source locale assumes the entity is monolingual — and there is no rule that says it has to be. A course with an English title and a Russian description is one entity with no single answer to "which locale is source?"&lt;/p&gt;

&lt;p&gt;The second attempt moved detection per-field. Better still. Still wrong, because per-field source locale assumes the field is monolingual — and there is no rule that says it has to be. A bilingual paragraph (a sentence in English, a Russian phrase set off in italics, a quoted scripture reference) has no single answer either.&lt;/p&gt;

&lt;p&gt;At that point the model is the bug, not the heuristic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locale is just a column
&lt;/h2&gt;

&lt;p&gt;The new schema, shipped to &lt;code&gt;main&lt;/code&gt; last night across eight stacked PRs:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;                 &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;entity_type&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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="s1"&gt;'course'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'chapter_block'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'assignment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'cohort'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="s1"&gt;'course_event'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'announcement'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
                       &lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;entity_id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt;              &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locale&lt;/span&gt;             &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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;locale&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ru'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;               &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;origin&lt;/span&gt;             &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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;origin&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'human'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'mt'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;source_version_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;superseded_by&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;created_by&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;content_versions_active_unique&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;content_versions&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_id&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;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;superseded_by&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every &lt;code&gt;(entity, field, locale)&lt;/code&gt; is its own row. There is no source locale and no overlay. There are rows that exist because a human wrote them, rows that exist because the MT pipeline produced them, and the lineage between them. The thing that says "this Russian title was machine-translated from that English title" is &lt;code&gt;source_version_id&lt;/code&gt;. The thing that says "an editor revised it" is &lt;code&gt;superseded_by&lt;/code&gt;. The thing that says "what language is this row" is &lt;code&gt;locale&lt;/code&gt;. No row outranks another by default.&lt;/p&gt;

&lt;p&gt;The model is symmetric. The data is the data. Adding a third language is &lt;code&gt;INSERT INTO content_versions ...&lt;/code&gt; plus appending a string to a Pydantic &lt;code&gt;Literal&lt;/code&gt;. Zero DDL. No schema deltas. No "let's refactor &lt;code&gt;source_locale&lt;/code&gt; to be nullable for the Spanish migration."&lt;/p&gt;
&lt;h2&gt;
  
  
  What this fixes that you can name
&lt;/h2&gt;

&lt;p&gt;The "Тайтл" bug class disappears entirely. There is no &lt;code&gt;source_locale&lt;/code&gt; column to be wrong, so it cannot be wrong. A teacher's UI preference no longer leaks into the content model.&lt;/p&gt;

&lt;p&gt;Mixed-language entities are first-class. A course with an English title and a Russian description is two &lt;code&gt;content_versions&lt;/code&gt; rows. Each one is correct in isolation. No "what's the source locale of this course" question to argue about, because no field on the entity claims to know.&lt;/p&gt;

&lt;p&gt;Translation history is preserved by construction. Editing a row creates a new row with &lt;code&gt;superseded_by&lt;/code&gt; pointing at the old one. The previous translation is not overwritten or archived in some side table; it is still in &lt;code&gt;content_versions&lt;/code&gt;, just with a non-null &lt;code&gt;superseded_by&lt;/code&gt;. The active row is the one with &lt;code&gt;superseded_by IS NULL&lt;/code&gt;, enforced by the partial unique index above.&lt;/p&gt;

&lt;p&gt;Cascade invalidation gets precise. When the English title is edited, the MT rows that were generated from it — and only those — get invalidated, via &lt;code&gt;source_version_id&lt;/code&gt;. The old code had to &lt;code&gt;purge_course_translations(course_id)&lt;/code&gt; and re-translate everything, because the lineage was not recorded anywhere readable. The new code knows exactly which rows depend on which.&lt;/p&gt;
&lt;h2&gt;
  
  
  What stays true from before
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7"&gt;registry pattern&lt;/a&gt; still holds. The fact that an entity has translatable fields is declared once, in &lt;code&gt;backend/app/services/translation/registry.py&lt;/code&gt;, and read by the orchestrator, the schemas, and the CI guard. The schema change did not move this; it sharpened it. The registry now declares "this entity has these fields in &lt;code&gt;content_versions&lt;/code&gt;" instead of "this entity has these &lt;code&gt;_ru&lt;/code&gt;/&lt;code&gt;_en&lt;/code&gt; overlay columns."&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg"&gt;Bible substitution layer&lt;/a&gt; still holds. Canonical text — KJV, Synodal, anything where the wording is a contract with the reader — never goes through the LLM. The MT pipeline substitutes placeholders, translates the prose around them, restores canonical target-locale text. The new model did not touch this. It could not; canonical text is not translated content. It is content that happens to exist in multiple locales because the canon exists in multiple locales. The substitution layer just makes sure the canonical version is the one that ships.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk"&gt;three-layer split&lt;/a&gt; still holds. UI strings, user-generated content, canonical artifacts — three problems, three mechanisms, one product. The new model only touches the middle layer. The other two were already right.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principle, generalized
&lt;/h2&gt;

&lt;p&gt;The "source language" idea is a residue of monolingual-first design. Pick almost any i18n library, almost any LMS, almost any CMS, and you will find the same implicit assumption: one language is canonical, the rest are derived. Defaults, fallbacks, base locales, primary languages, source-of-truth columns. They all encode the same idea — &lt;em&gt;somebody's language is real, everybody else's is a copy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For documentation, that may be honest. The MDN reference is in English; the French version is a translation of the English version; calling English the source is correct because it is. For a product that serves bilingual or multilingual users as equally first-class — a faith community, an immigrant network, a multilingual school — the assumption is wrong, and it leaks. Every dropdown that says "EN (Original)", every &lt;code&gt;fallback_locale: 'en'&lt;/code&gt; in a config file, every test that asserts the English string and ignores the Russian one, is a cultural primacy choice masquerading as engineering simplicity.&lt;/p&gt;

&lt;p&gt;The cost of refusing the assumption is real but bounded. The backfill script is ~550 lines of dual-write reconciliation. The schema is symmetric, so there is no per-language DDL. Read paths read one table, not "canonical column union overlay table." The whole migration is in flight as a six-phase stacked PR — half shipped, the rest sequenced with &lt;code&gt;DO NOT auto-merge until previous live + 7 days, take pg_dump, confirm PITR window, schedule Tue/Wed UTC&lt;/code&gt;. Boring, careful, recoverable. The principle is sharp; the rollout is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to verify you have the same bug
&lt;/h2&gt;

&lt;p&gt;Two checks. If your data model has a &lt;code&gt;source_locale&lt;/code&gt; (or &lt;code&gt;original_locale&lt;/code&gt;, or &lt;code&gt;default_locale&lt;/code&gt;) column on a translatable entity, you have a primary language. If your application config has a &lt;code&gt;fallback_locale&lt;/code&gt; and your read paths use it when a translation is missing, you have a primary language. Either is the residue. Both is the full version.&lt;/p&gt;

&lt;p&gt;The honest move is to model what is actually true: a course has content. Some of that content is in English. Some is in Russian. Some was written by a human. Some was generated by an MT pipeline from a specific human-written version. None of those facts make any language more real than any other.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bigger than i18n
&lt;/h2&gt;

&lt;p&gt;The deeper read is that the "source plus overlay" pattern is a default reach for any time you have versions of the same thing in different shapes: translations, variants, A/B copy, accessibility re-writes, plain-language summaries, audio scripts. The same model — every variant is a row, lineage is explicit, supersession is preserved, no variant is privileged by schema — generalizes cleanly past locale.&lt;/p&gt;

&lt;p&gt;If you are building anything where multiple variants of the same content need to coexist as equals, the question is not &lt;em&gt;which one is the source.&lt;/em&gt; The question is &lt;em&gt;what is the lineage, and who or what wrote each row.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your bilingual app has a fallback locale, you have already chosen which language is real.&lt;/p&gt;



&lt;p&gt;Equip is open source under MIT at &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;. Live at &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. The &lt;code&gt;content_versions&lt;/code&gt; foundation migration is &lt;code&gt;supabase/migrations/20260527230000_content_versions_foundation.sql&lt;/code&gt;. The six-phase rollout spans PRs #531 through #553 — Phase 1 (dual-write) is fully merged across all eleven translatable entity types; Phases 2 through 5 (dual-read with comparator, backfill, cv-primary read behind a flag, delete legacy + drop &lt;code&gt;content_translations&lt;/code&gt; + drop the source text columns) are sequenced as an open stack.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;br&gt;
  &lt;tbody&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
  &lt;/tr&gt;
&lt;br&gt;
  &lt;tr&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
  &lt;/tr&gt;
&lt;br&gt;
&lt;/tbody&gt;
&lt;br&gt;
&lt;/table&gt;&lt;/div&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>architecture</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The auth_rls_initplan linter has a blind spot: SECURITY DEFINER bodies</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 27 May 2026 21:46:29 +0000</pubDate>
      <link>https://dev.to/arvavit/the-authrlsinitplan-linter-has-a-blind-spot-security-definer-bodies-3hn5</link>
      <guid>https://dev.to/arvavit/the-authrlsinitplan-linter-has-a-blind-spot-security-definer-bodies-3hn5</guid>
      <description>&lt;p&gt;If you've ever migrated a Supabase project to the wrapped &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; pattern, you know the linter that flags the bare call. &lt;code&gt;auth_rls_initplan&lt;/code&gt; reads every RLS policy expression and warns whenever &lt;code&gt;auth.uid()&lt;/code&gt; is used directly instead of inside a scalar subselect. The wrap turns the call from a per-row evaluation into a single InitPlan. On a million-row table that is the difference between a 30ms read and a 30-second read.&lt;/p&gt;

&lt;p&gt;The linter is good. It is also incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't see
&lt;/h2&gt;

&lt;p&gt;The linter introspects the policy expression itself. It does not walk into the body of any function the policy calls. So this passes the linter cleanly:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
    &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
    &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
    &lt;span class="k"&gt;STABLE&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;rows_owner&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;things&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green linter. Slow query. The &lt;code&gt;(SELECT ...)&lt;/code&gt; at the policy level caches the return value of &lt;code&gt;is_owner(user_id)&lt;/code&gt;, which itself depends on the row's &lt;code&gt;user_id&lt;/code&gt; and runs every row. The auth call inside the function is back to per-row evaluation, and the InitPlan optimization the wrap was supposed to enable never kicks in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is the same wrap, one level deeper
&lt;/h2&gt;

&lt;p&gt;Move the scalar subselect inside the function body:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
    &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
    &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
    &lt;span class="k"&gt;STABLE&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function still runs per row, but the auth lookup inside it is the single InitPlan call the optimization was designed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find them
&lt;/h2&gt;

&lt;p&gt;Grep the schema for &lt;code&gt;auth.uid()&lt;/code&gt; inside function bodies, not just policies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;--schema-only&lt;/span&gt; your_db &lt;span class="se"&gt;\&lt;/span&gt;
    | rg &lt;span class="nt"&gt;-B&lt;/span&gt; 5 &lt;span class="s1"&gt;'auth\.(uid|jwt)\(\)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | rg &lt;span class="nt"&gt;-A&lt;/span&gt; 2 &lt;span class="s1"&gt;'SECURITY DEFINER'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; the slow query and look for a subplan that fires once per row instead of once per query. If it scales with rows scanned, you have one of these.&lt;/p&gt;

&lt;p&gt;The linter is a good first pass. This is the second pass to run before you call your migration done.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Equip: an open-source LMS for Bible schools — bilingual, scripture-aware, 4 weeks of real users</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Thu, 21 May 2026 00:30:31 +0000</pubDate>
      <link>https://dev.to/arvavit/equip-an-open-source-lms-for-bible-schools-bilingual-scripture-aware-4-weeks-of-real-users-bmo</link>
      <guid>https://dev.to/arvavit/equip-an-open-source-lms-for-bible-schools-bilingual-scripture-aware-4-weeks-of-real-users-bmo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Equip is a free, open-source LMS built for Bible schools and small ministries. MIT-licensed. Bilingual Russian↔English out of the box — the same course renders in either language depending on who's reading. Self-hostable end-to-end on free tiers. 4 weeks in production: 14 real users, 3 published courses, 3 issued certificates. This post is what's in it, what's wired in, and how it's built.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flzcsmuw8p04jgortir1o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flzcsmuw8p04jgortir1o.png" alt="Equip sign-in page — two-zone layout with a scripture quote on the left and a clean email + Google sign-in form on the right" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;Small Bible schools, home-church training programs, and missionary-prep tracks around the world run their coursework on paper, WhatsApp groups, or shared Google Sheets. The commercial LMSes either price out small ministries (Canvas, Blackboard), require an IT department to run (Moodle, Open edX), or fight the use case at every step. Most are built for K–12 or universities, not 30 students reading through Romans together.&lt;/p&gt;

&lt;p&gt;I wanted an LMS that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Costs &lt;strong&gt;zero&lt;/strong&gt; to run for a 20–100 student school&lt;/li&gt;
&lt;li&gt;Has &lt;strong&gt;no vendor lock-in&lt;/strong&gt; (your data, your hosting)&lt;/li&gt;
&lt;li&gt;Handles &lt;strong&gt;scripture correctly&lt;/strong&gt; — more on this below, it turned out to be the most interesting constraint&lt;/li&gt;
&lt;li&gt;Works in &lt;strong&gt;Russian and English&lt;/strong&gt; without manual per-string translation&lt;/li&gt;
&lt;li&gt;Is small enough that one nontechnical ministry admin can run it end-to-end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;Equip&lt;/a&gt;. Live at &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bilingual content, end-to-end
&lt;/h2&gt;

&lt;p&gt;This is the feature that does the most work, so it goes first.&lt;/p&gt;

&lt;p&gt;Russian and English UI ship together, but the more interesting decision is &lt;strong&gt;content&lt;/strong&gt;. A teacher writes a course in their own language. When a student of the other language enrolls, every text field on every entity (course name, module title, chapter content, quiz question, quiz option, quiz explanation, assignment prompt) is auto-translated via Gemini Flash Lite and cached per &lt;code&gt;(entity_type, entity_id, field, locale)&lt;/code&gt;. The translation runs once per field and reads from cache thereafter. Cost per course is measured in cents.&lt;/p&gt;

&lt;p&gt;The author always sees their own source-language content. The student always sees their target-language content. No "switch language to see English" toggle. No leaking source-locale strings. No &lt;code&gt;[translation missing]&lt;/code&gt; placeholders.&lt;/p&gt;

&lt;p&gt;Same &lt;code&gt;/register&lt;/code&gt; page, two locales, rendered live by Equip:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkyl7c8ngsfyzh3qgu8g2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkyl7c8ngsfyzh3qgu8g2.png" alt="Side-by-side English and Russian register pages: same URL, same component tree, locale-driven content swap including the scripture passage in the left rail" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Cyrillic side isn't a different file or a different route. It's the same React tree with &lt;code&gt;useTranslation()&lt;/code&gt;, plus the scripture passage in the left rail swapped from KJV (1769) to Synodal (1876) by a server-side substitution layer — see next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scripture-preservation constraint
&lt;/h2&gt;

&lt;p&gt;Most translation APIs (Gemini, GPT-4, DeepL) will happily paraphrase a Bible verse if you feed it into them as part of a longer paragraph. For a course where the exact wording of John 3:16 matters, that's a non-starter. "For God so loved the world" and "Because God loved the world this much" are not interchangeable in a Bible-study context, no matter how close the meaning.&lt;/p&gt;

&lt;p&gt;Equip solves this outside the LLM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pre-process incoming HTML, detect &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; + scripture-reference pairs.&lt;/li&gt;
&lt;li&gt;Swap the verse text for an opaque ASCII placeholder (&lt;code&gt;VERSE_&amp;lt;hex&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Send the prose-with-placeholders to Gemini Flash Lite with a system prompt that lists "preserve placeholders verbatim" as a hard rule.&lt;/li&gt;
&lt;li&gt;After the model returns, restore each placeholder with the canonical text from bundled translations: &lt;strong&gt;KJV 1769&lt;/strong&gt; for English, &lt;strong&gt;Synodal 1876&lt;/strong&gt; for Russian.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model never sees the verse text. The translation never paraphrases scripture. Implementation lives in &lt;code&gt;app/services/bible/substitution.py&lt;/code&gt; and is about 200 lines.&lt;/p&gt;

&lt;p&gt;There's a fallback in the system prompt for paraphrased quotes that the substitution layer can't confidently match (similarity &amp;lt; 0.80 to canonical) — "leave the original verse text untouched." That covers content where the substitution layer can't reliably do its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's wired in
&lt;/h2&gt;

&lt;p&gt;Equip is small in scope but talks to a few external services where it makes sense to. Each one is a deliberate choice, not a default:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;What it does in Equip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YouVersion Platform API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Verse-of-the-Day card on the home dashboard. Reads the YV verse-of-the-day endpoint, caches the result per (locale, date). YV registration is on the &lt;strong&gt;non-commercial&lt;/strong&gt; track, which is what shipped with our values from day one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Gemini Flash Lite&lt;/strong&gt; (Google AI)&lt;/td&gt;
&lt;td&gt;Bilingual content translation pipeline (above). Flash Lite is overkill on quality and underkill on cost for surface translation; this is the sweet spot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KJV 1769 + Synodal 1876&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bundled in the backend as JSON, served as the canonical scripture for the substitution layer. Public-domain, no API call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supabase Auth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google OAuth + email/password. JWT verification server-side. The &lt;code&gt;auth.uid()&lt;/code&gt; value flows through every RLS policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Datadog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browser-RUM on the SPA, log intake from the FastAPI backend, four synthetics on the public surfaces. Free tier.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transactional email (verify, password reset, certificate-ready notifications) from a verified &lt;code&gt;equipbible.com&lt;/code&gt; domain. Free tier.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sentry-class error attribution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not Sentry itself — Datadog's RUM + log correlation does the same job for our scale, single vendor.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern across all of these: pick a single best-fit service per layer, stay on free tiers as long as possible, never hide the integration behind a paywall.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does (the standard LMS surface)
&lt;/h2&gt;

&lt;p&gt;Courses → modules → chapters, rich content via TipTap (callouts, audio, embedded YouTube, images), quizzes (multiple choice, true/false, short-answer, essay), assignments with a teacher grading queue, certificates with teacher approval, gradebook, cohorts, calendar, announcements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, briefly
&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;Stack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 18, TypeScript, Vite, Tailwind, shadcn/ui, TipTap, motion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;FastAPI, Python 3.12, SQLAlchemy 2, Pydantic 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL on Supabase, RLS on every public table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Supabase Storage (avatars, course materials)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Vercel — static SPA at equipbible.com, Python serverless at api.equipbible.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;GitHub Actions: lint, typecheck, 700+ backend tests, 220+ frontend tests, npm audit, pip-audit, Postgres schema smoke, CodeQL, OpenSSF Scorecard, Dependabot auto-merge for patch/minor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Datadog (RUM, logs, synthetics)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total infra cost at current usage: &lt;strong&gt;$0&lt;/strong&gt;. Vercel, Supabase, Datadog, and Resend free tiers carry the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile, too
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3uuj1fs6p07jd1sko9d7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3uuj1fs6p07jd1sko9d7.png" alt="Equip sign-in on a 390px mobile viewport — single-column layout, same brand surface" width="390" height="844"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No native app; responsive web from 360px wide. A native app is months of work that buys little at this scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  State of production, 4 weeks in
&lt;/h2&gt;

&lt;p&gt;Real numbers from the database as of this morning, not aspirations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;14 users&lt;/strong&gt; (11 students, 2 teachers, 1 admin)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 published courses&lt;/strong&gt;, 1 in draft&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 modules, 28 chapters&lt;/strong&gt; of actual content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 enrollments&lt;/strong&gt; across the 3 courses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 chapters completed&lt;/strong&gt; by active students&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 certificates issued&lt;/strong&gt; (teacher-approved, full course completion)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 quiz attempts&lt;/strong&gt; submitted and graded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each user signed up with a real email, enrolled in a real course, and in three cases finished one and received a teacher-signed certificate. End-to-end flow runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's intentionally NOT there
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No "Discover" feed or social layer.&lt;/strong&gt; Students enroll because a teacher invited them, not because an algorithm recommended a course.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No live video conferencing.&lt;/strong&gt; Use Zoom or Jitsi separately. Equip stores course-event entries with a meeting-URL field. That's the integration surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No mobile app.&lt;/strong&gt; Responsive web works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No monetization features.&lt;/strong&gt; No paywalls, no premium tier, no "powered by" splash. MIT-licensed. Use it, fork it, deploy it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No "AI assistant" widget.&lt;/strong&gt; AI is in the translation pipeline as infrastructure, not in the UI as a feature. Bible-study students aren't asking for a chatbot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to actually use it
&lt;/h2&gt;

&lt;p&gt;Two paths, depending on what you want:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted at &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;.&lt;/strong&gt; Free. Sign up, you're in. Anyone can enroll in any published course. Teaching on the hosted instance is approval-based — the self-host path below has no such gate. No credit card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-host your own instance.&lt;/strong&gt; Clone &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;, point at your own Supabase project, deploy to Vercel. Bible schools that want their own URL, full data sovereignty, and isolation from other tenants get this. Total cost still $0 on the free tiers.&lt;/p&gt;

&lt;p&gt;If you're a Bible school or ministry running coursework on Google Sheets and WhatsApp groups, this is for you. Either path works. Pick the one that matches how much technical setup you want to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Near-term on the published roadmap: teacher onboarding flow (the first-login &lt;code&gt;/teacher&lt;/code&gt; page is too quiet right now), a completion-celebration moment (students don't know clearly when they finish a course), and a 15-minute self-host walkthrough for schools without a dev on staff.&lt;/p&gt;

&lt;p&gt;Longer-term: more language pairs (Spanish + Portuguese for missionary contexts) via &lt;a href="https://scripture.api.bible/" rel="noopener noreferrer"&gt;api.bible&lt;/a&gt;, in-platform certificate verification via signed JSON-LD, and a contribution path for translators to refine AI-translated content where wording matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;p&gt;Especially from three groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anyone running a small Bible school or ministry training program:&lt;/strong&gt; what's blocking you from moving off your current setup? Particularly curious about Russian-speaking schools in CIS countries where the Synodal canonical-text handling matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone who has built or contributed to a niche-domain LMS:&lt;/strong&gt; what's the one constraint you wish you'd designed for from day one? I'd rather steal your lessons than learn them again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone considering forking Equip for a different niche&lt;/strong&gt; (Sunday-school curriculum, a different religious tradition, secular adult-ed): tell me what'd need to change. Easier to plan the abstractions now than retrofit them later.&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;br&gt;
  &lt;tbody&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
  &lt;/tr&gt;
&lt;br&gt;
  &lt;tr&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
  &lt;/tr&gt;
&lt;br&gt;
&lt;/tbody&gt;
&lt;br&gt;
&lt;/table&gt;&lt;/div&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>supabase</category>
    </item>
    <item>
      <title>76 RLS policies rewritten in one migration: the auth.uid() init-plan trap in Supabase</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 20 May 2026 23:48:33 +0000</pubDate>
      <link>https://dev.to/arvavit/76-rls-policies-rewritten-in-one-migration-the-authuid-init-plan-trap-in-supabase-4hg</link>
      <guid>https://dev.to/arvavit/76-rls-policies-rewritten-in-one-migration-the-authuid-init-plan-trap-in-supabase-4hg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; If your Supabase RLS policies call &lt;code&gt;auth.uid()&lt;/code&gt; directly inside &lt;code&gt;USING(...)&lt;/code&gt; or &lt;code&gt;WITH CHECK(...)&lt;/code&gt;, Postgres re-evaluates the function once per row. Wrap it as &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; and the planner hoists the call to a single init plan that runs once per query. Same logical query, different plan, different cost at scale. We had 76 policies doing this wrong on Equip. Supabase's own &lt;code&gt;auth_rls_initplan&lt;/code&gt; advisor lint found every one of them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;Equip&lt;/a&gt; is an open-source LMS we run on &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt; — FastAPI backend, React frontend, Supabase Postgres with RLS on every public table. Migrations live in the repo as plain &lt;code&gt;.sql&lt;/code&gt; files. Standard setup, nothing exotic.&lt;/p&gt;

&lt;p&gt;A while back we ran Supabase's database advisor as part of a pre-deploy sweep and one warning stopped us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth_rls_initplan: Detects RLS policies that re-evaluate
auth functions for each row.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It listed 76 of our policies. Same warning every time, same root cause.&lt;/p&gt;
&lt;h2&gt;
  
  
  What &lt;code&gt;auth.uid()&lt;/code&gt; actually does per row
&lt;/h2&gt;

&lt;p&gt;A typical Equip policy at the time:&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="c1"&gt;-- BEFORE: auth.uid() called for every row scanned&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"assignments_update_teacher"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teacher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&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 &lt;code&gt;auth.uid()&lt;/code&gt; looks like a constant. It isn't. It's a Postgres function marked &lt;code&gt;STABLE&lt;/code&gt;, which means within one query it returns the same value, but the planner has to be told it can hoist the call out of the per-row loop. By default it doesn't. Every row scanned during the policy check fires the function again, allocates the JWT context, reads the claim, returns it.&lt;/p&gt;

&lt;p&gt;For a 50-row dev table you'd never notice. For a &lt;code&gt;chapter_progress&lt;/code&gt; table with one row per (student × chapter), or a &lt;code&gt;quiz_answers&lt;/code&gt; table that grows every time a student submits anything, it adds up fast.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that takes ten characters
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- AFTER: auth.uid() runs once, planner caches the result&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"assignments_update_teacher"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teacher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&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;Wrapping the call in a subquery isn't stylistic. The planner treats &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; as a subquery returning one row of one value, and pulls it out into an &lt;code&gt;InitPlan&lt;/code&gt; that runs exactly once at the start of the query. Every row check then sees a literal, not a function call.&lt;/p&gt;

&lt;p&gt;You can confirm this on your own database. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on the bare version shows a function call inside the filter. On the wrapped version it shows &lt;code&gt;InitPlan 1&lt;/code&gt; at the top and the filter references &lt;code&gt;$0&lt;/code&gt;. Same query, different plan.&lt;/p&gt;

&lt;p&gt;Supabase covers this under &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select" rel="noopener noreferrer"&gt;RLS performance recommendations&lt;/a&gt;, but it's easy to miss when you're writing policies by hand at the same pace you're shipping features.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why we missed it for months
&lt;/h2&gt;

&lt;p&gt;Honest moment: we shipped most of Equip's policies with the raw &lt;code&gt;auth.uid()&lt;/code&gt; pattern. They worked. Every test passed. Production users (students, teachers, admins) couldn't tell the difference. We were nowhere near the row count where the per-row overhead would have shown up as user-visible latency.&lt;/p&gt;

&lt;p&gt;That's the actual trap. The warning sign isn't "your app is slow." By the time it's slow you've already shipped a pile more policies in the same shape, because everything you wrote between then and now had no reason to fail. The trap is that this pattern works fine in dev and on a small prod, then quietly compounds when one table grows.&lt;/p&gt;

&lt;p&gt;Two patterns reliably hit the cliff first in our experience:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Per-user write tables&lt;/strong&gt; like &lt;code&gt;chapter_progress&lt;/code&gt; or &lt;code&gt;quiz_attempts&lt;/code&gt;. One row per (student × content), grows linearly with engagement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many-to-many junctions&lt;/strong&gt; like enrollments. Scanned during nearly every authenticated read.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Anything else (small lookup tables, course catalogs, taxonomy) stays fast for a long time even with the bad pattern. So the early "we don't see it yet" reading is correct, but it's not informative. The pattern is structurally wrong, you just haven't hit the row count that exposes it.&lt;/p&gt;
&lt;h2&gt;
  
  
  How we caught it: the database advisor
&lt;/h2&gt;

&lt;p&gt;The Supabase project dashboard has an &lt;strong&gt;Advisors&lt;/strong&gt; tab. Open it on any Postgres project and one of the performance lints is &lt;code&gt;auth_rls_initplan&lt;/code&gt;. It scans &lt;code&gt;pg_policies&lt;/code&gt;, parses the &lt;code&gt;qual&lt;/code&gt; and &lt;code&gt;with_check&lt;/code&gt; expressions, and flags any direct &lt;code&gt;auth.&amp;lt;function&amp;gt;()&lt;/code&gt; call that isn't wrapped.&lt;/p&gt;

&lt;p&gt;Two ways to run it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Supabase CLI (v2.81.3+)&lt;/span&gt;
supabase db advisors

&lt;span class="c"&gt;# Or via the dashboard:&lt;/span&gt;
&lt;span class="c"&gt;# https://supabase.com/dashboard/project/&amp;lt;ref&amp;gt;/advisors/performance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If you've wired the &lt;a href="https://supabase.com/docs/guides/getting-started/mcp" rel="noopener noreferrer"&gt;Supabase MCP server&lt;/a&gt; into your dev environment, &lt;code&gt;get_advisors&lt;/code&gt; returns the same list as a tool call you can run from an agent. Either way the output is a flat list of policy names. The migration itself is mechanical:&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="c1"&gt;-- For each flagged policy: DROP + recreate with the wrapped call&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;name&amp;gt;"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;name&amp;gt;"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Our &lt;code&gt;20260421015755_rls_perf_cleanup_016_policies.sql&lt;/code&gt; did this 76 times in a single migration. Mostly find-replace with care to keep the original semantics intact.&lt;/p&gt;
&lt;h2&gt;
  
  
  The PL/pgSQL footgun nobody mentions
&lt;/h2&gt;

&lt;p&gt;While you're already in there, one more thing: if you've abstracted any of this into a helper function (e.g. &lt;code&gt;is_admin()&lt;/code&gt; to keep policies DRY), check the language declaration.&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="c1"&gt;-- BAD: planner can't inline a plpgsql function, every call is opaque&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;STABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- GOOD: SQL functions get inlined into the policy at plan time&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;STABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;LANGUAGE sql&lt;/code&gt; + &lt;code&gt;STABLE&lt;/code&gt; + a single SELECT lets Postgres inline the function body into the policy at plan time. &lt;code&gt;EXPLAIN&lt;/code&gt; looks identical to inline EXISTS. Switch to &lt;code&gt;LANGUAGE plpgsql&lt;/code&gt; and the planner treats it as an opaque call. Can't push predicates in, can't reorder joins, every row hits the function.&lt;/p&gt;

&lt;p&gt;We don't use helpers in Equip yet, but multiple people in the Supabase community have hit this when extracting &lt;code&gt;is_member_of(school_id)&lt;/code&gt; for multi-tenant setups. The helper looks right, the policy looks DRY, the EXPLAIN looks wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to do today, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open your project's &lt;strong&gt;Advisors → Performance&lt;/strong&gt; tab. Look for &lt;code&gt;auth_rls_initplan&lt;/code&gt;. If the list is non-empty, you have the trap.&lt;/li&gt;
&lt;li&gt;Write a single migration that DROPs and recreates the flagged policies with &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; everywhere &lt;code&gt;auth.uid()&lt;/code&gt; appears bare. Don't try to refactor the policies' logic at the same time. Pure mechanical change.&lt;/li&gt;
&lt;li&gt;While you're in the advisor, glance at &lt;code&gt;multiple_permissive_policies&lt;/code&gt; and &lt;code&gt;policy_exists_rls_disabled&lt;/code&gt;. Both compound the same per-row cost. Multiple permissive policies on the same role + action each run separately, so two bad policies double the trap.&lt;/li&gt;
&lt;li&gt;If you have helper functions in policies, verify each is &lt;code&gt;LANGUAGE sql STABLE&lt;/code&gt; with a single SELECT body. Convert any &lt;code&gt;plpgsql&lt;/code&gt; ones if you can keep the logic in pure SQL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The advisor keeps flagging this on every new policy you write, so once you wire it into pre-merge checks (CI step, pre-deploy hook, or a habit on PR review) the trap doesn't come back.&lt;/p&gt;



&lt;p&gt;What I want to hear back, especially from people running Supabase in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has this lint caught anything for you &lt;em&gt;besides&lt;/em&gt; &lt;code&gt;auth.uid()&lt;/code&gt;? &lt;code&gt;auth.jwt() -&amp;gt;&amp;gt; 'role'&lt;/code&gt; should hit the same code path but I haven't traced it directly.&lt;/li&gt;
&lt;li&gt;If you've written &lt;code&gt;is_member_of(...)&lt;/code&gt; helpers for multi-tenant RLS, did you keep them in SQL or move to plpgsql? Curious about the tradeoff at scale.&lt;/li&gt;
&lt;li&gt;For anyone who left these unwrapped on purpose — what does that constraint look like? I can imagine a few cases where you'd want the per-row eval, but I can't think of any in policy code specifically.&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;br&gt;
  &lt;tbody&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
  &lt;/tr&gt;
&lt;br&gt;
  &lt;tr&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;
&lt;br&gt;
  &lt;/tr&gt;
&lt;br&gt;
&lt;/tbody&gt;
&lt;br&gt;
&lt;/table&gt;&lt;/div&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>4 footguns integrating YouVersion's new platform API — and a clean Verse of the Day</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Fri, 15 May 2026 18:39:44 +0000</pubDate>
      <link>https://dev.to/arvavit/4-footguns-integrating-youversions-new-platform-api-and-a-clean-verse-of-the-day-2087</link>
      <guid>https://dev.to/arvavit/4-footguns-integrating-youversions-new-platform-api-and-a-clean-verse-of-the-day-2087</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; YouVersion opened its API in April 2026. We wired it into our open-source Bible LMS as a Verse of the Day card on the dashboard. Four small things in their API surface cost us time. Writing them up so you don't repeat them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Equip is an open-source LMS for Bible schools (FastAPI + React + Supabase, MIT). A dashboard verse-of-the-day card is one of those quietly load-bearing features for a faith-shaped product — it gives the page a pastoral anchor on the right while keeping the actionable column primary, and creates a daily-return habit without screaming for engagement.&lt;/p&gt;

&lt;p&gt;YouVersion just opened &lt;strong&gt;YouVersion Platform&lt;/strong&gt;: 1,474 Bibles, 1,283 languages, REST API plus SDKs (Swift, Kotlin, React, React Native). Free for non-commercial use. So we plugged it in.&lt;/p&gt;

&lt;p&gt;The endpoints are simple. The friction is in the small print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun #1: &lt;code&gt;/v1/bibles&lt;/code&gt; returns 422 without &lt;code&gt;language_ranges[]&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;First instinct after &lt;code&gt;pip install httpx&lt;/code&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="c1"&gt;# Wrong. Will not work.
&lt;/span&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;headers&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;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Response:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Unprocessable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Entity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"missing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"loc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"language_ranges[]"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Field required"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The listing endpoint requires at least one ISO 639-3 language filter. We found that out the way everyone does — by reading the 422 body. Correct form:&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;language_ranges[]&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;eng&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# eng, rus, ukr, etc.
&lt;/span&gt;    &lt;span class="n"&gt;headers&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;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY&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;If you're calling from a JS client and reach for &lt;code&gt;URLSearchParams&lt;/code&gt;, the brackets matter — &lt;code&gt;language_ranges[]&lt;/code&gt; with the literal brackets works, &lt;code&gt;language_ranges&lt;/code&gt; without them does not.&lt;/p&gt;

&lt;p&gt;Side note while you're here: the auth header is &lt;code&gt;X-YVP-App-Key&lt;/code&gt;, not &lt;code&gt;Authorization: Bearer&lt;/code&gt;. The 401 you get from forgetting is generic enough that you'll sit there comparing your key character-by-character before going back to the docs. Pin the header name in the integration's docstring on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #2: &lt;code&gt;all_available=true&lt;/code&gt; reveals the real catalog
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;language_ranges[]=eng,rus,ukr&lt;/code&gt; we got &lt;strong&gt;11 Bibles back&lt;/strong&gt; — all English (ASV, BSB, Geneva, CPDV, etc.). Zero Russian. Zero Ukrainian.&lt;/p&gt;

&lt;p&gt;The marketing number is 1,474 translations. Where are they?&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&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;language_ranges[]&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;rus&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;all_available&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;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&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;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY&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 returns 5 Russian Bibles: NRT (Новый Русский Перевод — the modern Russian we needed), three CARS variants, and CSLAV (church Slavonic). For Ukrainian: just UKRK 1905 — pre-revolutionary, hard for modern students. No modern Огієнко or UBIO in the platform yet.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;strong&gt;the listing endpoint without &lt;code&gt;all_available&lt;/code&gt; shows what is enabled for your app key&lt;/strong&gt;, not what exists in the platform. Fetching a passage via &lt;code&gt;/bibles/{id}/passages/{ref}&lt;/code&gt; works for the broader catalog regardless of listing visibility — at least for non-commercial use as of this writing.&lt;/p&gt;

&lt;p&gt;Worth two minutes verifying for your own keys before you build a UI that says "no Russian translations available." We almost did.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #3: &lt;code&gt;content&lt;/code&gt; is HTML, even when you didn't ask
&lt;/h2&gt;

&lt;p&gt;The passage endpoint is the cleanest part of the API. You hand it a Bible ID and a USFM ref:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/bibles/&lt;/span&gt;&lt;span class="mi"&gt;143&lt;/span&gt;&lt;span class="err"&gt;/passages/JHN.&lt;/span&gt;&lt;span class="mf"&gt;3.16&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JHN.3.16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Ведь Бог так полюбил этот мир...&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"От Иоанна 3:16"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two things to notice. First, &lt;code&gt;reference&lt;/code&gt; is &lt;strong&gt;localized to the Bible's language&lt;/strong&gt; — "От Иоанна 3:16" for NRT, "John 3:16" for BSB, automatically. That is free i18n you would otherwise build yourself.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;content&lt;/code&gt; is HTML. Usually a thin &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; wrapper, sometimes with &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; markers, occasionally a chunk of whitespace bleeding from the source USFM. If you're rendering scripture in a plain-prose sidebar card, you have to strip:&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;_strip_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&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;-&amp;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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;[^&amp;gt;]+&amp;gt;&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Some responses pack newlines inside &amp;lt;p&amp;gt; from the source markup —
&lt;/span&gt;    &lt;span class="c1"&gt;# collapse whitespace so the card renders as a single tidy line.
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&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;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;YouVersion never injects user-supplied markup into &lt;code&gt;content&lt;/code&gt; (it is their licensed text — they control the source), so a regex strip is safe here. Do not copy this pattern verbatim to places where the content origin is untrusted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #4: their VOTD endpoint exists, but you cannot pick the verses
&lt;/h2&gt;

&lt;p&gt;YouVersion ships &lt;code&gt;/v1/verse_of_the_days/{day}&lt;/code&gt; that returns &lt;em&gt;their&lt;/em&gt; curated verse for that day of year:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/verse_of_the_days/&lt;/span&gt;&lt;span class="mi"&gt;135&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;135&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"passage_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ISA.12.2"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Useful if you want to mirror the YouVersion app behavior exactly. But it is their selection — you cannot pass a tag, theme, or audience filter. For an LMS aimed at Bible-school students we wanted curated passages that bias toward &lt;strong&gt;doctrinally-neutral, evergreen, ecumenical&lt;/strong&gt; scripture: no denomination-specific proof texts, nothing that translates badly into Russian or Ukrainian.&lt;/p&gt;

&lt;p&gt;So we rolled our own list — 250 references covering salvation, hope, comfort, love, faith, wisdom, prayer, and perseverance. Grouped by canon section: Gospels, Acts, Pauline epistles, Hebrews and General epistles, Revelation, Psalms, Proverbs, Wisdom and Prophets, Torah and Historical books, Job and Minor prophets.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape that worked: deterministic by day, cached by day
&lt;/h2&gt;

&lt;p&gt;Selection is a one-liner:&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;_pick_reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Everyone, everywhere, sees the same verse on a given UTC date.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_VERSES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toordinal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_VERSES&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;toordinal()&lt;/code&gt; gives a globally monotonic integer. Modulo the catalog size, you get a cycle that takes about eight months to repeat — long enough that no student sees the same verse twice in a school term. No randomness, no per-user state, no cache invalidation drama. The verse on a given date is the same in every browser on every device.&lt;/p&gt;

&lt;p&gt;Cache is a process-local dict keyed on &lt;code&gt;(date_iso, locale)&lt;/code&gt;. Evict any entry whose date is not today on every write — the cache never holds more than &lt;code&gt;len(supported_locales)&lt;/code&gt; entries. We support &lt;code&gt;en&lt;/code&gt; and &lt;code&gt;ru&lt;/code&gt;, so at most two entries deep:&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;with&lt;/span&gt; &lt;span class="n"&gt;_CACHE_LOCK&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;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CACHE&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;k&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;date_iso&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&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="n"&gt;_CACHE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two YouVersion calls per Python process per day. If the API key is unset (CI, fresh local dev), the service raises a typed exception and the route returns 404. The frontend treats 404 as "hide the card silently."&lt;/p&gt;

&lt;p&gt;Never block the dashboard on a third party. Same playbook as every other optional widget — but worth restating, because Bible-school students will not forgive a broken homepage on a Sunday.&lt;/p&gt;
&lt;h2&gt;
  
  
  A few things to weigh before you adopt this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-commercial lock-in is real.&lt;/strong&gt; You agree at app registration. Future paywalls, ads, or paid tiers revoke API access. For an open-source LMS this is fine. For a commercial product, model around it on day one — your &lt;code&gt;BibleProvider&lt;/code&gt; should be an adapter, not a hard import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Russian coverage is solid, Ukrainian is sparse.&lt;/strong&gt; Modern Russian is there (NRT). Ukrainian audiences still need a fallback layer for modern translations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SDKs ship UI components&lt;/strong&gt;, not just data clients. If you're using their React SDK you may not need most of the integration code above. We chose the REST API because Verse of the Day in our app is server-cached and renders in a slot we already control.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What I'd ask the room
&lt;/h2&gt;

&lt;p&gt;If you have integrated YouVersion Platform yourself — how are you handling the &lt;code&gt;locale → bible_id&lt;/code&gt; mapping when there are multiple translations in the same language? We pick one per locale (&lt;code&gt;en&lt;/code&gt; → BSB, &lt;code&gt;ru&lt;/code&gt; → NRT) because Verse of the Day is a one-card, one-rendering surface. For a reading-plan flow where users want to switch translations on the fly the design space is wider — curious what others have done.&lt;/p&gt;

&lt;p&gt;For anyone building Christian software in 2026 — does the non-commercial lock-in change how you architect, or do you accept it and never plan to monetize? Genuinely curious whether anyone has built around the constraint.&lt;/p&gt;

&lt;p&gt;And for the YouVersion Platform team if you read this: an &lt;code&gt;Accept: text/plain&lt;/code&gt; toggle on &lt;code&gt;/passages&lt;/code&gt; would let consumers skip the strip-HTML dance. Probably trivial on your side, ergonomic win on ours.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>fastapi</category>
      <category>react</category>
      <category>api</category>
    </item>
    <item>
      <title>Two days lost to PGRST116: when Supabase RLS hides a successful write</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 13 May 2026 19:36:01 +0000</pubDate>
      <link>https://dev.to/arvavit/two-days-lost-to-pgrst116-when-supabase-rls-hides-a-successful-write-4nch</link>
      <guid>https://dev.to/arvavit/two-days-lost-to-pgrst116-when-supabase-rls-hides-a-successful-write-4nch</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Our Supabase upsert wrote the row. The chained &lt;code&gt;.select().single()&lt;/code&gt; returned PGRST116. The wrapper read that as a failed write. The frontend retried. The retry was wrong. Two days to find why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We've been running an LMS on Supabase for the past several months — auth, RLS on every table, FastAPI talking to Postgres. The bug below cost us two days last quarter, and the fix changed how we read PGRST116 in our wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting
&lt;/h2&gt;

&lt;p&gt;We have a &lt;code&gt;quiz_attempts&lt;/code&gt; table. Each attempt gets created on quiz start, updated as the student progresses. The update is an upsert because a retry of the same &lt;code&gt;attempt_id&lt;/code&gt; should patch the existing row, not insert a duplicate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quiz_attempts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quiz_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt_count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;.select().single()&lt;/code&gt; chain returns the upserted row so we can show the student their new state.&lt;/p&gt;

&lt;p&gt;RLS policies, simplified:&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="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"students write their own attempts"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;with&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;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"students read their own attempts"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;is_visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The SELECT policy carries an extra &lt;code&gt;is_visible&lt;/code&gt; check that the write policy doesn't. That asymmetry is the seam the bug walks through.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;A trigger on &lt;code&gt;quiz_attempts&lt;/code&gt; flips &lt;code&gt;is_visible&lt;/code&gt; to false under a corner case (timing relative to a parallel write from an admin tool — the specifics aren't the point). The student's upsert committed: their fields were written, the row is theirs.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;.select().single()&lt;/code&gt; runs. The SELECT policy applies. &lt;code&gt;is_visible = false&lt;/code&gt;. PostgREST returns:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PGRST116"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Results contain 0 rows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JSON object requested, multiple (or no) rows returned"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Our runtime wrapper auto-threw on any &lt;code&gt;.error&lt;/code&gt;. To the upstream code, this looked identical to a failed upsert. The frontend retried with the same payload. The retry hit the same trigger. The user state diverged from the DB state. Etc.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this was hard
&lt;/h2&gt;

&lt;p&gt;PGRST116 has two completely different meanings when it comes after a mutation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The upsert truly failed&lt;/strong&gt; — constraint violation, missing required field, RLS denied the write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The upsert succeeded&lt;/strong&gt; — RLS just hid the returned row from the caller.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The wrapper conflated them. The PostgrestError code is identical. The HTTP status is identical (406). The only difference lives in the database, which the client can't see.&lt;/p&gt;

&lt;p&gt;Two days of logs because we kept treating "no row returned" as "no row written."&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't auto-throw on PGRST116 from a &lt;code&gt;.select().single()&lt;/code&gt; chained off a mutation.&lt;/strong&gt; Treat it as ambiguous and route to a separate branch:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeUpsertReturning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PGRST116&lt;/span&gt;&lt;span class="dl"&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;// The mutation may have succeeded; we just can't read the row.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;&lt;strong&gt;2. Service-role verification.&lt;/strong&gt; When the wrapper returns &lt;code&gt;hidden: true&lt;/code&gt;, the backend does a service-role read by &lt;code&gt;id&lt;/code&gt; to confirm whether the row actually exists. Yes → success-without-readback (treat as written). No → real failure, propagate. The client never branches on this directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Observability tying client errors to DB state.&lt;/strong&gt; Every PGRST116 from the wrapper emits a structured event with the query shape and request id. Server-side, we log the same id with the actual row state visible to service role. Correlating the two would have surfaced the mismatch on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd tell past-us
&lt;/h2&gt;

&lt;p&gt;PGRST116 is not a write error. It's a &lt;strong&gt;visibility&lt;/strong&gt; error.&lt;/p&gt;

&lt;p&gt;Your wrapper, your error handler, your retry logic — each should know which one it's seeing. If your stack can't tell the difference, you're going to retry successful writes. The kind of bug that produces is the kind where the symptom and the cause are twelve layers apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do you separate "write failed" from "write succeeded but I can't read it" in your Supabase code? What does the wrapper look like?&lt;/li&gt;
&lt;li&gt;Has anyone built a Postgres-side audit trigger that captures "row was written but RLS hid it from the writer"? Curious about the shape.&lt;/li&gt;
&lt;li&gt;For service-role verification — anything safer than &lt;code&gt;select(*).eq('id', id)&lt;/code&gt; you've used?&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;The project that runs this stack is open source:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The 3 i18n mistakes every open-source LMS makes</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 13 May 2026 03:21:32 +0000</pubDate>
      <link>https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk</link>
      <guid>https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Every open-source LMS treats internationalization as one problem. It's three. UI strings, user-generated content, and canonical artifacts each need a different mechanism. Most codebases collapse them into one — that's the bug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We've been building an open-source Bible school LMS for about 5 months. It runs in Russian and English (Ukrainian coming), with auto-translated user content and canonical-text preservation for scripture quotes. Building this forced me to look at how Moodle, Open edX, Canvas LMS, and Chamilo handle the same problem.&lt;/p&gt;

&lt;p&gt;The pattern is the same in all of them — and was the same in ours when we started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake #1: User-generated content treated like UI strings
&lt;/h2&gt;

&lt;p&gt;Every LMS has solid gettext-style infrastructure for UI strings. "Sign in", "Course catalog", "Submit assignment" — these live in &lt;code&gt;.po&lt;/code&gt; files (Moodle), Transifex (Open edX), &lt;code&gt;i18n-js&lt;/code&gt; (Canvas), YAML (Rails-based). A translator translates the file once. Done.&lt;/p&gt;

&lt;p&gt;User-generated content is a different problem entirely. When a teacher authors a course in Russian, the title — &lt;em&gt;"Введение в Послание к Римлянам"&lt;/em&gt; — is a row in &lt;code&gt;courses.title&lt;/code&gt;. An English-speaking student opens the catalog and sees Cyrillic. &lt;strong&gt;The UI is translated. The content isn't.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;UI strings&lt;/th&gt;
&lt;th&gt;User-generated content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Examples&lt;/td&gt;
&lt;td&gt;"Sign in", "Submit"&lt;/td&gt;
&lt;td&gt;Course title, lesson body, quiz question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;Fixed catalog of values&lt;/td&gt;
&lt;td&gt;Unbounded user input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool&lt;/td&gt;
&lt;td&gt;gettext / Transifex / YAML&lt;/td&gt;
&lt;td&gt;Runtime translation (Google, DeepL, Gemini)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When&lt;/td&gt;
&lt;td&gt;Build / release time&lt;/td&gt;
&lt;td&gt;Runtime (lazy or eager)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Locale file&lt;/td&gt;
&lt;td&gt;Separate cache table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common bug&lt;/td&gt;
&lt;td&gt;None major&lt;/td&gt;
&lt;td&gt;Treated like UI strings → only one language ever shown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Moodle's workaround is the multilang filter — you wrap content in &lt;code&gt;&amp;lt;span lang="ru"&amp;gt;…&amp;lt;/span&amp;gt;&amp;lt;span lang="en"&amp;gt;…&amp;lt;/span&amp;gt;&lt;/code&gt; and a filter shows the matching one. This is a 2008 solution. It puts the entire burden on the teacher: they must author every piece of content twice, in every language. Most don't, and the platform falls back to "show whatever the teacher wrote first."&lt;/p&gt;

&lt;p&gt;The shape of the right answer is a separate translation cache:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;content_translations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;entity_type&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'course', 'lesson', 'quiz_question'&lt;/span&gt;
  &lt;span class="n"&gt;entity_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'title', 'description', 'body'&lt;/span&gt;
  &lt;span class="n"&gt;locale&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'ru', 'en', 'uk', 'es'&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'human' | 'machine' | 'canonical'&lt;/span&gt;
  &lt;span class="n"&gt;cached_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&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_id&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;span class="n"&gt;locale&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;Teacher authors in one language. A translation worker fills in the others lazily (first request) or eagerly (on publish). The course-detail endpoint joins on &lt;code&gt;(entity_type, entity_id, field, viewer_locale)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architectural decision: &lt;strong&gt;content is not a UI string and cannot live in the same system.&lt;/strong&gt; If your LMS uses gettext for "Submit assignment" and the same mechanism for course titles, that's the bug.&lt;/p&gt;


&lt;h2&gt;
  
  
  Mistake #2: No accommodation for length variance
&lt;/h2&gt;

&lt;p&gt;English is dense. Russian runs ~25–30% longer for the same meaning. German is comparable. Finnish is worse. Arabic is shorter — and right-to-left, its own category.&lt;/p&gt;

&lt;p&gt;Most LMS UIs are designed in English. Buttons that fit "Save" don't fit "Сохранить". Nav tabs that fit "Courses" wrap to two lines for "Курсы и обучение". Mobile breaks first.&lt;/p&gt;

&lt;p&gt;Part of the fix is CSS:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Reserve space against the longer-language baseline */&lt;/span&gt;
&lt;span class="nc"&gt;.action-button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ellipsis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Tabular content: don't let translation reflow the grid */&lt;/span&gt;
&lt;span class="nc"&gt;.lesson-list-cell&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* fits 2-line Russian titles without shift */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;But the real fix is &lt;strong&gt;testing every screen in your longest-content language&lt;/strong&gt;, not just English. Most teams hire designers who only see the English mock and don't realize their "Continue" button is broken in Russian until a user reports it.&lt;/p&gt;

&lt;p&gt;What helped us: a Storybook locale switcher defaulting to Russian (not English), and a Playwright snapshot suite that screenshots both locales. The first commit that breaks Russian layout is caught in CI, not by a user.&lt;/p&gt;


&lt;h2&gt;
  
  
  Mistake #3: Canonical content forced through translation
&lt;/h2&gt;

&lt;p&gt;This is the bug that motivated our entire content-translation rewrite.&lt;/p&gt;

&lt;p&gt;A teacher in Russian writes: &lt;em&gt;"Послание к Римлянам 8:28 говорит, что все содействует ко благу"&lt;/em&gt;. The course gets auto-translated to English. Naively you send the whole string to Google Translate or DeepL, and you get back: &lt;em&gt;"Romans 8:28 says that everything works together for good."&lt;/em&gt; That's almost a real Bible verse — but it isn't quoting any actual translation. It's a translation of a translation.&lt;/p&gt;

&lt;p&gt;You don't want that. You want the actual KJV (or NIV, or ESV) text of Romans 8:28 spliced in.&lt;/p&gt;

&lt;p&gt;Same problem exists everywhere there's canonical content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Programming courses with code samples (don't translate the variables!)&lt;/li&gt;
&lt;li&gt;Math curricula with formulas&lt;/li&gt;
&lt;li&gt;Legal courses citing statutes&lt;/li&gt;
&lt;li&gt;Medical courses citing clinical-trial registries&lt;/li&gt;
&lt;li&gt;Literature courses with original-language quotes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most LMSes don't separate "translatable prose" from "canonical artifact" — so when they auto-translate, the canonical content gets mangled or invented.&lt;/p&gt;

&lt;p&gt;Our pattern: parse the source for canonical references, replace each with a placeholder token, translate the surrounding prose, then substitute the canonical text back from a separate lookup.&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;translate_with_canonical_preservation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&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;source_lang&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;target_lang&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;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Find canonical references in either language
&lt;/span&gt;    &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_bible_refs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# [{"raw": "Послание к Римлянам 8:28", "book": "ROM", "chapter": 8, "verses": [28]}]
&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. Replace each with a unique placeholder
&lt;/span&gt;    &lt;span class="n"&gt;placeholders&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&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;⟦CANON_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Translate the token-bearing string
&lt;/span&gt;    &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Substitute the canonical text back, looked up in the target translation
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;canonical_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup_canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# KJV for en, Synodal for ru
&lt;/span&gt;        &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;translated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical_text&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;translated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  How &lt;code&gt;extract_bible_refs&lt;/code&gt; handles the cross-language book-name matrix
&lt;/h3&gt;

&lt;p&gt;Detection is a regex over a normalized book-name dictionary. Each canonical book has an entry like:&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;BOOKS&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;ROM&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Romans&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;Rom&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;Rom.&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;ru&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="sh"&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="sh"&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="sh"&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="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uk&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="sh"&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="sh"&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="sh"&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;# ... 65 more books
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The regex is built per request as &lt;code&gt;(book_alias_1|book_alias_2|...)\s+(\d+):(\d+)(?:[-–](\d+))?&lt;/code&gt; so it accepts: &lt;code&gt;Romans 8:28&lt;/code&gt;, &lt;code&gt;Послание к Римлянам 8:28&lt;/code&gt;, &lt;code&gt;Рим 8:28&lt;/code&gt;, &lt;code&gt;Рим. 8:28&lt;/code&gt;, &lt;code&gt;Romans 8:28-29&lt;/code&gt;, &lt;code&gt;Romans 8:28a&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sundry edge cases: chapter-only refs (&lt;code&gt;Romans 8&lt;/code&gt; — whole-chapter), letter suffixes (&lt;code&gt;8:28a&lt;/code&gt; — first half of verse), em-dash vs hyphen, non-breaking spaces. Each lives in unit tests.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lookup_canonical&lt;/code&gt; pulls from a canonical text table keyed by &lt;code&gt;(book, chapter, verse_start, verse_end, translation)&lt;/code&gt;. Cache the final translated string keyed by &lt;code&gt;(entity_id, field, locale)&lt;/code&gt; per Mistake #1.&lt;/p&gt;

&lt;p&gt;This is ~400 lines we wouldn't have written if any of the LMSes we looked at had solved this for us.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;p&gt;These are the three patterns I keep seeing. They're not the only ones (cache invalidation on edits, RTL layout, Slavic plural forms — separate posts), but they're the ones every general-purpose LMS skips.&lt;/p&gt;

&lt;p&gt;If you've shipped i18n in an LMS, education platform, or any content product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do you separate UI strings from content?&lt;/strong&gt; What's your storage shape?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you handle length variance?&lt;/strong&gt; Do you test the long-language layout in CI?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you have canonical content that mustn't be translated?&lt;/strong&gt; Code samples, equations, citations? How are you handling it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious where teams have landed. The patterns above are our current best, not our last word.&lt;/p&gt;



&lt;p&gt;The project that drove all of this is open source:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>i18n</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <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://dev.to/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7</link>
      <guid>https://dev.to/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/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&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;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&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://dev.to/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg</link>
      <guid>https://dev.to/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/equip" rel="noopener noreferrer"&gt;equip&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/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&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;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Two weeks of building Equip 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://dev.to/arvavit/two-weeks-of-building-bible-school-lms-in-public-first-contributor-bilingual-content-real-11am</link>
      <guid>https://dev.to/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 Equip, 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/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt; (MIT, looking for stars and PRs)&lt;br&gt;
Live: &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&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/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Equip — open-source Bible 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://dev.to/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod</link>
      <guid>https://dev.to/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;Equip&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/equip" 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://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&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;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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