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
8e4eb4a7on 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) and4e3d1025(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.
What rsa-main has that main already has
No gap, no port. Listed so nobody re-does the work by accident.
| Feature | rsa-main commit | Where 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 hotfix | 4fcb1762 | On main (#5) |
| Hide admin create-user | ee017a18 | On main (#148) |
| Python 3.14 | ef6efe33 | On main (#23) |
| Rich text rendering | a5bcdc82 (#28) | main #26 |
| SP-402 admin unenroll and re-enroll | 17169f58 | main #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 save | 1bab6aea | main #54 |
| SP-450 DigitalOcean deploy rework | d6e39287, 2dcab796 | main #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 modes | 9bcdf46a | main #148, same column, different migration id |
| 30-minute default time limit, section headings not counted | 8e2beb70 | main #273, which cherry-picked from this commit |
| Validate imported questions before confirm | 253516ed | main #297 |
| Request-completed log line | c21959b0 | main #205, #229, a richer version |
What rsa-main has that main does not
Three buckets. Only the first one gets ported.
-
SP-505 quiz images in R2.
POST /quizzes/{nanoid}/imageand/image/from-url, pasted-image upload, base64 auto-migration to R2 underquiz-images/{quiz_nanoid}/, a strict media validator inservices/rich_text.py, media copied on course duplicate and deleted on archive. Commits11538497,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_urlcolumn, migrationc8f4d2a1b6e7which also strips<audio>tags out of the rich text and into the column,POST /quizzes/{nanoid}/audio,QuizQuestionAudio.tsx,lib/quiz-audio-upload.ts. Commit11538497. - SP-518 delete discarded quiz images on save. Commit
2d152677.
-
The
/storage/upload-urlAzure 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.shwith sshd, the Bitwarden secret resolver (main has been on Infisical since SP-506), thersa_lms_rwDB role and thessl=requirehack.
-
RSA logo and footer contact (
Header.tsx,Footer.tsx), the landing sections PainPointsSection, USPSection, FinalCTASection and CoursesSection, RSA banner images, hard-coded RSA testimonials inCourseOverviewTabs.tsx(commita77b3aa3), 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.
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.
a9b8c7d6e5f4 is add_short_answer_grading_mode. On main a9b8c7d6e5f4 is add_metric_judge_timeout_snapshot.questions.resources.89c6b741ec79, and main rewired the down_revision of 0c99b84bce09, a1b2c3d4e5f6 and b9c8d7e6f5a4.short_answer_grading_mode exists on both sides. RSA has it from a9b8c7d6e5f4, main from f8e9d0c1b2a3.questions.audio_url exists only on RSA.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.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.is_platform_admin stays false for everyone.firebase_uid values come from the project real-success-academy, and the backend supports one service account.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.services/quiz_attempt.py:369), not by id.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.tenant_id, wraps the level into a one-element array and leaves the new columns at their defaults.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.ENVIRONMENT=rsa in main's deploy-rsa.yml is not handled by 2283f8e01b9b_add_tenant_foundation.py.The plan
Ten steps, all sub-tasks of SP-645, all owned by Khang.
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.
Remove the RSA deployment config from main
deploy-rsa.yml, .env.rsa, the rsa-production Dockerfile stage and the RSA docs.
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.
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.
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.
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.
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.
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.
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.
Related tickets
All of these already exist. Links go to https://skillpixel.atlassian.net/browse/<KEY>.
- SP-645 master story, this plan. Blocked by SP-631 and SP-635. Relates to SP-371 (multi-tenancy support), SP-505, SP-518, SP-622, SP-632 and SP-369.
- SP-505 support MP3 audio prompts in quiz rich text. Done on rsa-main, Steven Ngo.
- SP-518 tech debt, clean up orphaned quiz images uploaded before save. Testing.
- SP-622 support RSA on quiz import. In progress, Khai Hoan.
-
SP-631 RSA course-level quiz availability window. Testing, Thang Pham.
SP-631BLOCKS SP-645
-
SP-635 RSA short-answer grading accuracy and reference-data quiz layout. Backlog.
SP-635BLOCKS SP-645
-
SP-632 RSA, temporarily restrict LMS access to approved accounts. Backlog. Superseded by the tenant with open enrollment off.
SP-632SUPERSEDED
- SP-369 RSA database and storage setup. Done. This is where the RSA database, bucket and role were created.
- SP-367 RSA infrastructure epic. Done, Dec 2025 to Jan 2026.
Open questions
Four answers I need before the cutover date is real.
- Is RSA video on the same Bunny library as SkillPixel?
- Does RSA have any real cart or transaction rows, or is commerce entirely off there?
- Which RSA staff already have a SkillPixel account under the same email? Those are the merge cases.
- How long a maintenance window can RSA accept for the cutover?