RSA onto the multi-tenant platform

Feature gaps between main and rsa-main, the schema facts that matter, and the migration steps.

Written 2026-08-30 by Khang Nguyen. Branches split at 8e4eb4a7 on 2026-01-30. Since then main took 291 commits, rsa-main took 51.

Fifty-one commits of divergence, and only two of them are real backend work

I expected a hard merge. It is not one. Most of rsa-main is already on main, either cherry-picked in both directions or reimplemented better. What is left fits in two tickets.

  • main and rsa-main split at commit 8e4eb4a7 on 2026-01-30. Since then main took 291 commits and rsa-main took 51.
  • Two rsa-main commits from 2026-08-29 are out of scope here: ddc9886f (SP-631, course-level quiz availability window, Thang Pham) and 4e3d1025 (SP-635, forgiving short-answer grading plus sticky timer, Steven Ngo). Their owners are landing them on main. Both block SP-645.
  • Target shape, decided 2026-08-30. RSA becomes one tenant inside the shared production database and R2 bucket, running the code that ships from main. The RSA database is a read-only source for a copy script and nothing else. We never run Alembic against it.
  • Only two real backend gaps: SP-505 quiz media (R2 images plus MP3 audio) and SP-518 discarded-image cleanup. Everything else on rsa-main is already on main or is dead code.
  • Frontend branding and the TODO-RSA hides are out of scope for the code work. They become tenant config.
  • Timeline (set 2026-08-31): the production window is 2026-08-31 22:00 to 2026-09-02 23:59. Compat PRs ship to prod first, a sample copy runs on beta, then the real copy runs on prod inside the window.

What rsa-main has that main already has

No gap, no port. Listed so nobody re-does the work by accident.

Featurersa-main commitWhere it is on main
SP-413/414/415 quiz attempt review, VN datetime format, start_time required 7f272580, 9a8e9b03, 563bd942 Same patches on main (git cherry match)
First-login role hotfix4fcb1762On main (#5)
Hide admin create-useree017a18On main (#148)
Python 3.14ef6efe33On main (#23)
Rich text renderinga5bcdc82 (#28)main #26
SP-402 admin unenroll and re-enroll17169f58main #21
SP-427 quiz .docx import 94053225 main #50, #52, #62. SP-622 (Khai Hoan, in progress) covers the RSA-specific import improvements
SP-434 bulk question save1bab6aeamain #54
SP-450 DigitalOcean deploy reworkd6e39287, 2dcab796main #69, #74
SP-482 RSA feedback patch (true/false import, MathText, QuizReviewItem) a9f68688 Mostly cherry-picks from main. MathText, QuizReviewItem, sanitize.ts, QuizSectionHeading and QuizQuestionForm all exist on main
SP-483 course settings page 546db78b main has SP-416 and its own pages/Instructor/CourseSettings/ with status, enrollment state, instructors, review policy and partner distribution
SP-510 short-answer grading modes9bcdf46amain #148, same column, different migration id
30-minute default time limit, section headings not counted8e2beb70main #273, which cherry-picked from this commit
Validate imported questions before confirm253516edmain #297
Request-completed log linec21959b0main #205, #229, a richer version

What rsa-main has that main does not

Three buckets. Only the first one gets ported.

PORT SP-646 SP-647
  • SP-505 quiz images in R2. POST /quizzes/{nanoid}/image and /image/from-url, pasted-image upload, base64 auto-migration to R2 under quiz-images/{quiz_nanoid}/, a strict media validator in services/rich_text.py, media copied on course duplicate and deleted on archive. Commits 11538497, 531959c5, bdf52708. On main the Quill editor image button still embeds base64 inline, which is the thing to kill.
  • SP-505 quiz MP3 audio. questions.audio_url column, migration c8f4d2a1b6e7 which also strips <audio> tags out of the rich text and into the column, POST /quizzes/{nanoid}/audio, QuizQuestionAudio.tsx, lib/quiz-audio-upload.ts. Commit 11538497.
  • SP-518 delete discarded quiz images on save. Commit 2d152677.
DEAD CODE, DO NOT PORT
  • The /storage/upload-url Azure SAS router (apps/backend/app/routers/v1/storage.py, schemas/storage.py). Main dropped it. Every upload on main is a presigned R2 PUT per feature.
  • entrypoint.sh with sshd, the Bitwarden secret resolver (main has been on Infisical since SP-506), the rsa_lms_rw DB role and the ssl=require hack.
FRONTEND ONLY, BECOMES TENANT CONFIG
  • RSA logo and footer contact (Header.tsx, Footer.tsx), the landing sections PainPointsSection, USPSection, FinalCTASection and CoursesSection, RSA banner images, hard-coded RSA testimonials in CourseOverviewTabs.tsx (commit a77b3aa3), AccountSettingsPage and NganLuongReturnPage.
  • TODO-RSA hides: the certificate page, essay and assignment lesson types, course pricing, About Us and FAQ, and the category filter.
  • Quiz editor behaviour reverts (c3753d1a, 41dfeb63, e79b30f5): RSA turned off question delete and reorder. Not porting. Main's editor is the one that ships.

RSA also uses its own Firebase project real-success-academy, its own R2 bucket and the DigitalOcean app rsa-prod. That is infra, handled in the steps below.

What main has that RSA will gain

Everything below is already running on main. RSA gets it the day it becomes a tenant.

Multi-tenancy Tenants, memberships, tenant-scoped roles (TENANT_ADMIN, INSTRUCTOR, STUDENT), managed subdomains and custom domains, branding, entitlements, staff invitations, per-tenant email and legal settings, usage and storage tracking, and the platform-admin app.
Commerce Vouchers, AlePay, SePay, billing info at checkout, trial enrollment, admin payment management.
Content H5P and slide lessons, blog, course tags, multiple difficulty levels, multi-instructor, ratings and reviews, hall of fame, contests, practice problems with a sandboxed judge runner, AI essay grading, a grading queue, assignment zip submissions, KaTeX, async quiz import and quiz attempt detail.
Engagement and ops Chat widget with admin escalation, AI tutor, email reminders and notifications, Redis cache, dynamic config, PostHog observability, request-id correlation.

Schema facts that matter, and which ones stop mattering

Half of the scary schema list disappears once you accept one rule. We never upgrade the RSA database. It is a read-only source. Every Alembic conflict below is moot because of that.

Finding
What we do
Alembic revision id collision. rsa-main head a9b8c7d6e5f4 is add_short_answer_grading_mode. On main a9b8c7d6e5f4 is add_metric_judge_timeout_snapshot.
Moot. We never upgrade the RSA database. Main only gets one fresh autogenerated migration, for questions.resources.
rsa-main has a merge migration 89c6b741ec79, and main rewired the down_revision of 0c99b84bce09, a1b2c3d4e5f6 and b9c8d7e6f5a4.
Moot for the same reason. Do not copy any rsa-main migration file into main.
short_answer_grading_mode exists on both sides. RSA has it from a9b8c7d6e5f4, main from f8e9d0c1b2a3.
Moot. The copy script maps the value 1:1.
questions.audio_url exists only on RSA.
Becomes questions.resources JSONB, a list of {"kind","url","mime"}. audio_url maps to one entry. Inline images stay in the HTML, because where they sit in the text matters.
Roles. RSA has users.roles[] plus is_superuser. Main has tenant_memberships.roles[] and users.is_platform_admin, and main's backfill migrations would turn ADMIN into PLATFORM_ADMIN.
The copy script maps ADMIN to TENANT_ADMIN on the RSA tenant. is_platform_admin stays false for everyone.
Firebase. RSA firebase_uid values come from the project real-success-academy, and the backend supports one service account.
Import the users into skill-pixel with their password hashes (firebase auth:export then auth:import), so uids are known at copy time. Fallback: insert with firebase_uid NULL and let claim_pending_by_email (apps/backend/app/repositories/user.py:93) claim the row on first login. Emails that already exist in SP are merged, not duplicated.
Primary keys. BIGINT ids collide with production rows.
Insert without id and let the sequences assign new ones. The script keeps an old to new map per table and remaps every foreign key in dependency order.
Quiz attempt answers are JSONB keyed by question nanoid (services/quiz_attempt.py:369), not by id.
No JSONB rewrite. Keep the nanoids.
tenant_id is NOT NULL on nearly every table, slugs are unique per tenant (uq_courses_tenant_slug), course level became a levels[] array, users gained nanoid, formal_name, phone, address and picture_url, and enrollments gained trial columns.
The script stamps tenant_id, wraps the level into a one-element array and leaves the new columns at their defaults.
R2 keys. RSA uses flat prefixes quiz-images/{quiz_nanoid}/, quiz-audio/ and course-thumbnails/ in its own bucket. Main uses tenants/{tenant_id}/{feature}/ since PR #352, and the storage-usage job only counts that layout.
Copy the objects into the tenant prefix and rewrite the URLs in the copied rows.
ENVIRONMENT=rsa in main's deploy-rsa.yml is not handled by 2283f8e01b9b_add_tenant_foundation.py.
Moot. The RSA deploy config gets deleted in SP-648.
Videos are on Bunny for both deployments.
Check whether it is the same library. If it is, the ids carry over untouched.

The plan

Ten steps, all sub-tasks of SP-645, all owned by Khang.

Step 1

Port SP-505 R2 quiz images and SP-518 cleanup to main

Depends onSP-631 and SP-635 landing on main
Done whenPasting an image on beta stores it under the tenant key, and discarded images are deleted on save.
SP-646 OWNER KHANG
Step 2

Add questions.resources and port SP-505 quiz audio to main

Generate the migration with alembic revision --autogenerate. Do not hand-copy the RSA one.

Depends onSP-631 and SP-635 landing on main
Done whenA student hears the MP3 during the attempt and again in review, on beta.
SP-647 OWNER KHANG
Step 3

Remove the RSA deployment config from main

deploy-rsa.yml, .env.rsa, the rsa-production Dockerfile stage and the RSA docs.

Depends onNothing. Can go any time.
Done whenNo RSA-specific deploy path exists on main.
SP-648 OWNER KHANG
Step 4

Provision the RSA tenant on beta and production

Create the tenant in platform-admin with open enrollment off, set entitlements and branding placeholders, and provision the managed subdomain (SP-633/634). Set up the custom domain through SP-617/618/619/477 but do not switch DNS yet.

Depends onNothing in this plan
Done whenThe RSA tenant exists on beta and production, reachable on its managed subdomain, closed to self-signup.
SP-649 OWNER KHANG
Step 5

Merge RSA Firebase users into skill-pixel

auth:export with the hash config, a dry-run import, email collision handling, then the full import, the uid map and the authorized domains.

Depends onNothing in this plan
Done whenEvery RSA account signs in against skill-pixel with its old password, and the uid map is written.
SP-650 OWNER KHANG
Step 6

Write the RSA to SP database copy script

Table order: users and memberships, then courses, chapters, lessons and videos, then quizzes, questions, assignments and essays, then enrollments, progress, attempts, submissions and certificates, then the optional banners and config. Rules: keep nanoids, assert no collisions, log the id maps, make re-runs idempotent, one transaction per table group.

Blocked bySP-646, SP-647, SP-649, SP-650
Done whenThe script copies a full RSA dump into a clean main database with matching per-table counts.
SP-651 OWNER KHANG
Step 7

Migrate RSA R2 media to the tenant key layout

Produce an old_url to new_url map for the copy script. Check Bunny while in here.

Depends onSP-649 for the tenant id
Done whenEvery RSA object sits under tenants/{tenant_id}/{feature}/ and the url map is complete.
SP-652 OWNER KHANG
Step 8

Rehearse locally and on beta

Local run first: a fresh dump against a clean main database, per-table counts, spot checks. Then beta, into the beta RSA tenant, with the QA checklist. Write the runbook and time the run.

Blocked bySP-651, SP-652
Done whenThe beta rehearsal passes the QA checklist and the runbook has a measured run time.
SP-653 OWNER KHANG
Step 9

Production cutover

Maintenance window, freeze rsa-prod, take the final dump, run the Firebase delta import, run the copy, activate the custom domain and switch DNS, smoke test. Keep a 2-week rollback window. Rollback is DNS back to DigitalOcean.

Blocked bySP-653
Done whenThe RSA domain serves the tenant on the shared platform and the smoke test passes.
SP-654 OWNER KHANG
Step 10

Decommission

Delete the rsa-prod app and the RSA database, archiving the final dump first. Delete the RSA bucket. Disable the real-success-academy Firebase project. Retire the RSA-only PostHog alerts (SP-447, SP-509). Tag rsa-main-final and archive the branch. Close SP-632 as superseded.

Blocked bySP-654 plus the 2-week rollback window
Done whenNo RSA-only infrastructure is running and the branch is archived.
SP-655 OWNER KHANG
Blocks all SP-631 SP-635 SP-645
Copy path SP-646SP-647SP-649SP-650 SP-651 SP-653 SP-654 SP-655
Media path SP-652 SP-653
Standalone SP-648

Related tickets

All of these already exist. Links go to https://skillpixel.atlassian.net/browse/<KEY>.

Open questions

Four answers I need before the cutover date is real.

  1. Is RSA video on the same Bunny library as SkillPixel?
  2. Does RSA have any real cart or transaction rows, or is commerce entirely off there?
  3. Which RSA staff already have a SkillPixel account under the same email? Those are the merge cases.
  4. How long a maintenance window can RSA accept for the cutover?